From aae6b06980721e4023fe37c8cec6fb71a57651e6 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 20 Jan 2026 17:32:29 -0500 Subject: [PATCH 1/5] fix(core): if `relatedTarget` is toggle, let `#onClickButton` manage toggle behavior --- core/pfe-core/controllers/combobox-controller.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index c69e129ea5..ca5292c017 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -735,9 +735,12 @@ export class ComboboxController< #onFocusoutListbox = (event: FocusEvent) => { if (!this.#hasTextInput && this.options.isExpanded()) { const root = this.#element?.getRootNode(); + // Check if focus moved to the toggle button + // If so, let the click handler manage toggle + const isToggleButton = event.relatedTarget === this.#button; if ((root instanceof ShadowRoot || root instanceof Document) && !this.items.includes(event.relatedTarget as Item) - ) { + && !isToggleButton) { this.#hide(); } } From 66793e6541f0e83205596aa94ee5e779bfee6d50 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 20 Jan 2026 17:35:25 -0500 Subject: [PATCH 2/5] fix(core): ensure RTI syncs AT focus when using a combo of mouse and keyboard --- .../controllers/roving-tabindex-controller.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core/pfe-core/controllers/roving-tabindex-controller.ts b/core/pfe-core/controllers/roving-tabindex-controller.ts index 9659a11095..6189ac689b 100644 --- a/core/pfe-core/controllers/roving-tabindex-controller.ts +++ b/core/pfe-core/controllers/roving-tabindex-controller.ts @@ -77,6 +77,21 @@ export class RovingTabindexController< if (container instanceof HTMLElement) { container.addEventListener('focusin', () => this.#gainedInitialFocus = true, { once: true }); + // Sync atFocusedItemIndex when an item receives DOM focus (e.g., via mouse click) + // This ensures keyboard navigation starts from the correct position + container.addEventListener('focusin', (event: FocusEvent) => { + const target = event.target as Item; + const index = this.items.indexOf(target); + // Only update if the target is a valid item and index differs + if (index >= 0 && index !== this.atFocusedItemIndex) { + // Update index via setter, but avoid the focus() call by temporarily + // clearing #gainedInitialFocus to prevent redundant focus + const hadInitialFocus = this.#gainedInitialFocus; + this.#gainedInitialFocus = false; + this.atFocusedItemIndex = index; + this.#gainedInitialFocus = hadInitialFocus; + } + }); } else { this.#logger.warn('RovingTabindexController requires a getItemsContainer function'); } From 97e92882269eb6139cf3eb0846ddeb5343d3a1ae Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 20 Jan 2026 17:38:51 -0500 Subject: [PATCH 3/5] fix(core): force to always search forward for Home and backward for End key presses --- core/pfe-core/controllers/at-focus-controller.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts index c8d099df06..03da55a838 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -49,8 +49,13 @@ export abstract class ATFocusController { set atFocusedItemIndex(index: number) { const previousIndex = this.#atFocusedItemIndex; - const direction = index > previousIndex ? 1 : -1; const { items, atFocusableItems } = this; + // - Home (index=0): always search forward to find first focusable item + // - End (index=last): always search backward to find last focusable item + // - Other cases: use comparison to determine direction + const direction = index === 0 ? 1 + : index >= items.length - 1 ? -1 + : index > previousIndex ? 1 : -1; const itemsIndexOfLastATFocusableItem = items.indexOf(this.atFocusableItems.at(-1)!); let itemToGainFocus = items.at(index); let itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!); From 4132c4c0b0a55942d79d38ac82d2ec90cc31e0b7 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Wed, 21 Jan 2026 17:08:11 -0500 Subject: [PATCH 4/5] fix(core): hide listbox on Shift+Tab when moving to the toggle button --- .../controllers/combobox-controller.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index ca5292c017..a79c43abc0 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -242,6 +242,7 @@ export class ComboboxController< #button: HTMLElement | null = null; #listbox: HTMLElement | null = null; #buttonInitialRole: string | null = null; + #buttonHasMouseDown = false; #mo = new MutationObserver(() => this.#initItems()); #microcopy = new Map>(Object.entries({ dimmed: { @@ -425,6 +426,8 @@ export class ComboboxController< #initButton() { this.#button?.removeEventListener('click', this.#onClickButton); this.#button?.removeEventListener('keydown', this.#onKeydownButton); + this.#button?.removeEventListener('mousedown', this.#onMousedownButton); + this.#button?.removeEventListener('mouseup', this.#onMouseupButton); this.#button = this.options.getToggleButton(); if (!this.#button) { throw new Error('ComboboxController getToggleButton() option must return an element'); @@ -434,6 +437,8 @@ export class ComboboxController< this.#button.setAttribute('aria-controls', this.#listbox?.id ?? ''); this.#button.addEventListener('click', this.#onClickButton); this.#button.addEventListener('keydown', this.#onKeydownButton); + this.#button.addEventListener('mousedown', this.#onMousedownButton); + this.#button.addEventListener('mouseup', this.#onMouseupButton); } #initInput() { @@ -580,6 +585,17 @@ export class ComboboxController< } }; + /** + * Distinguish click-to-toggle vs Tab/Shift+Tab + */ + #onMousedownButton = () => { + this.#buttonHasMouseDown = true; + }; + + #onMouseupButton = () => { + this.#buttonHasMouseDown = false; + }; + #onClickListbox = (event: MouseEvent) => { if (!this.multi && event.composedPath().some(this.options.isItem)) { this.#hide(); @@ -735,12 +751,14 @@ export class ComboboxController< #onFocusoutListbox = (event: FocusEvent) => { if (!this.#hasTextInput && this.options.isExpanded()) { const root = this.#element?.getRootNode(); - // Check if focus moved to the toggle button - // If so, let the click handler manage toggle - const isToggleButton = event.relatedTarget === this.#button; + // Check if focus moved to the toggle button via mouse click + // If so, let the click handler manage toggle (prevents double-toggle) + // But if focus moved via Shift+Tab (no mousedown), we should still hide + const isClickOnToggleButton = + event.relatedTarget === this.#button && this.#buttonHasMouseDown; if ((root instanceof ShadowRoot || root instanceof Document) && !this.items.includes(event.relatedTarget as Item) - && !isToggleButton) { + && !isClickOnToggleButton) { this.#hide(); } } From ba6a0dcee08d8feb87629870a318219c2d60647b Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Mon, 2 Feb 2026 16:26:42 -0500 Subject: [PATCH 5/5] feat(core): add optional `setItems` callback to listbox and combobox controller --- .../controllers/combobox-controller.ts | 28 +++++++++++++++++-- .../controllers/listbox-controller.ts | 26 ++++++++++++----- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index a79c43abc0..06c07894f1 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -140,6 +140,17 @@ export interface ComboboxControllerOptions extends * By default, toggles the `hidden` attribute on the item */ setItemHidden?(item: Item, hidden: boolean): void; + /** + * Optional. When provided, passed to ListboxController so it does not set + * aria-setsize/aria-posinset on items. + */ + setItems?(items: Item[]): void; + /** + * Optional. Returns position-in-set and set size for the focused item when + * building the Safari VoiceOver live-region announcement ("N of M"). + * When not provided, the controller reads aria-posinset/aria-setsize from the item. + */ + getItemPosition?(item: Item, items: Item[]): { posInSet: number; setSize: number } | null; } /** @@ -352,6 +363,7 @@ export class ComboboxController< getATFocusedItem: () => this.items[this.#fc?.atFocusedItemIndex ?? -1] ?? null, isItemDisabled: this.options.isItemDisabled, setItemSelected: this.options.setItemSelected, + setItems: this.options.setItems, }); ComboboxController.instances.set(host, this); ComboboxController.hosts.add(host); @@ -551,11 +563,21 @@ export class ComboboxController< if (this.#lb.isSelected(item)) { text += `, (${this.#translate('selected', langKey)})`; } - if (item.hasAttribute('aria-setsize') && item.hasAttribute('aria-posinset')) { + const position = + typeof this.options.getItemPosition === 'function' ? + this.options.getItemPosition(item, this.items) + : null; + const posInSet = + position?.posInSet + ?? (item.hasAttribute('aria-posinset') ? item.getAttribute('aria-posinset') : null); + const setSize = + position?.setSize + ?? (item.hasAttribute('aria-setsize') ? item.getAttribute('aria-setsize') : null); + if (posInSet != null && setSize != null) { if (langKey === 'ja') { - text += `, (${item.getAttribute('aria-setsize')} 件中 ${item.getAttribute('aria-posinset')} 件目)`; + text += `, (${setSize} 件中 ${posInSet} 件目)`; } else { - text += `, (${item.getAttribute('aria-posinset')} ${this.#translate('of', langKey)} ${item.getAttribute('aria-setsize')})`; + text += `, (${posInSet} ${this.#translate('of', langKey)} ${setSize})`; } } ComboboxController.#alert.lang = lang; diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 22c4123c05..dda1d56bd0 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -59,6 +59,12 @@ export interface ListboxControllerOptions { * a combobox input. */ getControlsElements?(): HTMLElement[]; + /** + * Optional callback when items are set. When provided, the controller does **not** + * set aria-setsize/aria-posinset on each item; the caller is responsible for list + * semantics (e.g. via ElementInternals). + */ + setItems?(items: Item[]): void; } /** @@ -192,16 +198,22 @@ export class ListboxController implements ReactiveCont } /** - * register's the host's Item elements as listbox controller items - * sets aria-setsize and aria-posinset on items - * @param items items + * Registers the host's Item elements as listbox controller items. + * If options provides a setItems function, that function is called with the items. + * Otherwise, sets aria-setsize and aria-posinset on each item. + * @param items - The Item elements to register */ set items(items: Item[]) { this.#items = items; - this.#items.forEach((item, index, _items) => { - item.ariaSetSize = _items.length.toString(); - item.ariaPosInSet = (index + 1).toString(); - }); + const { setItems } = this.#options; + if (typeof setItems === 'function') { + setItems(items); + } else { + this.#items.forEach((item, index, _items) => { + item.ariaSetSize = _items.length.toString(); + item.ariaPosInSet = (index + 1).toString(); + }); + } } /**