@@ -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 }
0 commit comments