Skip to content

Commit 8d0bc28

Browse files
committed
Add basic ARIA support
1 parent 291e9b5 commit 8d0bc28

File tree

3 files changed

+147
-24
lines changed

3 files changed

+147
-24
lines changed

ts/WoltLabSuite/WebComponent/woltlab-core-list-box.ts

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
1010

1111
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
1212
class WoltlabCoreListBoxElement extends HTMLParsedElement {
13+
#position = -1;
1314
#selected = "";
1415
readonly #formInput: HTMLInputElement;
15-
readonly #items: Set<WoltlabCoreListItemElement> = new Set();
16+
readonly #knownItems: WeakSet<WoltlabCoreListItemElement> = new WeakSet();
1617
readonly #shadow: ShadowRoot;
1718
readonly #slot: HTMLSlotElement;
1819

@@ -44,12 +45,47 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
4445

4546
this.#formInput = document.createElement("input");
4647
this.#formInput.type = "hidden";
48+
49+
this.addEventListener("focus", () => {
50+
const items = this.#getItems();
51+
if (items.length === 0) {
52+
return;
53+
}
54+
55+
let position = items.findIndex((item) => item.selected);
56+
if (position === -1) {
57+
position = 0;
58+
}
59+
60+
this.#setFocus(items, position);
61+
});
62+
63+
this.addEventListener("keydown", (event) => {
64+
switch (event.key) {
65+
case "ArrowDown":
66+
event.preventDefault();
67+
this.#focusNextItem();
68+
break;
69+
70+
case "ArrowUp":
71+
event.preventDefault();
72+
this.#focusPreviousItem();
73+
break;
74+
75+
case "Enter":
76+
event.preventDefault();
77+
this.#selectItem();
78+
break;
79+
}
80+
});
4781
}
4882

4983
parsedCallback() {
84+
this.classList.add("listBox");
5085
this.role = "listbox";
51-
this.setAttribute("aria-multiselectable", "false");
52-
this.setAttribute("aria-orientation", "vertical");
86+
this.ariaMultiSelectable = "false";
87+
this.ariaOrientation = "vertical";
88+
this.tabIndex = 0;
5389

5490
const selected = this.getAttribute("selected") || this.#selected;
5591
this.removeAttribute("selected");
@@ -72,8 +108,8 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
72108
element.selected = false;
73109
}
74110

