Skip to content

Commit 85b28dc

Browse files
authored
Merge bdd77cf into f43c1bc
2 parents f43c1bc + bdd77cf commit 85b28dc

File tree

9 files changed

+237
-69
lines changed

9 files changed

+237
-69
lines changed

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

Lines changed: 32 additions & 1 deletion
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
};

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,24 @@ export abstract class ATFocusController<Item extends HTMLElement> {
4949

5050
set atFocusedItemIndex(index: number) {
5151
const previousIndex = this.#atFocusedItemIndex;
52-
const direction = index > previousIndex ? 1 : -1;
5352
const { items, atFocusableItems } = this;
53+
// - Home (index=0): always search forward to find first focusable item
54+
// - End (index=last): always search backward to find last focusable item
55+
// - Other cases: use comparison to determine direction
56+
const direction =
57+
index === 0 ?
58+
1
59+
: index >= items.length - 1 ?
60+
-1
61+
: index > previousIndex ?
62+
1
63+
: -1;
5464
const itemsIndexOfLastATFocusableItem = items.indexOf(this.atFocusableItems.at(-1)!);
65+
// Wrap to first focusable item (e.g. skip disabled placeholder at 0) so cycling works after selection.
66+
const itemsIndexOfFirstATFocusableItem =
67+
atFocusableItems.length ?
68+
items.indexOf(this.atFocusableItems.at(0)!)
69+
: 0;
5570
let itemToGainFocus = items.at(index);
5671
let itemToGainFocusIsFocusable = atFocusableItems.includes(itemToGainFocus!);
5772
if (atFocusableItems.length) {
@@ -60,7 +75,14 @@ export abstract class ATFocusController<Item extends HTMLElement> {
6075
if (index < 0) {
6176
index = itemsIndexOfLastATFocusableItem;
6277
} else if (index >= itemsIndexOfLastATFocusableItem) {
63-
index = 0;
78+
index = itemsIndexOfFirstATFocusableItem;
79+
} else if (index < itemsIndexOfFirstATFocusableItem) {
80+
// Before first focusable (index 0 when e.g. placeholder is not focusable).
81+
// Home/End are handled in onKeydown by passing first/last focusable index, so the only
82+
// time we see 0 here is Up from first focusable → wrap to last.
83+
index = previousIndex === itemsIndexOfFirstATFocusableItem ?
84+
itemsIndexOfLastATFocusableItem
85+
: itemsIndexOfFirstATFocusableItem;
6486
} else {
6587
index = index + direction;
6688
}
@@ -113,6 +135,14 @@ export abstract class ATFocusController<Item extends HTMLElement> {
113135
this.itemsContainerElement ??= this.#initContainer();
114136
}
115137

138+
/**
139+
* Refresh items from the getItems option. Call this when the list of items
140+
* has changed (e.g. when a parent controller sets items).
141+
*/
142+
refreshItems(): void {
143+
this.initItems();
144+
}
145+
116146
hostConnected(): void {
117147
this.hostUpdate();
118148
}
@@ -183,24 +213,30 @@ export abstract class ATFocusController<Item extends HTMLElement> {
183213
event.stopPropagation();
184214
event.preventDefault();
185215
break;
186-
case 'Home':
216+
case 'Home': {
187217
if (!(event.target instanceof HTMLElement
188218
&& (event.target.hasAttribute('aria-activedescendant')
189219
|| event.target.ariaActiveDescendantElement))) {
190-
this.atFocusedItemIndex = 0;
220+
// Use first focusable index so the setter doesn't see 0 (reserved for Up-from-first wrap).
221+
const first = this.atFocusableItems.at(0);
222+
this.atFocusedItemIndex = first != null ? this.items.indexOf(first) : 0;
191223
event.stopPropagation();
192224
event.preventDefault();
193225
}
194226
break;
195-
case 'End':
227+
}
228+
case 'End': {
196229
if (!(event.target instanceof HTMLElement
197230
&& (event.target.hasAttribute('aria-activedescendant')
198231
|| event.target.ariaActiveDescendantElement))) {
199-
this.atFocusedItemIndex = this.items.length - 1;
232+
// Use last focusable index for consistency with lists that have non-focusable items.
233+
const last = this.atFocusableItems.at(-1);
234+
this.atFocusedItemIndex = last != null ? this.items.indexOf(last) : this.items.length - 1;
200235
event.stopPropagation();
201236
event.preventDefault();
202237
}
203238
break;
239+
}
204240
default:
205241
break;
206242
}

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

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export class ComboboxController<
159159
Item extends HTMLElement
160160
> implements ReactiveController {
161161
public static of<T extends HTMLElement>(
162-
host: ReactiveControllerHost,
162+
host: ReactiveControllerHost & HTMLElement,
163163
options: ComboboxControllerOptions<T>,
164164
): ComboboxController<T> {
165165
return new ComboboxController(host, options);
@@ -237,11 +237,13 @@ export class ComboboxController<
237237

238238
#lb: ListboxController<Item>;
239239
#fc?: ATFocusController<Item>;
240+
#initializing = false;
240241
#preventListboxGainingFocus = false;
241242
#input: HTMLElement | null = null;
242243
#button: HTMLElement | null = null;
243244
#listbox: HTMLElement | null = null;
244245
#buttonInitialRole: string | null = null;
246+
#buttonHasMouseDown = false;
245247
#mo = new MutationObserver(() => this.#initItems());
246248
#microcopy = new Map<string, Record<Lang, string>>(Object.entries({
247249
dimmed: {
@@ -280,6 +282,7 @@ export class ComboboxController<
280282

281283
set items(value: Item[]) {
282284
this.#lb.items = value;
285+
this.#fc?.refreshItems?.();
283286
}
284287

285288
/** Whether the combobox is disabled */
@@ -326,7 +329,7 @@ export class ComboboxController<
326329
}
327330

328331
private constructor(
329-
public host: ReactiveControllerHost,
332+
public host: ReactiveControllerHost & HTMLElement,
330333
options: ComboboxControllerOptions<Item>,
331334
) {
332335
host.addController(this);
@@ -362,7 +365,7 @@ export class ComboboxController<
362365
}
363366

364367
hostUpdated(): void {
365-
if (!this.#fc) {
368+
if (!this.#fc && !this.#initializing) {
366369
this.#init();
367370
}
368371
const expanded = this.options.isExpanded();
@@ -380,7 +383,7 @@ export class ComboboxController<
380383
ComboboxController.hosts.delete(this.host);
381384
}
382385

383-
async _onFocusoutElement(): Promise<void> {
386+
private async _onFocusoutElement(): Promise<void> {
384387
if (this.#hasTextInput && this.options.isExpanded()) {
385388
const root = this.#element?.getRootNode();
386389
await new Promise(requestAnimationFrame);
@@ -397,13 +400,15 @@ export class ComboboxController<
397400
* Order of operations is important
398401
*/
399402
async #init() {
403+
this.#initializing = true;
400404
await this.host.updateComplete;
401405
this.#initListbox();
402406
this.#initItems();
403407
this.#initButton();
404408
this.#initInput();
405409
this.#initLabels();
406410
this.#initController();
411+
this.#initializing = false;
407412
}
408413

409414
#initListbox() {
@@ -425,6 +430,8 @@ export class ComboboxController<
425430
#initButton() {
426431
this.#button?.removeEventListener('click', this.#onClickButton);
427432
this.#button?.removeEventListener('keydown', this.#onKeydownButton);
433+
this.#button?.removeEventListener('mousedown', this.#onMousedownButton);
434+
this.#button?.removeEventListener('mouseup', this.#onMouseupButton);
428435
this.#button = this.options.getToggleButton();
429436
if (!this.#button) {
430437
throw new Error('ComboboxController getToggleButton() option must return an element');
@@ -434,6 +441,8 @@ export class ComboboxController<
434441
this.#button.setAttribute('aria-controls', this.#listbox?.id ?? '');
435442
this.#button.addEventListener('click', this.#onClickButton);
436443
this.#button.addEventListener('keydown', this.#onKeydownButton);
444+
this.#button.addEventListener('mousedown', this.#onMousedownButton);
445+
this.#button.addEventListener('mouseup', this.#onMouseupButton);
437446
}
438447

439448
#initInput() {
@@ -504,6 +513,8 @@ export class ComboboxController<
504513
}
505514

506515
async #show(): Promise<void> {
516+
// Re-read items on open so slotted/dynamically added options are included:
517+
this.#initItems();
507518
const success = await this.options.requestShowListbox();
508519
this.#filterItems();
509520
if (success !== false && !this.#hasTextInput) {
@@ -531,26 +542,32 @@ export class ComboboxController<
531542
return strings?.[lang] ?? key;
532543
}
533544

534-
// TODO(bennypowers): perhaps move this to ActivedescendantController
535-
#announce(item: Item) {
545+
/**
546+
* Announces the focused item to a live region (e.g. for Safari VoiceOver).
547+
* @param item - The listbox option item to announce.
548+
* TODO(bennypowers): perhaps move this to ActivedescendantController
549+
*/
550+
#announce(item: Item): void {
536551
const value = this.options.getItemValue(item);
537552
ComboboxController.#alert?.remove();
538553
const fragment = ComboboxController.#alertTemplate.content.cloneNode(true) as DocumentFragment;
539554
ComboboxController.#alert = fragment.firstElementChild as HTMLElement;
540555
let text = value;
541556
const lang = deepClosest(this.#listbox, '[lang]')?.getAttribute('lang') ?? 'en';
542-
const langKey = lang?.match(ComboboxController.langsRE)?.at(0) as Lang ?? 'en';
557+
const langKey = (lang?.match(ComboboxController.langsRE)?.at(0) as Lang) ?? 'en';
543558
if (this.options.isItemDisabled(item)) {
544559
text += ` (${this.#translate('dimmed', langKey)})`;
545560
}
546561
if (this.#lb.isSelected(item)) {
547562
text += `, (${this.#translate('selected', langKey)})`;
548563
}
549-
if (item.hasAttribute('aria-setsize') && item.hasAttribute('aria-posinset')) {
564+
const posInSet = InternalsController.getAriaPosInSet(item);
565+
const setSize = InternalsController.getAriaSetSize(item);
566+
if (posInSet != null && setSize != null) {
550567
if (langKey === 'ja') {
551-
text += `, (${item.getAttribute('aria-setsize')} 件中 ${item.getAttribute('aria-posinset')} 件目)`;
568+
text += `, (${setSize} 件中 ${posInSet} 件目)`;
552569
} else {
553-
text += `, (${item.getAttribute('aria-posinset')} ${this.#translate('of', langKey)} ${item.getAttribute('aria-setsize')})`;
570+
text += `, (${posInSet} ${this.#translate('of', langKey)} ${setSize})`;
554571
}
555572
}
556573
ComboboxController.#alert.lang = lang;
@@ -580,6 +597,17 @@ export class ComboboxController<
580597
}
581598
};
582599

600+
/**
601+
* Distinguish click-to-toggle vs Tab/Shift+Tab
602+
*/
603+
#onMousedownButton = () => {
604+
this.#buttonHasMouseDown = true;
605+
};
606+
607+
#onMouseupButton = () => {
608+
this.#buttonHasMouseDown = false;
609+
};
610+
583611
#onClickListbox = (event: MouseEvent) => {
584612
if (!this.multi && event.composedPath().some(this.options.isItem)) {
585613
this.#hide();
@@ -735,9 +763,14 @@ export class ComboboxController<
735763
#onFocusoutListbox = (event: FocusEvent) => {
736764
if (!this.#hasTextInput && this.options.isExpanded()) {
737765
const root = this.#element?.getRootNode();
766+
// Check if focus moved to the toggle button via mouse click
767+
// If so, let the click handler manage toggle (prevents double-toggle)
768+
// But if focus moved via Shift+Tab (no mousedown), we should still hide
769+
const isClickOnToggleButton =
770+
event.relatedTarget === this.#button && this.#buttonHasMouseDown;
738771
if ((root instanceof ShadowRoot || root instanceof Document)
739772
&& !this.items.includes(event.relatedTarget as Item)
740-
) {
773+
&& !isClickOnToggleButton) {
741774
this.#hide();
742775
}
743776
}

0 commit comments

Comments
 (0)