From aae6b06980721e4023fe37c8cec6fb71a57651e6 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 20 Jan 2026 17:32:29 -0500 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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(); + }); + } } /** From 17e43f1518ba7bfa07ad239d3818efeb0748a35b Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 3 Feb 2026 14:43:55 -0500 Subject: [PATCH 06/18] Revert "feat(core): add optional `setItems` callback to listbox and combobox controller" This reverts commit ba6a0dcee08d8feb87629870a318219c2d60647b. --- .../controllers/combobox-controller.ts | 28 ++----------------- .../controllers/listbox-controller.ts | 26 +++++------------ 2 files changed, 10 insertions(+), 44 deletions(-) diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index 06c07894f1..a79c43abc0 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -140,17 +140,6 @@ 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; } /** @@ -363,7 +352,6 @@ 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); @@ -563,21 +551,11 @@ export class ComboboxController< if (this.#lb.isSelected(item)) { text += `, (${this.#translate('selected', langKey)})`; } - 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 (item.hasAttribute('aria-setsize') && item.hasAttribute('aria-posinset')) { if (langKey === 'ja') { - text += `, (${setSize} 件中 ${posInSet} 件目)`; + text += `, (${item.getAttribute('aria-setsize')} 件中 ${item.getAttribute('aria-posinset')} 件目)`; } else { - text += `, (${posInSet} ${this.#translate('of', langKey)} ${setSize})`; + text += `, (${item.getAttribute('aria-posinset')} ${this.#translate('of', langKey)} ${item.getAttribute('aria-setsize')})`; } } ComboboxController.#alert.lang = lang; diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index dda1d56bd0..22c4123c05 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -59,12 +59,6 @@ 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; } /** @@ -198,22 +192,16 @@ export class ListboxController implements ReactiveCont } /** - * 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 + * register's the host's Item elements as listbox controller items + * sets aria-setsize and aria-posinset on items + * @param items items */ set items(items: Item[]) { this.#items = items; - 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(); - }); - } + this.#items.forEach((item, index, _items) => { + item.ariaSetSize = _items.length.toString(); + item.ariaPosInSet = (index + 1).toString(); + }); } /** From cdf6ea113289c37573330a8f4abf77acc2a364ed Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Tue, 3 Feb 2026 15:47:30 -0500 Subject: [PATCH 07/18] feat(core): let the internals controller handle `aria-posinset` and `aria-setsize` --- .../controllers/combobox-controller.ts | 18 ++++-- .../controllers/internals-controller.ts | 57 +++++++++++++++++++ .../controllers/listbox-controller.ts | 19 ++++--- 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index a79c43abc0..e9e0434d14 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -536,26 +536,32 @@ export class ComboboxController< return strings?.[lang] ?? key; } - // TODO(bennypowers): perhaps move this to ActivedescendantController - #announce(item: Item) { + /** + * Announces the focused item to a live region (e.g. for Safari VoiceOver). + * @param item - The listbox option item to announce. + * TODO(bennypowers): perhaps move this to ActivedescendantController + */ + #announce(item: Item): void { const value = this.options.getItemValue(item); ComboboxController.#alert?.remove(); const fragment = ComboboxController.#alertTemplate.content.cloneNode(true) as DocumentFragment; ComboboxController.#alert = fragment.firstElementChild as HTMLElement; let text = value; const lang = deepClosest(this.#listbox, '[lang]')?.getAttribute('lang') ?? 'en'; - const langKey = lang?.match(ComboboxController.langsRE)?.at(0) as Lang ?? 'en'; + const langKey = (lang?.match(ComboboxController.langsRE)?.at(0) as Lang) ?? 'en'; if (this.options.isItemDisabled(item)) { text += ` (${this.#translate('dimmed', langKey)})`; } if (this.#lb.isSelected(item)) { text += `, (${this.#translate('selected', langKey)})`; } - if (item.hasAttribute('aria-setsize') && item.hasAttribute('aria-posinset')) { + const posInSet = InternalsController.getAriaPosInSet(item); + const setSize = InternalsController.getAriaSetSize(item); + 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/internals-controller.ts b/core/pfe-core/controllers/internals-controller.ts index de9684c366..dfcf017bf6 100644 --- a/core/pfe-core/controllers/internals-controller.ts +++ b/core/pfe-core/controllers/internals-controller.ts @@ -83,6 +83,63 @@ export class InternalsController implements ReactiveController, ARIAMixin { return Array.from(this.instances.get(host)?.internals.labels ?? []) as Element[]; } + /** + * Sets aria-posinset on a listbox item. Uses ElementInternals when the host has + * an InternalsController instance; otherwise sets/removes the host attribute. + * @param host - The listbox item element (option or option-like). + * @param value - Position in set (1-based), or null to clear. + */ + public static setAriaPosInSet(host: Element, value: number | string | null): void { + const instance = this.instances.get(host as unknown as ReactiveControllerHost); + if (instance) { + instance.ariaPosInSet = value != null ? String(value) : null; + } else if (value != null) { + host.setAttribute('aria-posinset', String(value)); + } else { + host.removeAttribute('aria-posinset'); + } + } + + /** + * Sets aria-setsize on a listbox item. Uses ElementInternals when the host has + * an InternalsController instance; otherwise sets/removes the host attribute. + * @param host - The listbox item element (option or option-like). + * @param value - Total set size, or null to clear. + */ + public static setAriaSetSize(host: Element, value: number | string | null): void { + const instance = this.instances.get(host as unknown as ReactiveControllerHost); + if (instance) { + instance.ariaSetSize = value != null ? String(value) : null; + } else if (value != null) { + host.setAttribute('aria-setsize', String(value)); + } else { + host.removeAttribute('aria-setsize'); + } + } + + /** + * Gets aria-posinset from a listbox item (internals or attribute). + * @param host - The listbox item element. + */ + public static getAriaPosInSet(host: Element): string | null { + const instance = this.instances.get(host as unknown as ReactiveControllerHost); + return instance != null ? + instance.ariaPosInSet + : host.getAttribute('aria-posinset'); + } + + /** + * Gets aria-setsize from a listbox item (internals or attribute). + * @param host - The listbox item element. + */ + public static getAriaSetSize(host: Element): string | null { + const instance = this.instances.get(host as unknown as ReactiveControllerHost); + return instance != null ? + instance.ariaSetSize + : host.getAttribute('aria-setsize'); + } + + public static isSafari: boolean = !isServer && /^((?!chrome|android).)*safari/i.test(navigator.userAgent); diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 22c4123c05..04957a8d09 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -3,6 +3,7 @@ import type { RequireProps } from '../core.ts'; import { isServer } from 'lit'; import { arraysAreEquivalent } from '../functions/arraysAreEquivalent.js'; +import { InternalsController } from './internals-controller.js'; /** * Options for listbox controller @@ -192,16 +193,11 @@ 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. + * @param items - Array of listbox option elements. */ set items(items: Item[]) { this.#items = items; - this.#items.forEach((item, index, _items) => { - item.ariaSetSize = _items.length.toString(); - item.ariaPosInSet = (index + 1).toString(); - }); } /** @@ -268,6 +264,10 @@ export class ListboxController implements ReactiveCont } } + /** + * Called during host update; syncs control element listeners and + * applies aria-posinset/aria-setsize to each item via InternalsController. + */ hostUpdate(): void { const last = this.#controlsElements; this.#controlsElements = this.#options.getControlsElements?.() ?? []; @@ -278,6 +278,11 @@ export class ListboxController implements ReactiveCont el.addEventListener('keyup', this.#onKeyup); } } + const items = this.#items; + items.forEach((item, index) => { + InternalsController.setAriaPosInSet(item, index + 1); + InternalsController.setAriaSetSize(item, items.length); + }); } hostUpdated(): void { From e594b00799f1c318ea7c6705d4595db8ab8656ef Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 4 Feb 2026 10:08:48 +0200 Subject: [PATCH 08/18] fix(core): narrow host type for combobox, internals controllers --- .../controllers/combobox-controller.ts | 4 +- .../controllers/internals-controller.ts | 55 +++++++------------ 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index e9e0434d14..e40998dfaa 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -159,7 +159,7 @@ export class ComboboxController< Item extends HTMLElement > implements ReactiveController { public static of( - host: ReactiveControllerHost, + host: ReactiveControllerHost & HTMLElement, options: ComboboxControllerOptions, ): ComboboxController { return new ComboboxController(host, options); @@ -327,7 +327,7 @@ export class ComboboxController< } private constructor( - public host: ReactiveControllerHost, + public host: ReactiveControllerHost & HTMLElement, options: ComboboxControllerOptions, ) { host.addController(this); diff --git a/core/pfe-core/controllers/internals-controller.ts b/core/pfe-core/controllers/internals-controller.ts index dfcf017bf6..0f2940b927 100644 --- a/core/pfe-core/controllers/internals-controller.ts +++ b/core/pfe-core/controllers/internals-controller.ts @@ -2,7 +2,6 @@ import { isServer, type ReactiveController, type ReactiveControllerHost, - type LitElement, } from 'lit'; function isARIAMixinProp(key: string): key is keyof ARIAMixin { @@ -59,8 +58,8 @@ function aria( protos.get(target).add(key); } -function getLabelText(label: HTMLElement) { - if (label.hidden) { +function getLabelText(label: Node) { + if (!(label instanceof HTMLElement) || label.hidden) { return ''; } else { const ariaLabel = label.getAttribute?.('aria-label'); @@ -68,8 +67,10 @@ function getLabelText(label: HTMLElement) { } } +type InternalsHost = ReactiveControllerHost & HTMLElement; + export class InternalsController implements ReactiveController, ARIAMixin { - private static instances = new WeakMap(); + private static instances = new WeakMap(); declare readonly form: ElementInternals['form']; declare readonly shadowRoot: ElementInternals['shadowRoot']; @@ -79,7 +80,7 @@ export class InternalsController implements ReactiveController, ARIAMixin { declare readonly willValidate: ElementInternals['willValidate']; declare readonly validationMessage: ElementInternals['validationMessage']; - public static getLabels(host: ReactiveControllerHost): Element[] { + public static getLabels(host: InternalsHost): Element[] { return Array.from(this.instances.get(host)?.internals.labels ?? []) as Element[]; } @@ -89,8 +90,8 @@ export class InternalsController implements ReactiveController, ARIAMixin { * @param host - The listbox item element (option or option-like). * @param value - Position in set (1-based), or null to clear. */ - public static setAriaPosInSet(host: Element, value: number | string | null): void { - const instance = this.instances.get(host as unknown as ReactiveControllerHost); + public static setAriaPosInSet(host: HTMLElement, value: number | string | null): void { + const instance = this.instances.get(host); if (instance) { instance.ariaPosInSet = value != null ? String(value) : null; } else if (value != null) { @@ -106,8 +107,8 @@ export class InternalsController implements ReactiveController, ARIAMixin { * @param host - The listbox item element (option or option-like). * @param value - Total set size, or null to clear. */ - public static setAriaSetSize(host: Element, value: number | string | null): void { - const instance = this.instances.get(host as unknown as ReactiveControllerHost); + public static setAriaSetSize(host: HTMLElement, value: number | string | null): void { + const instance = this.instances.get(host); if (instance) { instance.ariaSetSize = value != null ? String(value) : null; } else if (value != null) { @@ -121,8 +122,8 @@ export class InternalsController implements ReactiveController, ARIAMixin { * Gets aria-posinset from a listbox item (internals or attribute). * @param host - The listbox item element. */ - public static getAriaPosInSet(host: Element): string | null { - const instance = this.instances.get(host as unknown as ReactiveControllerHost); + public static getAriaPosInSet(host: HTMLElement): string | null { + const instance = this.instances.get(host); return instance != null ? instance.ariaPosInSet : host.getAttribute('aria-posinset'); @@ -132,21 +133,17 @@ export class InternalsController implements ReactiveController, ARIAMixin { * Gets aria-setsize from a listbox item (internals or attribute). * @param host - The listbox item element. */ - public static getAriaSetSize(host: Element): string | null { - const instance = this.instances.get(host as unknown as ReactiveControllerHost); + public static getAriaSetSize(host: HTMLElement): string | null { + const instance = this.instances.get(host); return instance != null ? instance.ariaSetSize : host.getAttribute('aria-setsize'); } - public static isSafari: boolean = !isServer && /^((?!chrome|android).)*safari/i.test(navigator.userAgent); - public static of( - host: ReactiveControllerHost, - options?: InternalsControllerOptions, - ): InternalsController { + public static of(host: InternalsHost, options?: InternalsControllerOptions): InternalsController { constructingAllowed = true; // implement the singleton pattern // using a public static constructor method is much easier to manage, @@ -206,21 +203,16 @@ export class InternalsController implements ReactiveController, ARIAMixin { @aria ariaValueNow: string | null = null; @aria ariaValueText: string | null = null; - /** WARNING: be careful of cross-root ARIA browser support */ + /** As of April 2025, the following are considered Baseline supported in evergreen browsers */ @aria ariaActiveDescendantElement: Element | null = null; - /** WARNING: be careful of cross-root ARIA browser support */ @aria ariaControlsElements: Element[] | null = null; - /** WARNING: be careful of cross-root ARIA browser support */ @aria ariaDescribedByElements: Element[] | null = null; - /** WARNING: be careful of cross-root ARIA browser support */ @aria ariaDetailsElements: Element[] | null = null; - /** WARNING: be careful of cross-root ARIA browser support */ @aria ariaErrorMessageElements: Element[] | null = null; - /** WARNING: be careful of cross-root ARIA browser support */ @aria ariaFlowToElements: Element[] | null = null; - /** WARNING: be careful of cross-root ARIA browser support */ @aria ariaLabelledByElements: Element[] | null = null; - /** WARNING: be careful of cross-root ARIA browser support */ + + /** As of February 2026, this is not supported in Chromium browsers */ @aria ariaOwnsElements: Element[] | null = null; /** True when the control is disabled via it's containing fieldset element */ @@ -243,16 +235,14 @@ export class InternalsController implements ReactiveController, ARIAMixin { /** A best-attempt based on observed behaviour in FireFox 115 on fedora 38 */ get computedLabelText(): string { return this.internals.ariaLabel - || Array.from(this.internals.labels as NodeListOf) + || Array.from(this.internals.labels) .reduce((acc, label) => `${acc}${getLabelText(label)}`, ''); } private get element() { if (isServer) { - // FIXME(bennyp): a little white lie, which may break - // when the controller is applied to non-lit frameworks. - return this.host as LitElement; + return this.host; } else { return this.host instanceof HTMLElement ? this.host : this.options?.getHTMLElement?.(); } @@ -262,10 +252,7 @@ export class InternalsController implements ReactiveController, ARIAMixin { private _formDisabled = false; - private constructor( - public host: ReactiveControllerHost, - private options?: InternalsControllerOptions, - ) { + private constructor(public host: InternalsHost, private options?: InternalsControllerOptions) { if (!constructingAllowed) { throw new Error('InternalsController must be constructed with `InternalsController.for()`'); } From 7170bc8d9cfcc0a4567d95b459f89878b212f87f Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Wed, 4 Feb 2026 16:58:52 -0500 Subject: [PATCH 09/18] fix(core): allow dynamically added options to receive keyboard focus --- core/pfe-core/controllers/at-focus-controller.ts | 8 ++++++++ core/pfe-core/controllers/combobox-controller.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts index 03da55a838..519dd2a4ba 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -118,6 +118,14 @@ export abstract class ATFocusController { this.itemsContainerElement ??= this.#initContainer(); } + /** + * Refresh items from the getItems option. Call this when the list of items + * has changed (e.g. when a parent controller sets items). + */ + refreshItems(): void { + this.initItems(); + } + hostConnected(): void { this.hostUpdate(); } diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index e40998dfaa..fd86e100c2 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -281,6 +281,7 @@ export class ComboboxController< set items(value: Item[]) { this.#lb.items = value; + this.#fc?.refreshItems?.(); } /** Whether the combobox is disabled */ From 2fe0d321d90f3a9a5d14f1f1630607559973254e Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 4 Feb 2026 16:30:48 +0200 Subject: [PATCH 10/18] fix(core): update host when setting listbox items --- core/pfe-core/controllers/listbox-controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index 04957a8d09..e7f3331822 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -198,6 +198,7 @@ export class ListboxController implements ReactiveCont */ set items(items: Item[]) { this.#items = items; + this.host.requestUpdate(); } /** From 92111281d2e51dc8aefbe8be9ce4706bdbbd4e70 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Wed, 4 Feb 2026 16:31:25 +0200 Subject: [PATCH 11/18] fix(core): getAria(PosInSet/SetSize) query attributes first, fall back to EI Attributes are user settings, internals are defaults, so we need to try the attributes first --- .../controllers/internals-controller.ts | 46 +++++++++---------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/core/pfe-core/controllers/internals-controller.ts b/core/pfe-core/controllers/internals-controller.ts index 0f2940b927..84b8c7a6c3 100644 --- a/core/pfe-core/controllers/internals-controller.ts +++ b/core/pfe-core/controllers/internals-controller.ts @@ -85,7 +85,17 @@ export class InternalsController implements ReactiveController, ARIAMixin { } /** - * Sets aria-posinset on a listbox item. Uses ElementInternals when the host has + * Gets the ARIA posinset value from a listbox item (attribute takes precedence over internals). + * @param host - The listbox item element. + */ + public static getAriaPosInSet(host: HTMLElement): string | null { + return host.getAttribute('aria-posinset') + ?? this.instances.get(host)?.ariaPosInSet + ?? null; + } + + /** + * Sets the ARIA posinset on a listbox item. Uses ElementInternals when the host has * an InternalsController instance; otherwise sets/removes the host attribute. * @param host - The listbox item element (option or option-like). * @param value - Position in set (1-based), or null to clear. @@ -102,7 +112,17 @@ export class InternalsController implements ReactiveController, ARIAMixin { } /** - * Sets aria-setsize on a listbox item. Uses ElementInternals when the host has + * Gets the ARIA setsize from a listbox item (aria attribute if set or defaulting to internals). + * @param host - The listbox item element. + */ + public static getAriaSetSize(host: HTMLElement): string | null { + return host.getAttribute('aria-setsize') + ?? this.instances.get(host)?.ariaSetSize + ?? null; + } + + /** + * Sets the ARIA setsize on a listbox item. Uses ElementInternals when the host has * an InternalsController instance; otherwise sets/removes the host attribute. * @param host - The listbox item element (option or option-like). * @param value - Total set size, or null to clear. @@ -118,28 +138,6 @@ export class InternalsController implements ReactiveController, ARIAMixin { } } - /** - * Gets aria-posinset from a listbox item (internals or attribute). - * @param host - The listbox item element. - */ - public static getAriaPosInSet(host: HTMLElement): string | null { - const instance = this.instances.get(host); - return instance != null ? - instance.ariaPosInSet - : host.getAttribute('aria-posinset'); - } - - /** - * Gets aria-setsize from a listbox item (internals or attribute). - * @param host - The listbox item element. - */ - public static getAriaSetSize(host: HTMLElement): string | null { - const instance = this.instances.get(host); - return instance != null ? - instance.ariaSetSize - : host.getAttribute('aria-setsize'); - } - public static isSafari: boolean = !isServer && /^((?!chrome|android).)*safari/i.test(navigator.userAgent); From 9fdd2696d9e74017086e8d2241e0fb8a6bf80b05 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 5 Feb 2026 15:23:03 +0200 Subject: [PATCH 12/18] fix(core): map shadow item back to light dom --- .../activedescendant-controller.ts | 33 ++++++++++++++++++- .../controllers/listbox-controller.ts | 21 +++++++----- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/core/pfe-core/controllers/activedescendant-controller.ts b/core/pfe-core/controllers/activedescendant-controller.ts index a5d1e9a47b..e6cb595289 100644 --- a/core/pfe-core/controllers/activedescendant-controller.ts +++ b/core/pfe-core/controllers/activedescendant-controller.ts @@ -134,7 +134,10 @@ export class ActivedescendantController< super.atFocusedItemIndex = index; const item = this._items.at(this.atFocusedItemIndex); for (const _item of this.items) { - this.options.setItemActive?.(_item, _item === item); + const isActive = _item === item; + // Map clone back to original item for setItemActive callback + const originalItem = this.#shadowToLightMap.get(_item) ?? _item; + this.options.setItemActive?.(originalItem, isActive); } const container = this.options.getActiveDescendantContainer(); if (!ActivedescendantController.supportsCrossRootActiveDescendant) { @@ -150,6 +153,12 @@ export class ActivedescendantController< } protected set controlsElements(elements: HTMLElement[]) { + // Avoid removing/re-adding listeners if elements haven't changed + // This prevents breaking event listeners during active event dispatch + if (elements.length === this.#controlsElements.length + && elements.every((el, i) => el === this.#controlsElements[i])) { + return; + } for (const old of this.#controlsElements) { old?.removeEventListener('keydown', this.onKeydown); } @@ -159,6 +168,22 @@ export class ActivedescendantController< } } + /** + * Check the source item's focusable state, not the clone's. + * This is needed because filtering sets `hidden` on the light DOM item, + * and the MutationObserver sync to clones is asynchronous. + */ + override get atFocusableItems(): Item[] { + return this._items.filter(item => { + // Map clone to source item to check actual hidden state + const sourceItem = this.#shadowToLightMap.get(item) ?? item; + return !!sourceItem + && sourceItem.ariaHidden !== 'true' + && !sourceItem.hasAttribute('inert') + && !sourceItem.hasAttribute('hidden'); + }); + } + /** All items */ get items() { return this._items; @@ -195,6 +220,11 @@ export class ActivedescendantController< this.#shadowToLightMap.set(item, item); return item; } else { + // Reuse existing clone if available to maintain stable IDs + const existingClone = this.#lightToShadowMap.get(item); + if (existingClone) { + return existingClone; + } const clone = item.cloneNode(true) as Item; clone.id = getRandomId(); this.#lightToShadowMap.set(item, clone); @@ -214,6 +244,7 @@ export class ActivedescendantController< protected options: ActivedescendantControllerOptions, ) { super(host, options); + this.initItems(); this.options.getItemValue ??= function(this: Item) { return (this as unknown as HTMLOptionElement).value; }; diff --git a/core/pfe-core/controllers/listbox-controller.ts b/core/pfe-core/controllers/listbox-controller.ts index e7f3331822..491d77dab0 100644 --- a/core/pfe-core/controllers/listbox-controller.ts +++ b/core/pfe-core/controllers/listbox-controller.ts @@ -197,8 +197,10 @@ export class ListboxController implements ReactiveCont * @param items - Array of listbox option elements. */ set items(items: Item[]) { - this.#items = items; - this.host.requestUpdate(); + if (!arraysAreEquivalent(items, this.#items)) { + this.#items = items; + this.host.requestUpdate(); + } } /** @@ -385,12 +387,15 @@ export class ListboxController implements ReactiveCont if (this.items.includes(shadowRootItem)) { return shadowRootItem; } else { - const index = - Array.from(shadowRootListboxElement?.children ?? []) - .filter(this.#options.isItem) - .filter(x => !x.hidden) - .indexOf(shadowRootItem); - return this.#items.filter(x => !x.hidden)[index]; + // Shadow clone needs to be mapped back to light DOM item. + // Match by value attribute or text content since index-based matching + // doesn't work when items are filtered (hidden state differs between clone and source) + const cloneValue = shadowRootItem.getAttribute('value') + ?? shadowRootItem.textContent?.trim(); + const sourceItem = this.#items.find(item => + (item.getAttribute('value') ?? item.textContent?.trim()) === cloneValue + ); + return sourceItem ?? null; } } From b0d8867e3c71ec913c780d159db6b5b2a4fe3451 Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 5 Feb 2026 15:23:20 +0200 Subject: [PATCH 13/18] fix(core): manage state when initializing items in ComboboxController --- core/pfe-core/controllers/combobox-controller.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index fd86e100c2..00523a0cc9 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -237,6 +237,7 @@ export class ComboboxController< #lb: ListboxController; #fc?: ATFocusController; + #initializing = false; #preventListboxGainingFocus = false; #input: HTMLElement | null = null; #button: HTMLElement | null = null; @@ -364,7 +365,7 @@ export class ComboboxController< } hostUpdated(): void { - if (!this.#fc) { + if (!this.#fc && !this.#initializing) { this.#init(); } const expanded = this.options.isExpanded(); @@ -382,7 +383,7 @@ export class ComboboxController< ComboboxController.hosts.delete(this.host); } - async _onFocusoutElement(): Promise { + private async _onFocusoutElement(): Promise { if (this.#hasTextInput && this.options.isExpanded()) { const root = this.#element?.getRootNode(); await new Promise(requestAnimationFrame); @@ -399,6 +400,7 @@ export class ComboboxController< * Order of operations is important */ async #init() { + this.#initializing = true; await this.host.updateComplete; this.#initListbox(); this.#initItems(); @@ -406,6 +408,7 @@ export class ComboboxController< this.#initInput(); this.#initLabels(); this.#initController(); + this.#initializing = false; } #initListbox() { From 75aecb2e5dc0325b6882462f61e7f4e851b0dc3f Mon Sep 17 00:00:00 2001 From: Benny Powers Date: Thu, 5 Feb 2026 15:23:32 +0200 Subject: [PATCH 14/18] refactor(core): simplify arraysAreEquivalent --- core/pfe-core/functions/arraysAreEquivalent.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/core/pfe-core/functions/arraysAreEquivalent.ts b/core/pfe-core/functions/arraysAreEquivalent.ts index 475520cd1e..a7d4c8b57f 100644 --- a/core/pfe-core/functions/arraysAreEquivalent.ts +++ b/core/pfe-core/functions/arraysAreEquivalent.ts @@ -7,18 +7,13 @@ * @param b second array */ export function arraysAreEquivalent(a: unknown, b: unknown): boolean { - if (!Array.isArray(a) || !Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) { // one or both are not an array return a === b; } else if (a.length !== b.length) { // lengths are different return false; } else if (!a.length && !b.length) { // both are empty return true; } else { // multi and length of both is equal - for (const [i, element] of a.entries()) { - if (element !== b[i]) { - return false; - } - } - return true; + return a.every((v, i) => b[i] === v); } } From f0ac13a5634be1924746844be22cfdc8757fc3ae Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 5 Feb 2026 14:22:38 -0500 Subject: [PATCH 15/18] fix(core): refresh items on `#show` so that dynamically added options are keyboard focusable --- core/pfe-core/controllers/combobox-controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/pfe-core/controllers/combobox-controller.ts b/core/pfe-core/controllers/combobox-controller.ts index 00523a0cc9..103efa0b60 100644 --- a/core/pfe-core/controllers/combobox-controller.ts +++ b/core/pfe-core/controllers/combobox-controller.ts @@ -513,6 +513,8 @@ export class ComboboxController< } async #show(): Promise { + // Re-read items on open so slotted/dynamically added options are included: + this.#initItems(); const success = await this.options.requestShowListbox(); this.#filterItems(); if (success !== false && !this.#hasTextInput) { From 3cdc15edc81fa18e92aeda2e1c2fa1b3e36a6d88 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 5 Feb 2026 16:11:46 -0500 Subject: [PATCH 16/18] fix(core): fix arrow up/down focus wrapping after initial selection has been made --- .../controllers/at-focus-controller.ts | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/core/pfe-core/controllers/at-focus-controller.ts b/core/pfe-core/controllers/at-focus-controller.ts index 519dd2a4ba..2001521bb6 100644 --- a/core/pfe-core/controllers/at-focus-controller.ts +++ b/core/pfe-core/controllers/at-focus-controller.ts @@ -53,10 +53,20 @@ export abstract class ATFocusController { // - 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 direction = + index === 0 ? + 1 + : index >= items.length - 1 ? + -1 + : index > previousIndex ? + 1 + : -1; const itemsIndexOfLastATFocusableItem = items.indexOf(this.atFocusableItems.at(-1)!); + // Wrap to first focusable item (e.g. skip disabled placeholder at 0) so cycling works after selection. + const itemsIndexOfFirstATFocusableItem = + atFocusableItems.length ? + items.indexOf(this.atFocusableItems.at(0)!) + : 0; let itemToGainFocus = items.at(index); let itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!); if (atFocusableItems.length) { @@ -65,7 +75,14 @@ export abstract class ATFocusController { if (index < 0) { index = itemsIndexOfLastATFocusableItem; } else if (index >= itemsIndexOfLastATFocusableItem) { - index = 0; + index = itemsIndexOfFirstATFocusableItem; + } else if (index < itemsIndexOfFirstATFocusableItem) { + // Before first focusable (index 0 when e.g. placeholder is not focusable). + // Home/End are handled in onKeydown by passing first/last focusable index, so the only + // time we see 0 here is Up from first focusable → wrap to last. + index = previousIndex === itemsIndexOfFirstATFocusableItem ? + itemsIndexOfLastATFocusableItem + : itemsIndexOfFirstATFocusableItem; } else { index = index + direction; } @@ -196,24 +213,30 @@ export abstract class ATFocusController { event.stopPropagation(); event.preventDefault(); break; - case 'Home': + case 'Home': { if (!(event.target instanceof HTMLElement && (event.target.hasAttribute('aria-activedescendant') || event.target.ariaActiveDescendantElement))) { - this.atFocusedItemIndex = 0; + // Use first focusable index so the setter doesn't see 0 (reserved for Up-from-first wrap). + const first = this.atFocusableItems.at(0); + this.atFocusedItemIndex = first != null ? this.items.indexOf(first) : 0; event.stopPropagation(); event.preventDefault(); } break; - case 'End': + } + case 'End': { if (!(event.target instanceof HTMLElement && (event.target.hasAttribute('aria-activedescendant') || event.target.ariaActiveDescendantElement))) { - this.atFocusedItemIndex = this.items.length - 1; + // Use last focusable index for consistency with lists that have non-focusable items. + const last = this.atFocusableItems.at(-1); + this.atFocusedItemIndex = last != null ? this.items.indexOf(last) : this.items.length - 1; event.stopPropagation(); event.preventDefault(); } break; + } default: break; } From 01951d4e7c7e996d77780630243a957b1f04762d Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 5 Feb 2026 16:51:52 -0500 Subject: [PATCH 17/18] fix(select): don't steal browser focus on page load --- elements/pf-select/pf-select.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/elements/pf-select/pf-select.ts b/elements/pf-select/pf-select.ts index 32420b52fe..0bcf091e47 100644 --- a/elements/pf-select/pf-select.ts +++ b/elements/pf-select/pf-select.ts @@ -269,10 +269,15 @@ export class PfSelect extends LitElement { this.value = selected.map(x => x.value).join(); await this.updateComplete; switch (this.variant) { - case 'single': + case 'single': { + // Only focus toggle when closing after user selection; avoid stealing focus on init. + const wasExpanded = this.expanded; this.hide(); - this._toggleButton?.focus(); + if (wasExpanded) { + this._toggleButton?.focus(); + } break; + } case 'typeahead': this._toggleInput!.value = this.value; } From bdd77cf6c9bdbbd64729c957e70545f714f2be90 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 5 Feb 2026 17:08:14 -0500 Subject: [PATCH 18/18] test(select): change test to call `focus()` vs using the `focus` method --- elements/pf-select/test/pf-select.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/elements/pf-select/test/pf-select.spec.ts b/elements/pf-select/test/pf-select.spec.ts index ba634a2a80..07c4cd6469 100644 --- a/elements/pf-select/test/pf-select.spec.ts +++ b/elements/pf-select/test/pf-select.spec.ts @@ -78,7 +78,7 @@ describe('', function() { describe('with accessible-label attribute and 3 items', function() { let element: PfSelect; const updateComplete = () => element.updateComplete; - const focus = () => element.focus; + const focus = () => element.focus(); beforeEach(async function() { element = await createFixture(html` @@ -150,7 +150,7 @@ describe('', function() { describe('with `placeholder` attribute and 3 items', function() { let element: PfSelect; const updateComplete = () => element.updateComplete; - const focus = () => element.focus; + const focus = () => element.focus(); beforeEach(async function() { element = await createFixture(html` @@ -244,7 +244,7 @@ describe('', function() { describe('with 3 items and associated