75-
if (!this.#items.has(element)) {
76-
this.#items.add(element);
111+
if (!this.#knownItems.has(element)) {
112+
this.#knownItems.add(element);
77113

78114
element.addEventListener("change", (event) => {
79115
if (event.detail.selected) {
@@ -93,7 +129,7 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
93129
this.#selected = value;
94130
this.setAttribute("selected", value);
95131

96-
for (const item of this.#items) {
132+
for (const item of this.#getItems()) {
97133
if (item.selected) {
98134
if (item.value !== value) {
99135
item.selected = false;
@@ -115,6 +151,67 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
115151
this.dispatchEvent(event);
116152
}
117153

154+
#focusNextItem(): void {
155+
const items = this.#getItems();
156+
const size = items.length;
157+
if (size === 0) {
158+
return;
159+
}
160+
161+
let position = this.#position + 1;
162+
if (position >= size) {
163+
position = size - 1;
164+
}
165+
166+
if (position === this.#position) {
167+
return;
168+
}
169+
170+
this.#setFocus(items, position);
171+
}
172+
173+
#focusPreviousItem(): void {
174+
const items = this.#getItems();
175+
if (items.length === 0) {
176+
return;
177+
}
178+
179+
let position = this.#position - 1;
180+
if (position < 0) {
181+
position = 0;
182+
}
183+
184+
if (position === this.#position) {
185+
return;
186+
}
187+
188+
this.#setFocus(items, position);
189+
}
190+
191+
#setFocus(items: WoltlabCoreListItemElement[], position: number): void {
192+
for (let i = 0, length = items.length; i < length; i++) {
193+
const item = items[i];
194+
195+
if (i === position) {
196+
this.setAttribute("aria-activedescendant", item.id);
197+
item.focused = true;
198+
} else {
199+
item.focused = false;
200+
}
201+
}
202+
203+
this.#position = position;
204+
}
205+
206+
#selectItem(): void {
207+
const item = this.#getItems()[this.#position];
208+
if (item === undefined) {
209+
return;
210+
}
211+
212+
this.#changeSelection(item.value);
213+
}
214+
118215
#updateFormInput(name: string): void {
119216
if (name === "") {
120217
this.removeAttribute("name");
@@ -127,6 +224,12 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
127224
}
128225
}
129226

227+
#getItems(): WoltlabCoreListItemElement[] {
228+
return Array.from(this.#slot.assignedElements()).filter(
229+
(element) => element instanceof WoltlabCoreListItemElement,
230+
);
231+
}
232+
130233
get selected(): string {
131234
return this.#selected;
132235
}

ts/WoltLabSuite/WebComponent/woltlab-core-list-item.ts

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1+
let idCounter = 0;
2+
13
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
24
export class WoltlabCoreListItemElement extends HTMLElement {
35
#shadow: ShadowRoot | undefined = undefined;
46

57
connectedCallback() {
68
this.role = "option";
7-
this.setAttribute("aria-selected", String(this.selected));
8-
this.tabIndex = this.selected ? 0 : -1;
9+
this.classList.add("listBox__item");
10+
if (this.ariaSelected === null) {
11+
this.ariaSelected = "false";
12+
}
13+
14+
if (!this.id) {
15+
this.id = "woltlabCoreListItem" + idCounter++;
16+
}
917

1018
this.addEventListener("click", () => {
1119
this.selected = true;
@@ -67,6 +75,7 @@ html[data-color-scheme="dark"] :host {
6775

6876
const iconWrapper = document.createElement("div");
6977
iconWrapper.classList.add("icon");
78+
iconWrapper.ariaHidden = "true";
7079

7180
const iconSlot = document.createElement("slot");
7281
iconSlot.name = "icon";
@@ -87,15 +96,6 @@ html[data-color-scheme="dark"] :host {
8796
this.querySelector('slot[name="icon"]')?.remove();
8897

8998
this.append(icon);
90-
91-
this.addEventListener("focus", () => {
92-
this.tabIndex = 0;
93-
});
94-
this.addEventListener("blur", () => {
95-
if (!this.selected) {
96-
this.tabIndex = -1;
97-
}
98-
});
9999
}
100100

101101
#getShadow(): ShadowRoot {
@@ -107,18 +107,26 @@ html[data-color-scheme="dark"] :host {
107107
}
108108

109109
get selected(): boolean {
110-
return this.hasAttribute("selected");
110+
return this.ariaSelected === "true";
111111
}
112112

113113
set selected(selected: boolean) {
114114
if (selected) {
115-
this.setAttribute("aria-selected", "true");
116-
this.setAttribute("selected", "");
117-
this.tabIndex = 0;
115+
this.ariaSelected = "true";
116+
} else {
117+
this.ariaSelected = "false";
118+
}
119+
}
120+
121+
get focused(): boolean {
122+
return this.hasAttribute("focused");
123+
}
124+
125+
set focused(focused: boolean) {
126+
if (focused) {
127+
this.setAttribute("focused", "");
118128
} else {
119-
this.setAttribute("aria-selected", "false");
120-
this.removeAttribute("selected");
121-
this.tabIndex = -1;
129+
this.removeAttribute("focused");
122130
}
123131
}
124132

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.listBox:focus-visible {
2+
/* Do not visually render the focus on the list box, instead we the focus
3+
is visually moved to an item in the list.
4+
See https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_focus_activedescendant */
5+
outline: none !important;
6+
}
7+
8+
.listBox__item[focused] {
9+
/* See https://css-tricks.com/copy-the-browsers-native-focus-styles/ */
10+
outline: 5px auto Highlight;
11+
outline: 5px auto -webkit-focus-ring-color;
12+
}

0 commit comments

Comments
 (0)