-
Notifications
You must be signed in to change notification settings - Fork 106
Expand file tree
/
Copy pathactivedescendant-controller.ts
More file actions
305 lines (278 loc) · 11.9 KB
/
activedescendant-controller.ts
File metadata and controls
305 lines (278 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
import type { ReactiveControllerHost } from 'lit';
import { type ATFocusControllerOptions, ATFocusController } from './at-focus-controller.js';
import { isServer, nothing } from 'lit';
import { getRandomId } from '../functions/random.js';
import { bound } from '../decorators/bound.js';
export interface ActivedescendantControllerOptions<
Item extends HTMLElement
> extends ATFocusControllerOptions<Item> {
/**
* Returns a reference to the element which acts as the assistive technology container for
* the items. In the case of a combobox, this is the input element.
*/
getActiveDescendantContainer(): HTMLElement | null;
/**
* Optional callback to control the assistive technology focus behavior of items.
* By default, ActivedescendantController will not do anything special to items when they receive
* assistive technology focus, and will only set the `activedescendant` property on the container.
* If you provide this callback, ActivedescendantController will call it on your item with the
* active state. You may use this to set active styles.
*/
setItemActive?(item: Item, active: boolean): void;
/**
* Optional callback to retrieve the value from an option element.
* By default, retrieves the `value` attribute, or the text content.
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLOptionElement
*/
getItemValue?(item: Item): string;
}
/**
* Implements activedescendant pattern, as described in WAI-ARIA practices,
* [Managing Focus in Composites Using aria-activedescendant][ad]
*
* The steps for using the aria-activedescendant method of managing focus are as follows.
*
* - When the container element that has a role that supports aria-activedescendant is loaded
* or created, ensure that:
* - The container element is included in the tab sequence as described in
* Keyboard Navigation Between Components or is a focusable element of a composite
* that implements a roving tabindex.
* - It has aria-activedescendant="IDREF" where IDREF is the ID of the element within
* the container that should be identified as active when the widget receives focus.
* The referenced element needs to meet the DOM relationship requirements described below.
* - When the container element receives DOM focus, draw a visual focus indicator on the active
* element and ensure the active element is scrolled into view.
* - When the composite widget contains focus and the user presses a navigation key that moves
* focus within the widget, such as an arrow key:
* - Change the value of aria-activedescendant on the container to refer to the element
* that should be reported to assistive technologies as active.
* - Move the visual focus indicator and, if necessary, scrolled the active element into view.
* - If the design calls for a specific element to be focused the next time a user moves focus
* into the composite with Tab or Shift+Tab, check if aria-activedescendant is referring to
* that target element when the container loses focus. If it is not, set aria-activedescendant
* to refer to the target element.
*
* The specification for aria-activedescendant places important restrictions on the
* DOM relationship between the focused element that has the aria-activedescendant attribute
* and the element referenced as active by the value of the attribute.
* One of the following three conditions must be met.
*
* 1. The element referenced as active is a DOM descendant of the focused referencing element.
* 2. The focused referencing element has a value specified for the aria-owns property that
* includes the ID of the element referenced as active.
* 3. The focused referencing element has role of combobox, textbox, or searchbox
* and has aria-controls property referring to an element with a role that supports
* aria-activedescendant and either:
* 1. The element referenced as active is a descendant of the controlled element.
* 2. The controlled element has a value specified for the aria-owns property that includes
* the ID of the element referenced as active.
*
* [ad]: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant
*/
export class ActivedescendantController<
Item extends HTMLElement = HTMLElement
> extends ATFocusController<Item> {
/**
* When true, the browser supports cross-root ARIA such that the controller does not need
* to copy item nodes into the controlling nodes' root
*/
public static get supportsCrossRootActiveDescendant(): boolean {
return !isServer && 'ariaActiveDescendantElement' in HTMLElement.prototype;
}
static of<Item extends HTMLElement>(
host: ReactiveControllerHost,
options: ActivedescendantControllerOptions<Item>,
): ActivedescendantController<Item> {
return new ActivedescendantController(host, options);
}
/** Maps from original element to shadow DOM clone */
#lightToShadowMap = new WeakMap<Item, Item>();
/** Maps from shadow DOM clone to original element */
#shadowToLightMap = new WeakMap<Item, Item>();
/** Set of item which should not be cloned */
#noCloneSet = new WeakSet<Item>();
/** Element which controls the list i.e. combobox */
#controlsElements: HTMLElement[] = [];
#observing = false;
#listMO = new MutationObserver(records => this.#onItemsDOMChange(records));
#attrMO = new MutationObserver(records => this.#onItemAttributeChange(records));
#syncAttr(attributeName: string, fromNode: Item) {
const toNode = this.#shadowToLightMap.get(fromNode as Item)
?? this.#lightToShadowMap.get(fromNode as Item);
const newVal = fromNode.getAttribute(attributeName);
const oldVal = toNode?.getAttribute(attributeName);
if (!fromNode.hasAttribute(attributeName)) {
toNode?.removeAttribute(attributeName);
} else if (oldVal !== newVal) {
toNode?.setAttribute(attributeName, newVal!);
}
}
get atFocusedItemIndex(): number {
return super.atFocusedItemIndex;
}
/**
* Rather than setting DOM focus, applies the `aria-activedescendant` attribute,
* using AriaIDLAttributes for cross-root aria, if supported by the browser
* @param item item
*/
set atFocusedItemIndex(index: number) {
super.atFocusedItemIndex = index;
const item = this._items.at(this.atFocusedItemIndex);
for (const _item of this.items) {
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) {
container?.setAttribute('aria-activedescendant', item?.id ?? '');
} else if (container) {
container.ariaActiveDescendantElement = item ?? null;
}
this.host.requestUpdate();
}
protected get controlsElements(): HTMLElement[] {
return this.#controlsElements;
}
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);
}
this.#controlsElements = elements;
for (const element of this.#controlsElements) {
element.addEventListener('keydown', this.onKeydown);
}
}
/**
* 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;
}
/**
* Sets the list of items and activates the next activatable item after the current one
* @param items tabindex items
*/
override set items(items: Item[]) {
const container = this.options.getItemsContainer?.() ?? this.host;
if (!(container instanceof HTMLElement)) {
throw new Error('items container must be an HTMLElement');
}
this.itemsContainerElement = container;
const { supportsCrossRootActiveDescendant } = ActivedescendantController;
if (supportsCrossRootActiveDescendant
|| [container] // all nodes are in the same root
.concat(this.controlsElements)
.concat(items)
.every((node, _, a) => node.getRootNode() === a[0].getRootNode())) {
this._items = items.map(x => {
if (!supportsCrossRootActiveDescendant) {
x.id ||= getRandomId();
}
return x;
});
} else {
this._items = items?.map((item: Item) => {
item.removeAttribute('tabindex');
if (container.contains(item)) {
item.id ||= getRandomId();
this.#noCloneSet.add(item);
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);
this.#shadowToLightMap.set(clone, item);
// Though efforts were taken to disconnect
// this observer, it may still be a memory leak
this.#attrMO.observe(clone, { attributes: true });
this.#attrMO.observe(item, { attributes: true });
return clone;
}
});
}
}
private constructor(
public host: ReactiveControllerHost,
protected options: ActivedescendantControllerOptions<Item>,
) {
super(host, options);
this.initItems();
this.options.getItemValue ??= function(this: Item) {
return (this as unknown as HTMLOptionElement).value;
};
}
#onItemsDOMChange(records: MutationRecord[]) {
for (const { removedNodes } of records) {
for (const removed of removedNodes as NodeListOf<Item>) {
this.#lightToShadowMap.get(removed)?.remove();
this.#lightToShadowMap.delete(removed);
}
}
};
#onItemAttributeChange(records: MutationRecord[]) {
for (const { target, attributeName } of records) {
if (attributeName) {
this.#syncAttr(attributeName, target as Item);
}
}
};
/** @internal */
override initItems(): void {
this.#attrMO.disconnect();
super.initItems();
this.controlsElements = this.options.getControlsElements?.() ?? [];
if (!this.#observing && this.itemsContainerElement && this.itemsContainerElement.isConnected) {
this.#listMO.observe(this.itemsContainerElement, { childList: true });
this.#observing = true;
}
}
hostDisconnected(): void {
this.controlsElements = [];
this.#observing = false;
this.#listMO.disconnect();
this.#attrMO.disconnect();
}
@bound
protected override onKeydown(event: KeyboardEvent): void {
if (!event.ctrlKey
&& !event.altKey
&& !event.metaKey
&& !!this.atFocusableItems.length) {
super.onKeydown(event);
};
}
public renderItemsToShadowRoot(): typeof nothing | Node[] {
if (ActivedescendantController.supportsCrossRootActiveDescendant) {
return nothing;
} else {
return this.items?.filter(x => !this.#noCloneSet.has(x));
}
}
}