Skip to content

Commit ec6b358

Browse files
fix(core): improve ATFocus, Combobox, Internals, Listbox, and RovingTabIndex controllers (#2963)
* fix(core): if `relatedTarget` is toggle, let `#onClickButton` manage toggle behavior * fix(core): ensure RTI syncs AT focus when using a combo of mouse and keyboard * fix(core): force to always search forward for Home and backward for End key presses * fix(core): hide listbox on Shift+Tab when moving to the toggle button * feat(core): add optional `setItems` callback to listbox and combobox controller * Revert "feat(core): add optional `setItems` callback to listbox and combobox controller" This reverts commit ba6a0dc. * feat(core): let the internals controller handle `aria-posinset` and `aria-setsize` * fix(core): narrow host type for combobox, internals controllers * fix(core): allow dynamically added options to receive keyboard focus * fix(core): update host when setting listbox items * 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 * fix(core): map shadow item back to light dom * fix(core): manage state when initializing items in ComboboxController * refactor(core): simplify arraysAreEquivalent * fix(core): refresh items on `#show` so that dynamically added options are keyboard focusable * fix(core): fix arrow up/down focus wrapping after initial selection has been made * fix(select): don't steal browser focus on page load * test(select): change test to call `focus()` vs using the `focus` method * fix(core): add explicit extension for `ATFocusController` type import * test(core): atFocusedItemIndex setter * refactor(core): atFocusedItemIndex setter refactors for readability * refactor(core): make ATFocusController initItems internal * docs: changesets --------- Co-authored-by: Benny Powers <web@bennypowers.com>
1 parent f43c1bc commit ec6b358

13 files changed

+529
-94
lines changed

.changeset/brave-pens-study.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@patternfly/pfe-core": patch
3+
---
4+
`ATFocusController`: fix keyboard focus wrapping and dynamic item support
5+
6+
- Fix arrow up/down focus wrapping when a non-focusable placeholder occupies
7+
index 0 (e.g. a disabled "Select a value" option)
8+
- Fix dynamically added options not receiving keyboard focus until the listbox
9+
is reopened

.changeset/warm-dogs-laugh.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
"@patternfly/elements": patch
3+
---
4+
`<pf-select>`: don't steal browser focus on page load, and improve keyboard accessibility of the listbox.

core/pfe-core/controllers/activedescendant-controller.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,10 @@ export class ActivedescendantController<
134134
super.atFocusedItemIndex = index;
135135
const item = this._items.at(this.atFocusedItemIndex);
136136
for (const _item of this.items) {
137-
this.options.setItemActive?.(_item, _item === item);
137+
const isActive = _item === item;
138+
// Map clone back to original item for setItemActive callback
139+
const originalItem = this.#shadowToLightMap.get(_item) ?? _item;
140+
this.options.setItemActive?.(originalItem, isActive);
138141
}
139142
const container = this.options.getActiveDescendantContainer();
140143
if (!ActivedescendantController.supportsCrossRootActiveDescendant) {
@@ -150,6 +153,12 @@ export class ActivedescendantController<
150153
}
151154

152155
protected set controlsElements(elements: HTMLElement[]) {
156+
// Avoid removing/re-adding listeners if elements haven't changed
157+
// This prevents breaking event listeners during active event dispatch
158+
if (elements.length === this.#controlsElements.length
159+
&& elements.every((el, i) => el === this.#controlsElements[i])) {
160+
return;
161+
}
153162
for (const old of this.#controlsElements) {
154163
old?.removeEventListener('keydown', this.onKeydown);
155164
}
@@ -159,6 +168,22 @@ export class ActivedescendantController<
159168
}
160169
}
161170

171+
/**
172+
* Check the source item's focusable state, not the clone's.
173+
* This is needed because filtering sets `hidden` on the light DOM item,
174+
* and the MutationObserver sync to clones is asynchronous.
175+
*/
176+
override get atFocusableItems(): Item[] {
177+
return this._items.filter(item => {
178+
// Map clone to source item to check actual hidden state
179+
const sourceItem = this.#shadowToLightMap.get(item) ?? item;
180+
return !!sourceItem
181+
&& sourceItem.ariaHidden !== 'true'
182+
&& !sourceItem.hasAttribute('inert')
183+
&& !sourceItem.hasAttribute('hidden');
184+
});
185+
}
186+
162187
/** All items */
163188
get items() {
164189
return this._items;
@@ -195,6 +220,11 @@ export class ActivedescendantController<
195220
this.#shadowToLightMap.set(item, item);
196221
return item;
197222
} else {
223+
// Reuse existing clone if available to maintain stable IDs
224+
const existingClone = this.#lightToShadowMap.get(item);
225+
if (existingClone) {
226+
return existingClone;
227+
}
198228
const clone = item.cloneNode(true) as Item;
199229
clone.id = getRandomId();
200230
this.#lightToShadowMap.set(item, clone);
@@ -214,6 +244,7 @@ export class ActivedescendantController<
214244
protected options: ActivedescendantControllerOptions<Item>,
215245
) {
216246
super(host, options);
247+
this.initItems();
217248
this.options.getItemValue ??= function(this: Item) {
218249
return (this as unknown as HTMLOptionElement).value;
219250
};
@@ -236,7 +267,8 @@ export class ActivedescendantController<
236267
}
237268
};
238269

