@@ -242,6 +242,7 @@ export class ComboboxController<
242242 #button: HTMLElement | null = null ;
243243 #listbox: HTMLElement | null = null ;
244244 #buttonInitialRole: string | null = null ;
245+ #buttonHasMouseDown = false ;
245246 #mo = new MutationObserver ( ( ) => this . #initItems( ) ) ;
246247 #microcopy = new Map < string , Record < Lang , string > > ( Object . entries ( {
247248 dimmed : {
@@ -425,6 +426,8 @@ export class ComboboxController<
425426 #initButton( ) {
426427 this . #button?. removeEventListener ( 'click' , this . #onClickButton) ;
427428 this . #button?. removeEventListener ( 'keydown' , this . #onKeydownButton) ;
429+ this . #button?. removeEventListener ( 'mousedown' , this . #onMousedownButton) ;
430+ this . #button?. removeEventListener ( 'mouseup' , this . #onMouseupButton) ;
428431 this . #button = this . options . getToggleButton ( ) ;
429432 if ( ! this . #button) {
430433 throw new Error ( 'ComboboxController getToggleButton() option must return an element' ) ;
@@ -434,6 +437,8 @@ export class ComboboxController<
434437 this . #button. setAttribute ( 'aria-controls' , this . #listbox?. id ?? '' ) ;
435438 this . #button. addEventListener ( 'click' , this . #onClickButton) ;
436439 this . #button. addEventListener ( 'keydown' , this . #onKeydownButton) ;
440+ this . #button. addEventListener ( 'mousedown' , this . #onMousedownButton) ;
441+ this . #button. addEventListener ( 'mouseup' , this . #onMouseupButton) ;
437442 }
438443
439444 #initInput( ) {
@@ -531,26 +536,32 @@ export class ComboboxController<
531536 return strings ?. [ lang ] ?? key ;
532537 }
533538
534- // TODO(bennypowers): perhaps move this to ActivedescendantController
535- #announce( item : Item ) {
539+ /**
540+ * Announces the focused item to a live region (e.g. for Safari VoiceOver).
541+ * @param item - The listbox option item to announce.
542+ * TODO(bennypowers): perhaps move this to ActivedescendantController
543+ */
544+ #announce( item : Item ) : void {
536545 const value = this . options . getItemValue ( item ) ;
537546 ComboboxController . #alert?. remove ( ) ;
538547 const fragment = ComboboxController . #alertTemplate. content . cloneNode ( true ) as DocumentFragment ;
539548 ComboboxController . #alert = fragment . firstElementChild as HTMLElement ;
540549 let text = value ;
541550 const lang = deepClosest ( this . #listbox, '[lang]' ) ?. getAttribute ( 'lang' ) ?? 'en' ;
542- const langKey = lang ?. match ( ComboboxController . langsRE ) ?. at ( 0 ) as Lang ?? 'en' ;
551+ const langKey = ( lang ?. match ( ComboboxController . langsRE ) ?. at ( 0 ) as Lang ) ?? 'en' ;
543552 if ( this . options . isItemDisabled ( item ) ) {
544553 text += ` (${ this . #translate( 'dimmed' , langKey ) } )` ;
545554 }
546555 if ( this . #lb. isSelected ( item ) ) {
547556 text += `, (${ this . #translate( 'selected' , langKey ) } )` ;
548557 }
549- if ( item . hasAttribute ( 'aria-setsize' ) && item . hasAttribute ( 'aria-posinset' ) ) {
558+ const posInSet = InternalsController . getAriaPosInSet ( item ) ;
559+ const setSize = InternalsController . getAriaSetSize ( item ) ;
560+ if ( posInSet != null && setSize != null ) {
550561 if ( langKey === 'ja' ) {
551- text += `, (${ item . getAttribute ( 'aria-setsize' ) } 件中 ${ item . getAttribute ( 'aria-posinset' ) } 件目)` ;
562+ text += `, (${ setSize } 件中 ${ posInSet } 件目)` ;
552563 } else {
553- text += `, (${ item . getAttribute ( 'aria-posinset' ) } ${ this . #translate( 'of' , langKey ) } ${ item . getAttribute ( 'aria-setsize' ) } )` ;
564+ text += `, (${ posInSet } ${ this . #translate( 'of' , langKey ) } ${ setSize } )` ;
554565 }
555566 }
556567 ComboboxController . #alert. lang = lang ;
@@ -580,6 +591,17 @@ export class ComboboxController<
580591 }
581592 } ;
582593
594+ /**
595+ * Distinguish click-to-toggle vs Tab/Shift+Tab
596+ */
597+ #onMousedownButton = ( ) => {
598+ this . #buttonHasMouseDown = true ;
599+ } ;
600+
601+ #onMouseupButton = ( ) => {
602+ this . #buttonHasMouseDown = false ;
603+ } ;
604+
583605 #onClickListbox = ( event : MouseEvent ) => {
584606 if ( ! this . multi && event . composedPath ( ) . some ( this . options . isItem ) ) {
585607 this . #hide( ) ;
@@ -735,9 +757,14 @@ export class ComboboxController<
735757 #onFocusoutListbox = ( event : FocusEvent ) => {
736758 if ( ! this . #hasTextInput && this . options . isExpanded ( ) ) {
737759 const root = this . #element?. getRootNode ( ) ;
760+ // Check if focus moved to the toggle button via mouse click
761+ // If so, let the click handler manage toggle (prevents double-toggle)
762+ // But if focus moved via Shift+Tab (no mousedown), we should still hide
763+ const isClickOnToggleButton =
764+ event . relatedTarget === this . #button && this . #buttonHasMouseDown;
738765 if ( ( root instanceof ShadowRoot || root instanceof Document )
739766 && ! this . items . includes ( event . relatedTarget as Item )
740- ) {
767+ && ! isClickOnToggleButton ) {
741768 this . #hide( ) ;
742769 }
743770 }
0 commit comments