@@ -159,7 +159,7 @@ export class ComboboxController<
159159 Item extends HTMLElement
160160> implements ReactiveController {
161161 public static of < T extends HTMLElement > (
162- host : ReactiveControllerHost ,
162+ host : ReactiveControllerHost & HTMLElement ,
163163 options : ComboboxControllerOptions < T > ,
164164 ) : ComboboxController < T > {
165165 return new ComboboxController ( host , options ) ;
@@ -237,11 +237,13 @@ export class ComboboxController<
237237
238238 #lb: ListboxController < Item > ;
239239 #fc?: ATFocusController < Item > ;
240+ #initializing = false ;
240241 #preventListboxGainingFocus = false ;
241242 #input: HTMLElement | null = null ;
242243 #button: HTMLElement | null = null ;
243244 #listbox: HTMLElement | null = null ;
244245 #buttonInitialRole: string | null = null ;
246+ #buttonHasMouseDown = false ;
245247 #mo = new MutationObserver ( ( ) => this . #initItems( ) ) ;
246248 #microcopy = new Map < string , Record < Lang , string > > ( Object . entries ( {
247249 dimmed : {
@@ -280,6 +282,7 @@ export class ComboboxController<
280282
281283 set items ( value : Item [ ] ) {
282284 this . #lb. items = value ;
285+ this . #fc?. refreshItems ?.( ) ;
283286 }
284287
285288 /** Whether the combobox is disabled */
@@ -326,7 +329,7 @@ export class ComboboxController<
326329 }
327330
328331 private constructor (
329- public host : ReactiveControllerHost ,
332+ public host : ReactiveControllerHost & HTMLElement ,
330333 options : ComboboxControllerOptions < Item > ,
331334 ) {
332335 host . addController ( this ) ;
@@ -362,7 +365,7 @@ export class ComboboxController<
362365 }
363366
364367 hostUpdated ( ) : void {
365- if ( ! this . #fc) {
368+ if ( ! this . #fc && ! this . #initializing ) {
366369 this . #init( ) ;
367370 }
368371 const expanded = this . options . isExpanded ( ) ;
@@ -380,7 +383,7 @@ export class ComboboxController<
380383 ComboboxController . hosts . delete ( this . host ) ;
381384 }
382385
383- async _onFocusoutElement ( ) : Promise < void > {
386+ private async _onFocusoutElement ( ) : Promise < void > {
384387 if ( this . #hasTextInput && this . options . isExpanded ( ) ) {
385388 const root = this . #element?. getRootNode ( ) ;
386389 await new Promise ( requestAnimationFrame ) ;
@@ -397,13 +400,15 @@ export class ComboboxController<
397400 * Order of operations is important
398401 */
399402 async #init( ) {
403+ this . #initializing = true ;
400404 await this . host . updateComplete ;
401405 this . #initListbox( ) ;
402406 this . #initItems( ) ;
403407 this . #initButton( ) ;
404408 this . #initInput( ) ;
405409 this . #initLabels( ) ;
406410 this . #initController( ) ;
411+ this . #initializing = false ;
407412 }
408413
409414 #initListbox( ) {
@@ -425,6 +430,8 @@ export class ComboboxController<
425430 #initButton( ) {
426431 this . #button?. removeEventListener ( 'click' , this . #onClickButton) ;
427432 this . #button?. removeEventListener ( 'keydown' , this . #onKeydownButton) ;
433+ this . #button?. removeEventListener ( 'mousedown' , this . #onMousedownButton) ;
434+ this . #button?. removeEventListener ( 'mouseup' , this . #onMouseupButton) ;
428435 this . #button = this . options . getToggleButton ( ) ;
429436 if ( ! this . #button) {
430437 throw new Error ( 'ComboboxController getToggleButton() option must return an element' ) ;
@@ -434,6 +441,8 @@ export class ComboboxController<
434441 this . #button. setAttribute ( 'aria-controls' , this . #listbox?. id ?? '' ) ;
435442 this . #button. addEventListener ( 'click' , this . #onClickButton) ;
436443 this . #button. addEventListener ( 'keydown' , this . #onKeydownButton) ;
444+ this . #button. addEventListener ( 'mousedown' , this . #onMousedownButton) ;
445+ this . #button. addEventListener ( 'mouseup' , this . #onMouseupButton) ;
437446 }
438447
439448 #initInput( ) {
@@ -531,26 +540,32 @@ export class ComboboxController<
531540 return strings ?. [ lang ] ?? key ;
532541 }
533542
534- // TODO(bennypowers): perhaps move this to ActivedescendantController
535- #announce( item : Item ) {
543+ /**
544+ * Announces the focused item to a live region (e.g. for Safari VoiceOver).
545+ * @param item - The listbox option item to announce.
546+ * TODO(bennypowers): perhaps move this to ActivedescendantController
547+ */
548+ #announce( item : Item ) : void {
536549 const value = this . options . getItemValue ( item ) ;
537550 ComboboxController . #alert?. remove ( ) ;
538551 const fragment = ComboboxController . #alertTemplate. content . cloneNode ( true ) as DocumentFragment ;
539552 ComboboxController . #alert = fragment . firstElementChild as HTMLElement ;
540553 let text = value ;
541554 const lang = deepClosest ( this . #listbox, '[lang]' ) ?. getAttribute ( 'lang' ) ?? 'en' ;
542- const langKey = lang ?. match ( ComboboxController . langsRE ) ?. at ( 0 ) as Lang ?? 'en' ;
555+ const langKey = ( lang ?. match ( ComboboxController . langsRE ) ?. at ( 0 ) as Lang ) ?? 'en' ;
543556 if ( this . options . isItemDisabled ( item ) ) {
544557 text += ` (${ this . #translate( 'dimmed' , langKey ) } )` ;
545558 }
546559 if ( this . #lb. isSelected ( item ) ) {
547560 text += `, (${ this . #translate( 'selected' , langKey ) } )` ;
548561 }
549- if ( item . hasAttribute ( 'aria-setsize' ) && item . hasAttribute ( 'aria-posinset' ) ) {
562+ const posInSet = InternalsController . getAriaPosInSet ( item ) ;
563+ const setSize = InternalsController . getAriaSetSize ( item ) ;
564+ if ( posInSet != null && setSize != null ) {
550565 if ( langKey === 'ja' ) {
551- text += `, (${ item . getAttribute ( 'aria-setsize' ) } 件中 ${ item . getAttribute ( 'aria-posinset' ) } 件目)` ;
566+ text += `, (${ setSize } 件中 ${ posInSet } 件目)` ;
552567 } else {
553- text += `, (${ item . getAttribute ( 'aria-posinset' ) } ${ this . #translate( 'of' , langKey ) } ${ item . getAttribute ( 'aria-setsize' ) } )` ;
568+ text += `, (${ posInSet } ${ this . #translate( 'of' , langKey ) } ${ setSize } )` ;
554569 }
555570 }
556571 ComboboxController . #alert. lang = lang ;
@@ -580,6 +595,17 @@ export class ComboboxController<
580595 }
581596 } ;
582597
598+ /**
599+ * Distinguish click-to-toggle vs Tab/Shift+Tab
600+ */
601+ #onMousedownButton = ( ) => {
602+ this . #buttonHasMouseDown = true ;
603+ } ;
604+
605+ #onMouseupButton = ( ) => {
606+ this . #buttonHasMouseDown = false ;
607+ } ;
608+
583609 #onClickListbox = ( event : MouseEvent ) => {
584610 if ( ! this . multi && event . composedPath ( ) . some ( this . options . isItem ) ) {
585611 this . #hide( ) ;
@@ -735,9 +761,14 @@ export class ComboboxController<
735761 #onFocusoutListbox = ( event : FocusEvent ) => {
736762 if ( ! this . #hasTextInput && this . options . isExpanded ( ) ) {
737763 const root = this . #element?. getRootNode ( ) ;
764+ // Check if focus moved to the toggle button via mouse click
765+ // If so, let the click handler manage toggle (prevents double-toggle)
766+ // But if focus moved via Shift+Tab (no mousedown), we should still hide
767+ const isClickOnToggleButton =
768+ event . relatedTarget === this . #button && this . #buttonHasMouseDown;
738769 if ( ( root instanceof ShadowRoot || root instanceof Document )
739770 && ! this . items . includes ( event . relatedTarget as Item )
740- ) {
771+ && ! isClickOnToggleButton ) {
741772 this . #hide( ) ;
742773 }
743774 }
0 commit comments