@@ -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( ) {
@@ -504,6 +513,8 @@ export class ComboboxController<
504513 }
505514
506515 async #show( ) : Promise < void > {
516+ // Re-read items on open so slotted/dynamically added options are included:
517+ this . #initItems( ) ;
507518 const success = await this . options . requestShowListbox ( ) ;
508519 this . #filterItems( ) ;
509520 if ( success !== false && ! this . #hasTextInput) {
@@ -531,26 +542,32 @@ export class ComboboxController<
531542 return strings ?. [ lang ] ?? key ;
532543 }
533544
534- // TODO(bennypowers): perhaps move this to ActivedescendantController
535- #announce( item : Item ) {
545+ /**
546+ * Announces the focused item to a live region (e.g. for Safari VoiceOver).
547+ * @param item - The listbox option item to announce.
548+ * TODO(bennypowers): perhaps move this to ActivedescendantController
549+ */
550+ #announce( item : Item ) : void {
536551 const value = this . options . getItemValue ( item ) ;
537552 ComboboxController . #alert?. remove ( ) ;
538553 const fragment = ComboboxController . #alertTemplate. content . cloneNode ( true ) as DocumentFragment ;
539554 ComboboxController . #alert = fragment . firstElementChild as HTMLElement ;
540555 let text = value ;
541556 const lang = deepClosest ( this . #listbox, '[lang]' ) ?. getAttribute ( 'lang' ) ?? 'en' ;
542- const langKey = lang ?. match ( ComboboxController . langsRE ) ?. at ( 0 ) as Lang ?? 'en' ;
557+ const langKey = ( lang ?. match ( ComboboxController . langsRE ) ?. at ( 0 ) as Lang ) ?? 'en' ;
543558 if ( this . options . isItemDisabled ( item ) ) {
544559 text += ` (${ this . #translate( 'dimmed' , langKey ) } )` ;
545560 }
546561 if ( this . #lb. isSelected ( item ) ) {
547562 text += `, (${ this . #translate( 'selected' , langKey ) } )` ;
548563 }
549- if ( item . hasAttribute ( 'aria-setsize' ) && item . hasAttribute ( 'aria-posinset' ) ) {
564+ const posInSet = InternalsController . getAriaPosInSet ( item ) ;
565+ const setSize = InternalsController . getAriaSetSize ( item ) ;
566+ if ( posInSet != null && setSize != null ) {
550567 if ( langKey === 'ja' ) {
551- text += `, (${ item . getAttribute ( 'aria-setsize' ) } 件中 ${ item . getAttribute ( 'aria-posinset' ) } 件目)` ;
568+ text += `, (${ setSize } 件中 ${ posInSet } 件目)` ;
552569 } else {
553- text += `, (${ item . getAttribute ( 'aria-posinset' ) } ${ this . #translate( 'of' , langKey ) } ${ item . getAttribute ( 'aria-setsize' ) } )` ;
570+ text += `, (${ posInSet } ${ this . #translate( 'of' , langKey ) } ${ setSize } )` ;
554571 }
555572 }
556573 ComboboxController . #alert. lang = lang ;
@@ -580,6 +597,17 @@ export class ComboboxController<
580597 }
581598 } ;
582599
600+ /**
601+ * Distinguish click-to-toggle vs Tab/Shift+Tab
602+ */
603+ #onMousedownButton = ( ) => {
604+ this . #buttonHasMouseDown = true ;
605+ } ;
606+
607+ #onMouseupButton = ( ) => {
608+ this . #buttonHasMouseDown = false ;
609+ } ;
610+
583611 #onClickListbox = ( event : MouseEvent ) => {
584612 if ( ! this . multi && event . composedPath ( ) . some ( this . options . isItem ) ) {
585613 this . #hide( ) ;
@@ -735,9 +763,14 @@ export class ComboboxController<
735763 #onFocusoutListbox = ( event : FocusEvent ) => {
736764 if ( ! this . #hasTextInput && this . options . isExpanded ( ) ) {
737765 const root = this . #element?. getRootNode ( ) ;
766+ // Check if focus moved to the toggle button via mouse click
767+ // If so, let the click handler manage toggle (prevents double-toggle)
768+ // But if focus moved via Shift+Tab (no mousedown), we should still hide
769+ const isClickOnToggleButton =
770+ event . relatedTarget === this . #button && this . #buttonHasMouseDown;
738771 if ( ( root instanceof ShadowRoot || root instanceof Document )
739772 && ! this . items . includes ( event . relatedTarget as Item )
740- ) {
773+ && ! isClickOnToggleButton ) {
741774 this . #hide( ) ;
742775 }
743776 }
0 commit comments