⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
aae6b06
fix(core): if `relatedTarget` is toggle, let `#onClickButton` manage …
adamjohnson Jan 20, 2026
66793e6
fix(core): ensure RTI syncs AT focus when using a combo of mouse and …
adamjohnson Jan 20, 2026
97e9288
fix(core): force to always search forward for Home and backward for …
adamjohnson Jan 20, 2026
4132c4c
fix(core): hide listbox on Shift+Tab when moving to the toggle button
adamjohnson Jan 21, 2026
ba6a0dc
feat(core): add optional `setItems` callback to listbox and combobox …
adamjohnson Feb 2, 2026
17e43f1
Revert "feat(core): add optional `setItems` callback to listbox and c…
adamjohnson Feb 3, 2026
cdf6ea1
feat(core): let the internals controller handle `aria-posinset` and `…
adamjohnson Feb 3, 2026
e594b00
fix(core): narrow host type for combobox, internals controllers
bennypowers Feb 4, 2026
7170bc8
fix(core): allow dynamically added options to receive keyboard focus
adamjohnson Feb 4, 2026
2fe0d32
fix(core): update host when setting listbox items
bennypowers Feb 4, 2026
9211128
fix(core): getAria(PosInSet/SetSize) query attributes first, fall bac…
bennypowers Feb 4, 2026
9fdd269
fix(core): map shadow item back to light dom
bennypowers Feb 5, 2026
b0d8867
fix(core): manage state when initializing items in ComboboxController
bennypowers Feb 5, 2026
75aecb2
refactor(core): simplify arraysAreEquivalent
bennypowers Feb 5, 2026
f0ac13a
fix(core): refresh items on `#show` so that dynamically added options…
adamjohnson Feb 5, 2026
3cdc15e
fix(core): fix arrow up/down focus wrapping after initial selection h…
adamjohnson Feb 5, 2026
01951d4
fix(select): don't steal browser focus on page load
adamjohnson Feb 5, 2026
bdd77cf
test(select): change test to call `focus()` vs using the `focus` method
adamjohnson Feb 5, 2026
69928a5
fix(core): add explicit extension for `ATFocusController` type import
adamjohnson Feb 6, 2026
d106980
test(core): atFocusedItemIndex setter
bennypowers Feb 9, 2026
b6dec4b
refactor(core): atFocusedItemIndex setter
bennypowers Feb 9, 2026
8611949
refactor(core): make ATFocusController initItems internal
bennypowers Feb 9, 2026
4823594
docs: changesets
bennypowers Feb 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/brave-pens-study.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@patternfly/pfe-core": patch
---
`ATFocusController`: fix keyboard focus wrapping and dynamic item support

- Fix arrow up/down focus wrapping when a non-focusable placeholder occupies
index 0 (e.g. a disabled "Select a value" option)
- Fix dynamically added options not receiving keyboard focus until the listbox
is reopened
4 changes: 4 additions & 0 deletions .changeset/warm-dogs-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
"@patternfly/elements": patch
---
`<pf-select>`: don't steal browser focus on page load, and improve keyboard accessibility of the listbox.
36 changes: 34 additions & 2 deletions core/pfe-core/controllers/activedescendant-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -214,6 +244,7 @@ export class ActivedescendantController<
protected options: ActivedescendantControllerOptions<Item>,
) {
super(host, options);
this.initItems();
this.options.getItemValue ??= function(this: Item) {
return (this as unknown as HTMLOptionElement).value;
};
Expand All @@ -236,7 +267,8 @@ export class ActivedescendantController<
}
};