239-
protected override initItems(): void {
270+
/** @internal */
271+
override initItems(): void {
240272
this.#attrMO.disconnect();
241273
super.initItems();
242274
this.controlsElements = this.options.getControlsElements?.() ?? [];

core/pfe-core/controllers/at-focus-controller.ts

Lines changed: 74 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -47,31 +47,51 @@ export abstract class ATFocusController<Item extends HTMLElement> {
4747
return this.#atFocusedItemIndex;
4848
}
4949

50-
set atFocusedItemIndex(index: number) {
51-
const previousIndex = this.#atFocusedItemIndex;
52-
const direction = index > previousIndex ? 1 : -1;
50+
set atFocusedItemIndex(requestedIndex: number) {
5351
const { items, atFocusableItems } = this;
54-
const itemsIndexOfLastATFocusableItem = items.indexOf(this.atFocusableItems.at(-1)!);
55-
let itemToGainFocus = items.at(index);
56-
let itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!);
57-
if (atFocusableItems.length) {
58-
let count = 0;
59-
while (!itemToGainFocus || !itemToGainFocusIsFocusable && count++ <= 1000) {
60-
if (index < 0) {
61-
index = itemsIndexOfLastATFocusableItem;
62-
} else if (index >= itemsIndexOfLastATFocusableItem) {
63-
index = 0;
64-
} else {
65-
index = index + direction;
66-
}
67-
itemToGainFocus = items.at(index);
68-
itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!);
69-
}
70-
if (count >= 1000) {
71-
throw new Error('Could not atFocusedItemIndex');
72-
}
52+
53+
if (!atFocusableItems.length) {
54+
this.#atFocusedItemIndex = requestedIndex;
55+
return;
56+
}
57+
58+
// Fast path: requested item is already focusable
59+
if (requestedIndex >= 0
60+
&& requestedIndex < items.length
61+
&& atFocusableItems.includes(items[requestedIndex])) {
62+
this.#atFocusedItemIndex = requestedIndex;
63+
return;
64+
}
65+
66+
const lastFocusableIndex = items.indexOf(atFocusableItems.at(-1)!);
67+
68+
// Navigated before start → wrap to last focusable
69+
if (requestedIndex < 0) {
70+
this.#atFocusedItemIndex = lastFocusableIndex;
71+
return;
72+
}
73+
74+
const firstFocusableIndex = items.indexOf(atFocusableItems[0]);
75+
76+
// Navigated past end or past last focusable → wrap to first focusable
77+
if (requestedIndex >= items.length
78+
|| requestedIndex > lastFocusableIndex) {
79+
this.#atFocusedItemIndex = firstFocusableIndex;
80+
return;
7381
}
74-
this.#atFocusedItemIndex = index;
82+
83+
// Before first focusable (e.g. disabled placeholder at index 0).
84+
// ArrowUp from first focusable → wrap to last; otherwise snap to first.
85+
if (requestedIndex < firstFocusableIndex) {
86+
this.#atFocusedItemIndex =
87+
this.#atFocusedItemIndex === firstFocusableIndex ? lastFocusableIndex
88+
: firstFocusableIndex;
89+
return;
90+
}
91+
92+
// Mid-list non-focusable item: find nearest focusable in the navigation direction
93+
this.#atFocusedItemIndex =
94+
items.indexOf(this.#getNextFocusableItem(requestedIndex));
7595
}
7696

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

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

156+
/**
157+
* When setting atFocusedItemIndex, and the current focus is on a
158+
* mid-list non-focusable item: find nearest focusable in the navigation direction.
159+
* disabled items will be skipped, and out of bounds items will be wrapped
160+
*
161+
* @param requestedIndex the desired index
162+
*/
163+
#getNextFocusableItem(requestedIndex: number) {
164+
const { items, atFocusableItems } = this;
165+
if (requestedIndex > this.#atFocusedItemIndex) {
166+
return atFocusableItems.find(item => items.indexOf(item) > requestedIndex)!;
167+
} else {
168+
return atFocusableItems.findLast(item => items.indexOf(item) < requestedIndex)!;
169+
}
170+
}
171+
133172
/**
134173
* Override and conditionally call `super.onKeydown` to filter out keyboard events
135174
* which should not result in a focus change. Ensure that subclass' method is bound
@@ -183,24 +222,30 @@ export abstract class ATFocusController<Item extends HTMLElement> {
183222
event.stopPropagation();
184223
event.preventDefault();
185224
break;
186-
case 'Home':
225+
case 'Home': {
187226
if (!(event.target instanceof HTMLElement
188227
&& (event.target.hasAttribute('aria-activedescendant')
189228
|| event.target.ariaActiveDescendantElement))) {
190-
this.atFocusedItemIndex = 0;
229+
// Use first focusable index so the setter doesn't see 0 (reserved for Up-from-first wrap).
230+
const first = this.atFocusableItems.at(0);
231+
this.atFocusedItemIndex = first != null ? this.items.indexOf(first) : 0;
191232
event.stopPropagation();
192233
event.preventDefault();
193234
}
194235
break;
195-
case 'End':
236+
}
237+
case 'End': {
196238
if (!(event.target instanceof HTMLElement
197239
&& (event.target.hasAttribute('aria-activedescendant')
198240
|| event.target.ariaActiveDescendantElement))) {
199-
this.atFocusedItemIndex = this.items.length - 1;
241+
// Use last focusable index for consistency with lists that have non-focusable items.
242+
const last = this.atFocusableItems.at(-1);
243+
this.atFocusedItemIndex = last != null ? this.items.indexOf(last) : this.items.length - 1;
200244
event.stopPropagation();
201245
event.preventDefault();
202246
}
203247
break;
248+
}
204249
default:
205250
break;
206251
}

0 commit comments

Comments
 (0)