Skip to content

Commit 443f7c5

Browse files
committed
Add voice support to search bar on supported browsers
1 parent 0031f90 commit 443f7c5

12 files changed

Lines changed: 318 additions & 88 deletions

File tree

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
"@types/confusing-browser-globals": "1.0.3",
101101
"@types/cross-spawn": "6.0.6",
102102
"@types/dedent": "^0.7.2",
103+
"@types/dom-speech-recognition": "^0.0.7",
103104
"@types/eslint": "^9.6.1",
104105
"@types/eslint-plugin-security": "3.0.0",
105106
"@types/glidejs__glide": "^3.6.6",

src/components/Search/SearchBar/client/__tests__/index.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,31 @@ const flushMicrotasks = async () => {
3434
describe('SearchBar web component', () => {
3535
let container: AstroContainer
3636

37+
class MockSpeechRecognition {
38+
public continuous = false
39+
public interimResults = false
40+
public lang = 'en-US'
41+
42+
public onstart: (() => void) | null = null
43+
public onend: (() => void) | null = null
44+
public onerror: ((event: { error: string }) => void) | null = null
45+
public onresult: ((event: unknown) => void) | null = null
46+
47+
public start = vi.fn(() => {
48+
this.onstart?.()
49+
})
50+
51+
public stop = vi.fn(() => {
52+
this.onend?.()
53+
})
54+
}
55+
3756
beforeEach(async () => {
3857
container = await AstroContainer.create()
3958
searchQueryMock.mockReset()
59+
60+
delete (globalThis as unknown as Record<string, unknown>).SpeechRecognition
61+
delete (globalThis as unknown as Record<string, unknown>).webkitSpeechRecognition
4062
})
4163

4264
const runComponentRender = async (
@@ -166,8 +188,10 @@ describe('SearchBar web component', () => {
166188
const toggleBtn = element.querySelector('[data-search-toggle]') as HTMLButtonElement
167189
const input = element.querySelector('[data-search-input]') as HTMLInputElement
168190
const clearBtn = element.querySelector('[data-search-clear]') as HTMLButtonElement
191+
const micBtn = element.querySelector('[data-search-mic]') as HTMLButtonElement
169192

170193
expect(clearBtn.hasAttribute('hidden')).toBe(true)
194+
expect(micBtn.hasAttribute('hidden')).toBe(true)
171195

172196
toggleBtn.click()
173197
await flushMicrotasks()
@@ -177,6 +201,8 @@ describe('SearchBar web component', () => {
177201
await flushMicrotasks()
178202

179203
expect(clearBtn.hasAttribute('hidden')).toBe(false)
204+
// Mic stays hidden unless the browser supports speech recognition.
205+
expect(micBtn.hasAttribute('hidden')).toBe(true)
180206

181207
clearBtn.click()
182208
await flushMicrotasks()
@@ -186,4 +212,34 @@ describe('SearchBar web component', () => {
186212
expect(clearBtn.hasAttribute('hidden')).toBe(false)
187213
})
188214
})
215+
216+
it('fills the query from speech recognition when available', async () => {
217+
await runHeaderComponentRender(async ({ element, window }) => {
218+
;(window as unknown as { SpeechRecognition?: unknown }).SpeechRecognition = MockSpeechRecognition as unknown
219+
;(window as unknown as { webkitSpeechRecognition?: unknown }).webkitSpeechRecognition =
220+
MockSpeechRecognition as unknown
221+
222+
const toggleBtn = element.querySelector('[data-search-toggle]') as HTMLButtonElement
223+
const input = element.querySelector('[data-search-input]') as HTMLInputElement
224+
const micBtn = element.querySelector('[data-search-mic]') as HTMLButtonElement
225+
226+
toggleBtn.click()
227+
await flushMicrotasks()
228+
229+
micBtn.click()
230+
await flushMicrotasks()
231+
232+
// Simulate a recognition result.
233+
const recognition = (element as unknown as { speechRecognition?: MockSpeechRecognition }).speechRecognition
234+
expect(recognition).toBeTruthy()
235+
236+
recognition?.onresult?.({
237+
resultIndex: 0,
238+
results: [[{ transcript: 'hello world' }]],
239+
} as unknown)
240+
241+
await flushMicrotasks()
242+
expect(input.value).toBe('hello world')
243+
})
244+
})
189245
})

src/components/Search/SearchBar/client/index.ts

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@ export class SearchBarElement extends LitElement {
2424

2525
private toggleBtn: HTMLButtonElement | null = null
2626
private panel: HTMLElement | null = null
27+
private micBtn: HTMLButtonElement | null = null
2728
private clearBtn: HTMLButtonElement | null = null
2829
private isExpanded = true
2930

31+
private speechRecognition: SpeechRecognition | null = null
32+
private isListening = false
33+
3034
private isOutsideListenersAttached = false
3135
private unsubscribeHeaderSearchExpanded: (() => void) | null = null
3236

@@ -62,7 +66,7 @@ export class SearchBarElement extends LitElement {
6266

6367
private cacheElements(): void {
6468
const { form, input, resultsContainer, resultsList } = getSearchBarElements(this)
65-
const { toggleBtn, panel, clearBtn } = getSearchBarOptionalElements(this)
69+
const { toggleBtn, panel, micBtn, clearBtn } = getSearchBarOptionalElements(this)
6670

6771
this.form = form
6872
this.input = input
@@ -71,10 +75,12 @@ export class SearchBarElement extends LitElement {
7175

7276
this.toggleBtn = toggleBtn
7377
this.panel = panel
78+
this.micBtn = micBtn
7479
this.clearBtn = clearBtn
7580
this.isExpanded = this.toggleBtn ? getHeaderSearchExpanded() : this.getIsExpandedFromDom()
7681

7782
this.updateClearButtonVisibility()
83+
this.updateMicButtonVisibility()
7884
}
7985

8086
private initHeaderExpandedState(): void {
@@ -153,6 +159,11 @@ export class SearchBarElement extends LitElement {
153159
this.clearBtn.dataset['searchListener'] = 'true'
154160
}
155161

162+
if (this.micBtn && !this.micBtn.dataset['searchListener']) {
163+
this.micBtn.addEventListener('click', this.handleMicClick)
164+
this.micBtn.dataset['searchListener'] = 'true'
165+
}
166+
156167
if (!this.dataset['searchKeyListener']) {
157168
this.addEventListener('keydown', this.handleKeyDown)
158169
this.dataset['searchKeyListener'] = 'true'
@@ -189,6 +200,7 @@ export class SearchBarElement extends LitElement {
189200
}
190201

191202
if (!isExpanded) {
203+
this.stopSpeechRecognition()
192204
this.clearResults()
193205
this.hideResults()
194206
this.detachOutsideListeners()
@@ -197,6 +209,7 @@ export class SearchBarElement extends LitElement {
197209
}
198210

199211
this.updateClearButtonVisibility()
212+
this.updateMicButtonVisibility()
200213
}
201214

202215
private updateClearButtonVisibility(): void {
@@ -210,6 +223,141 @@ export class SearchBarElement extends LitElement {
210223
this.clearBtn.setAttribute('aria-label', query.length > 0 ? 'Clear search' : 'Close search')
211224
}
212225

226+
private getSpeechRecognitionCtor(): (new () => SpeechRecognition) | null {
227+
const view = (this.ownerDocument?.defaultView ?? null) as
228+
| {
229+
SpeechRecognition?: new () => SpeechRecognition
230+
webkitSpeechRecognition?: new () => SpeechRecognition
231+
}
232+
| null
233+
234+
const globalAny = globalThis as unknown as {
235+
SpeechRecognition?: new () => SpeechRecognition
236+
webkitSpeechRecognition?: new () => SpeechRecognition
237+
window?: {
238+
SpeechRecognition?: new () => SpeechRecognition
239+
webkitSpeechRecognition?: new () => SpeechRecognition
240+
}
241+
}
242+
243+
return (
244+
view?.SpeechRecognition ??
245+
view?.webkitSpeechRecognition ??
246+
globalAny.SpeechRecognition ??
247+
globalAny.webkitSpeechRecognition ??
248+
globalAny.window?.SpeechRecognition ??
249+
globalAny.window?.webkitSpeechRecognition ??
250+
null
251+
)
252+
}
253+
254+
private updateMicButtonVisibility(): void {
255+
if (!this.micBtn) {
256+
return
257+
}
258+
259+
const supported = this.getSpeechRecognitionCtor() !== null
260+
const shouldShow = supported && this.isExpanded
261+
this.micBtn.toggleAttribute('hidden', !shouldShow)
262+
this.micBtn.setAttribute('aria-label', this.isListening ? 'Stop voice search' : 'Voice search')
263+
}
264+
265+
private ensureSpeechRecognition(): SpeechRecognition | null {
266+
if (this.speechRecognition) {
267+
return this.speechRecognition
268+
}
269+
270+
const ctor = this.getSpeechRecognitionCtor()
271+
if (!ctor) {
272+
return null
273+
}
274+
275+
// Lazily create recognition; keeps SSR/older browsers safe.
276+
const recognition = new ctor()
277+
recognition.continuous = false
278+
recognition.interimResults = true
279+
280+
const docLang = document.documentElement.getAttribute('lang')
281+
recognition.lang = docLang && docLang.length > 0 ? docLang : 'en-US'
282+
283+
recognition.onstart = () => {
284+
this.isListening = true
285+
this.updateMicButtonVisibility()
286+
}
287+
288+
recognition.onend = () => {
289+
this.isListening = false
290+
this.updateMicButtonVisibility()
291+
}
292+
293+
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
294+
const context = { scriptName: 'SearchBarElement', operation: 'speechRecognition.onerror' }
295+
handleScriptError(new Error(`Speech recognition error: ${event.error}`), context)
296+
this.stopSpeechRecognition()
297+
}
298+
299+
recognition.onresult = (event: SpeechRecognitionEvent) => {
300+
if (!this.input) {
301+
return
302+
}
303+
304+
const transcript = this.getTranscriptFromSpeechEvent(event)
305+
if (!transcript) {
306+
return
307+
}
308+
309+
this.input.value = transcript
310+
this.input.dispatchEvent(new Event('input', { bubbles: true }))
311+
this.input.focus()
312+
}
313+
314+
this.speechRecognition = recognition
315+
return recognition
316+
}
317+
318+
private getTranscriptFromSpeechEvent(event: SpeechRecognitionEvent): string {
319+
// Note: Safari exposes only `webkitSpeechRecognition` and can behave differently.
320+
// We keep the extraction defensive so it works across implementations and in tests.
321+
const eventAny = event as unknown as {
322+
resultIndex?: number
323+
results?: ArrayLike<ArrayLike<{ transcript?: string } & { confidence?: number }> & { isFinal?: boolean }>
324+
}
325+
326+
const resultIndex = eventAny.resultIndex ?? 0
327+
const result = eventAny.results?.[resultIndex]
328+
const firstAlternative = result?.[0]
329+
return (firstAlternative?.transcript ?? '').trim()
330+
}
331+
332+
private startSpeechRecognition(): void {
333+
const recognition = this.ensureSpeechRecognition()
334+
if (!recognition) {
335+
this.updateMicButtonVisibility()
336+
return
337+
}
338+
339+
try {
340+
recognition.start()
341+
} catch (error) {
342+
// Some implementations throw if start() is called while already active.
343+
handleScriptError(error, { scriptName: 'SearchBarElement', operation: 'speechRecognition.start' })
344+
}
345+
}
346+
347+
private stopSpeechRecognition(): void {
348+
if (!this.speechRecognition) {
349+
this.isListening = false
350+
this.updateMicButtonVisibility()
351+
return
352+
}
353+
354+
try {
355+
this.speechRecognition.stop()
356+
} catch (error) {
357+
handleScriptError(error, { scriptName: 'SearchBarElement', operation: 'speechRecognition.stop' })
358+
}
359+
}
360+
213361
private expand(): void {
214362
if (!this.toggleBtn) {
215363
return
@@ -403,6 +551,23 @@ export class SearchBarElement extends LitElement {
403551
this.input.focus()
404552
}
405553

554+
private readonly handleMicClick = () => {
555+
if (!this.input) {
556+
return
557+
}
558+
559+
if (!this.isExpanded) {
560+
this.expand()
561+
}
562+
563+
if (this.isListening) {
564+
this.stopSpeechRecognition()
565+
return
566+
}
567+
568+
this.startSpeechRecognition()
569+
}
570+
406571
private showResults(): void {
407572
this.resultsContainer?.classList.remove('hidden')
408573
}

src/components/Search/SearchBar/client/selectors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const SELECTORS = {
1111
export const OPTIONAL_SELECTORS = {
1212
toggleBtn: '[data-search-toggle]',
1313
panel: '[data-search-panel]',
14+
micBtn: '[data-search-mic]',
1415
clearBtn: '[data-search-clear]',
1516
} as const
1617

@@ -65,11 +66,13 @@ export function getSearchBarElements(context: Element) {
6566
export function getSearchBarOptionalElements(context: Element) {
6667
const toggleBtn = context.querySelector(OPTIONAL_SELECTORS.toggleBtn)
6768
const panel = context.querySelector(OPTIONAL_SELECTORS.panel)
69+
const micBtn = context.querySelector(OPTIONAL_SELECTORS.micBtn)
6870
const clearBtn = context.querySelector(OPTIONAL_SELECTORS.clearBtn)
6971

7072
return {
7173
toggleBtn: toggleBtn instanceof HTMLButtonElement ? toggleBtn : null,
7274
panel: panel instanceof HTMLElement ? panel : null,
75+
micBtn: micBtn instanceof HTMLButtonElement ? micBtn : null,
7376
clearBtn: clearBtn instanceof HTMLButtonElement ? clearBtn : null,
7477
}
7578
}

src/components/Search/SearchBar/index.astro

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
import Icon from '@components/Icon/index.astro'
33
import closeSvg from '../../../icons/close.svg?raw'
4+
import microphoneSvg from '../../../icons/microphone.svg?raw'
45
import styles from './index.module.css'
56
67
export interface Props {
@@ -56,6 +57,16 @@ const clearButtonLabel = hasInitialQuery ? 'Clear search' : 'Close search'
5657
]}
5758
/>
5859

60+
<button
61+
data-search-mic
62+
type="button"
63+
aria-label="Voice search"
64+
hidden
65+
class="relative inline-flex items-center justify-center w-(--header-icon-size) h-(--header-icon-size) shrink-0 rounded-full border-0 bg-transparent text-content-active hover:text-primary focus:outline-none focus-visible:text-primary transition-colors duration-150 ease-linear"
66+
>
67+
<span aria-hidden="true" set:html={microphoneSvg} />
68+
</button>
69+
5970
<button
6071
data-search-clear
6172
type="button"

0 commit comments

Comments
 (0)