protected override initItems(): void {
/** @internal */
override initItems(): void {
this.#attrMO.disconnect();
super.initItems();
this.controlsElements = this.options.getControlsElements?.() ?? [];
Expand Down
103 changes: 74 additions & 29 deletions core/pfe-core/controllers/at-focus-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,31 +47,51 @@ export abstract class ATFocusController<Item extends HTMLElement> {
return this.#atFocusedItemIndex;
}

set atFocusedItemIndex(index: number) {
const previousIndex = this.#atFocusedItemIndex;
const direction = index > previousIndex ? 1 : -1;
set atFocusedItemIndex(requestedIndex: number) {
const { items, atFocusableItems } = this;
const itemsIndexOfLastATFocusableItem = items.indexOf(this.atFocusableItems.at(-1)!);
let itemToGainFocus = items.at(index);
let itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!);
if (atFocusableItems.length) {
let count = 0;
while (!itemToGainFocus || !itemToGainFocusIsFocusable && count++ <= 1000) {
if (index < 0) {
index = itemsIndexOfLastATFocusableItem;
} else if (index >= itemsIndexOfLastATFocusableItem) {
index = 0;
} else {
index = index + direction;
}
itemToGainFocus = items.at(index);
itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!);
}
if (count >= 1000) {
throw new Error('Could not atFocusedItemIndex');
}

if (!atFocusableItems.length) {
this.#atFocusedItemIndex = requestedIndex;
return;
}

// Fast path: requested item is already focusable
if (requestedIndex >= 0
&& requestedIndex < items.length
&& atFocusableItems.includes(items[requestedIndex])) {
this.#atFocusedItemIndex = requestedIndex;
return;
}

const lastFocusableIndex = items.indexOf(atFocusableItems.at(-1)!);

// Navigated before start → wrap to last focusable
if (requestedIndex < 0) {
this.#atFocusedItemIndex = lastFocusableIndex;
return;
}

const firstFocusableIndex = items.indexOf(atFocusableItems[0]);

// Navigated past end or past last focusable → wrap to first focusable
if (requestedIndex >= items.length
|| requestedIndex > lastFocusableIndex) {
this.#atFocusedItemIndex = firstFocusableIndex;
return;
}
this.#atFocusedItemIndex = index;

// Before first focusable (e.g. disabled placeholder at index 0).
// ArrowUp from first focusable → wrap to last; otherwise snap to first.
if (requestedIndex < firstFocusableIndex) {
this.#atFocusedItemIndex =
this.#atFocusedItemIndex === firstFocusableIndex ? lastFocusableIndex
: firstFocusableIndex;
return;
}

// Mid-list non-focusable item: find nearest focusable in the navigation direction
this.#atFocusedItemIndex =
items.indexOf(this.#getNextFocusableItem(requestedIndex));
}

/** Elements which control the items container e.g. a combobox input */
Expand Down Expand Up @@ -106,9 +126,12 @@ export abstract class ATFocusController<Item extends HTMLElement> {
}

/**
* Initialize the items and itemsContainerElement fields
* Initialize the items and itemsContainerElement fields.
* Call this when the list of items has changed
* (e.g. when a parent controller sets items).
* @internal not for use by element authors
*/
protected initItems(): void {
initItems(): void {
this.items = this.options.getItems();
this.itemsContainerElement ??= this.#initContainer();
}
Expand All @@ -130,6 +153,22 @@ export abstract class ATFocusController<Item extends HTMLElement> {
?? (!isServer && this.host instanceof HTMLElement ? this.host : null);
}

/**
* When setting atFocusedItemIndex, and the current focus is on a
* mid-list non-focusable item: find nearest focusable in the navigation direction.
* disabled items will be skipped, and out of bounds items will be wrapped
*
* @param requestedIndex the desired index
*/
#getNextFocusableItem(requestedIndex: number) {
const { items, atFocusableItems } = this;
if (requestedIndex > this.#atFocusedItemIndex) {
return atFocusableItems.find(item => items.indexOf(item) > requestedIndex)!;
} else {
return atFocusableItems.findLast(item => items.indexOf(item) < requestedIndex)!;
}
}

/**
* Override and conditionally call `super.onKeydown` to filter out keyboard events
* which should not result in a focus change. Ensure that subclass' method is bound
Expand Down Expand Up @@ -183,24 +222,30 @@ export abstract class ATFocusController<Item extends HTMLElement> {
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;
}
Expand Down
Loading
Loading