From aae6b06980721e4023fe37c8cec6fb71a57651e6 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 20 Jan 2026 17:32:29 -0500 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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(); } }