Skip to content

Commit cdf6ea1

Browse files
committed
feat(core): let the internals controller handle aria-posinset and aria-setsize
1 parent 17e43f1 commit cdf6ea1

File tree

3 files changed

+81
-13
lines changed

3 files changed

+81
-13
lines changed

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

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -536,26 +536,32 @@ export class ComboboxController<
536536
return strings?.[lang] ?? key;
537537
}
538538

539-
// TODO(bennypowers): perhaps move this to ActivedescendantController
540-
#announce(item: Item) {
539+
/**
540+
* Announces the focused item to a live region (e.g. for Safari VoiceOver).
541+
* @param item - The listbox option item to announce.
542+
* TODO(bennypowers): perhaps move this to ActivedescendantController
543+
*/
544+
#announce(item: Item): void {
541545
const value = this.options.getItemValue(item);
542546
ComboboxController.#alert?.remove();
543547
const fragment = ComboboxController.#alertTemplate.content.cloneNode(true) as DocumentFragment;
544548
ComboboxController.#alert = fragment.firstElementChild as HTMLElement;
545549
let text = value;
546550
const lang = deepClosest(this.#listbox, '[lang]')?.getAttribute('lang') ?? 'en';
547-
const langKey = lang?.match(ComboboxController.langsRE)?.at(0) as Lang ?? 'en';
551+
const langKey = (lang?.match(ComboboxController.langsRE)?.at(0) as Lang) ?? 'en';
548552
if (this.options.isItemDisabled(item)) {
549553
text += ` (${this.#translate('dimmed', langKey)})`;
550554
}
551555
if (this.#lb.isSelected(item)) {
552556
text += `, (${this.#translate('selected', langKey)})`;
553557
}
554-
if (item.hasAttribute('aria-setsize') && item.hasAttribute('aria-posinset')) {
558+
const posInSet = InternalsController.getAriaPosInSet(item);
559+
const setSize = InternalsController.getAriaSetSize(item);
560+
if (posInSet != null && setSize != null) {
555561
if (langKey === 'ja') {
556-
text += `, (${item.getAttribute('aria-setsize')} 件中 ${item.getAttribute('aria-posinset')} 件目)`;
562+
text += `, (${setSize} 件中 ${posInSet} 件目)`;
557563
} else {
558-
text += `, (${item.getAttribute('aria-posinset')} ${this.#translate('of', langKey)} ${item.getAttribute('aria-setsize')})`;
564+
text += `, (${posInSet} ${this.#translate('of', langKey)} ${setSize})`;
559565
}
560566
}
561567
ComboboxController.#alert.lang = lang;

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,63 @@ export class InternalsController implements ReactiveController, ARIAMixin {
8383
return Array.from(this.instances.get(host)?.internals.labels ?? []) as Element[];
8484
}
8585

86+
/**
87+
* Sets aria-posinset on a listbox item. Uses ElementInternals when the host has
88+
* an InternalsController instance; otherwise sets/removes the host attribute.
89+
* @param host - The listbox item element (option or option-like).
90+
* @param value - Position in set (1-based), or null to clear.
91+
*/
92+
public static setAriaPosInSet(host: Element, value: number | string | null): void {
93+
const instance = this.instances.get(host as unknown as ReactiveControllerHost);
94+
if (instance) {
95+
instance.ariaPosInSet = value != null ? String(value) : null;
96+
} else if (value != null) {
97+
host.setAttribute('aria-posinset', String(value));
98+
} else {
99+
host.removeAttribute('aria-posinset');
100+
}
101+
}
102+
103+
/**
104+
* Sets aria-setsize on a listbox item. Uses ElementInternals when the host has
105+
* an InternalsController instance; otherwise sets/removes the host attribute.
106+
* @param host - The listbox item element (option or option-like).
107+
* @param value - Total set size, or null to clear.
108+
*/
109+
public static setAriaSetSize(host: Element, value: number | string | null): void {
110+
const instance = this.instances.get(host as unknown as ReactiveControllerHost);
111+
if (instance) {
112+
instance.ariaSetSize = value != null ? String(value) : null;
113+
} else if (value != null) {
114+
host.setAttribute('aria-setsize', String(value));
115+
} else {
116+
host.removeAttribute('aria-setsize');
117+
}
118+
}
119+
120+
/**
121+
* Gets aria-posinset from a listbox item (internals or attribute).
122+
* @param host - The listbox item element.
123+
*/
124+
public static getAriaPosInSet(host: Element): string | null {
125+
const instance = this.instances.get(host as unknown as ReactiveControllerHost);
126+
return instance != null ?
127+
instance.ariaPosInSet
128+
: host.getAttribute('aria-posinset');
129+
}
130+
131+
/**
132+
* Gets aria-setsize from a listbox item (internals or attribute).
133+
* @param host - The listbox item element.
134+
*/
135+
public static getAriaSetSize(host: Element): string | null {
136+
const instance = this.instances.get(host as unknown as ReactiveControllerHost);
137+
return instance != null ?
138+
instance.ariaSetSize
139+
: host.getAttribute('aria-setsize');
140+
}
141+
142+
86143
public static isSafari: boolean =
87144
!isServer && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
88145

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { RequireProps } from '../core.ts';
33

44
import { isServer } from 'lit';
55
import { arraysAreEquivalent } from '../functions/arraysAreEquivalent.js';
6+
import { InternalsController } from './internals-controller.js';
67

78
/**
89
* Options for listbox controller
@@ -192,16 +193,11 @@ export class ListboxController<Item extends HTMLElement> implements ReactiveCont
192193
}
193194

194195
/**
195-
* register's the host's Item elements as listbox controller items
196-
* sets aria-setsize and aria-posinset on items
197-
* @param items items
196+
* Registers the host's item elements as listbox controller items.
197+
* @param items - Array of listbox option elements.
198198
*/
199199
set items(items: Item[]) {
200200
this.#items = items;
201-
this.#items.forEach((item, index, _items) => {
202-
item.ariaSetSize = _items.length.toString();
203-
item.ariaPosInSet = (index + 1).toString();
204-
});
205201
}
206202

207203
/**
@@ -268,6 +264,10 @@ export class ListboxController<Item extends HTMLElement> implements ReactiveCont
268264
}
269265
}
270266

267+
/**
268+
* Called during host update; syncs control element listeners and
269+
* applies aria-posinset/aria-setsize to each item via InternalsController.
270+
*/
271271
hostUpdate(): void {
272272
const last = this.#controlsElements;
273273
this.#controlsElements = this.#options.getControlsElements?.() ?? [];
@@ -278,6 +278,11 @@ export class ListboxController<Item extends HTMLElement> implements ReactiveCont
278278
el.addEventListener('keyup', this.#onKeyup);
279279
}
280280
}
281+
const items = this.#items;
282+
items.forEach((item, index) => {
283+
InternalsController.setAriaPosInSet(item, index + 1);
284+
InternalsController.setAriaSetSize(item, items.length);
285+
});
281286
}
282287

283288
hostUpdated(): void {

0 commit comments

Comments
 (0)