Skip to content

Commit d68509a

Browse files
committed
Implement the selection of multiple items
1 parent 224f543 commit d68509a

File tree

2 files changed

+160
-68
lines changed

2 files changed

+160
-68
lines changed

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

Lines changed: 80 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ import HTMLParsedElement from "./html-parsed-element";
22
import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
33

44
{
5-
type ChangePayload = { selected: string };
6-
75
interface WoltlabCoreListBoxEventMap {
8-
change: CustomEvent<ChangePayload>;
6+
change: CustomEvent<void>;
97
}
108

119
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
1210
class WoltlabCoreListBoxElement extends HTMLParsedElement {
11+
#multiple = false;
1312
#position = -1;
1413
#selected = "";
14+
#selectedValues: string[] = [];
1515
readonly #formInput: HTMLInputElement;
1616
readonly #knownItems: WeakSet<WoltlabCoreListItemElement> = new WeakSet();
1717
readonly #shadow: ShadowRoot;
@@ -24,20 +24,21 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
2424
style.textContent = `
2525
:host {
2626
background-color: var(--wcfDropdownBackground);
27-
border-radius: 4px;
28-
box-shadow: var(--wcfBoxShadow);
29-
color: var(--wcfDropdownText);
27+
border-radius: 4px;
28+
box-shadow: var(--wcfBoxShadow);
29+
color: var(--wcfDropdownText);
3030
display: flex;
31-
flex-direction: column;
32-
min-width: 160px !important;
33-
padding: 4px 0;
34-
pointer-events: all;
35-
position: fixed;
36-
text-align: left;
37-
z-index: 450;
31+
min-width: 160px !important;
32+
padding: 4px 0;
33+
pointer-events: all;
34+
position: fixed;
35+
text-align: left;
36+
z-index: 450;
3837
}
3938
4039
.content {
40+
display: flex;
41+
flex-direction: column;
4142
overflow: auto;
4243
}
4344
`;
@@ -69,12 +70,6 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
6970
});
7071

7172
this.addEventListener("keydown", (event) => {
72-
if (event.key.length === 1) {
73-
event.preventDefault();
74-
this.#focusFirstMatchingItem(event.key);
75-
return;
76-
}
77-
7873
switch (event.key) {
7974
case "ArrowDown":
8075
event.preventDefault();
@@ -92,22 +87,42 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
9287
break;
9388

9489
case "Enter":
95-
event.preventDefault();
96-
this.#selectItem();
90+
if (!this.#multiple) {
91+
event.preventDefault();
92+
this.#selectItem();
93+
}
9794
break;
9895

9996
case "Home":
10097
event.preventDefault();
10198
this.#focusFirstItem();
10299
break;
100+
101+
case " ":
102+
// The space is always intercepted because in a single selection
103+
// list it would trigger a scroll event.
104+
event.preventDefault();
105+
106+
if (this.#multiple) {
107+
this.#selectItem();
108+
}
109+
break;
110+
111+
default:
112+
if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
113+
event.preventDefault();
114+
this.#focusFirstMatchingItem(event.key);
115+
}
116+
break;
103117
}
104118
});
105119
}
106120

107121
parsedCallback() {
108122
this.classList.add("listBox");
109123
this.role = "listbox";
110-
this.ariaMultiSelectable = "false";
124+
this.#multiple = this.hasAttribute("multiple");
125+
this.ariaMultiSelectable = String(this.#multiple);
111126
this.ariaOrientation = "vertical";
112127
this.tabIndex = 0;
113128

@@ -135,12 +150,8 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
135150
if (!this.#knownItems.has(element)) {
136151
this.#knownItems.add(element);
137152

138-
element.addEventListener("change", (event) => {
139-
if (event.detail.selected) {
140-
this.#changeSelection(element.value);
141-
} else {
142-
throw new Error("TODO: not implemented");
143-
}
153+
element.addEventListener("change", () => {
154+
this.#updateSelection(element);
144155
});
145156
}
146157
}
@@ -149,29 +160,37 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
149160
this.#updateFormInput(this.name);
150161
}
151162

152-
#changeSelection(value: string): void {
153-
this.#selected = value;
154-
this.setAttribute("selected", value);
163+
#updateSelection(changedItem: WoltlabCoreListItemElement): void {
164+
const items = this.#getItems();
165+
166+
if (this.#multiple) {
167+
this.#selectedValues = items.filter((item) => item.selected).map((item) => item.value);
168+
169+
const position = items.indexOf(changedItem);
170+
this.#setFocus(items, position);
171+
} else {
172+
const { value } = changedItem;
155173

156-
for (const item of this.#getItems()) {
157-
if (item.selected) {
158-
if (item.value !== value) {
174+
for (const item of items) {
175+
if (!item.selected) {
176+
continue;
177+
}
178+
179+
if (changedItem === undefined) {
180+
item.selected = false;
181+
} else if (item.value === value) {
182+
this.#selected = value;
183+
} else {
159184
item.selected = false;
160185
}
161-
} else if (item.value === value) {
162-
item.selected = true;
163186
}
164187
}
165188

166-
if (this.#formInput !== undefined) {
189+
/*if (this.#formInput !== undefined) {
167190
this.#formInput.value = value;
168-
}
191+
}*/
169192

170-
const event = new CustomEvent<ChangePayload>("change", {
171-
detail: {
172-
selected: value,
173-
},
174-
});
193+
const event = new CustomEvent<void>("change");
175194
this.dispatchEvent(event);
176195
}
177196

@@ -290,7 +309,7 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
290309
return;
291310
}
292311

293-
this.#changeSelection(item.value);
312+
item.toggle();
294313
}
295314

296315
#updateFormInput(name: string): void {
@@ -311,17 +330,33 @@ import { WoltlabCoreListItemElement } from "./woltlab-core-list-item";
311330
);
312331
}
313332

314-
get selected(): string {
333+
get selected(): string | undefined {
334+
if (this.#multiple) {
335+
return undefined;
336+
}
337+
315338
return this.#selected;
316339
}
317340

341+
get selectedValues(): string[] | undefined {
342+
if (this.#multiple) {
343+
return this.#selectedValues;
344+
}
345+
346+
return undefined;
347+
}
348+
318349
get name(): string {
319350
return this.getAttribute("name") || "";
320351
}
321352

322353
set name(name: string) {
323354
this.#updateFormInput(name);
324355
}
356+
357+
get multiple(): boolean {
358+
return this.#multiple;
359+
}
325360
}
326361

327362
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging

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

Lines changed: 80 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,42 @@ let idCounter = 0;
33
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
44
export class WoltlabCoreListItemElement extends HTMLElement {
55
#shadow: ShadowRoot | undefined = undefined;
6+
#multiple = false;
7+
readonly #checkbox: HTMLInputElement;
8+
9+
constructor() {
10+
super();
11+
12+
this.#checkbox = document.createElement("input");
13+
this.#checkbox.type = "checkbox";
14+
this.#checkbox.inert = true;
15+
}
616

717
connectedCallback() {
818
this.role = "option";
919
this.classList.add("listBox__item");
10-
if (this.ariaSelected === null) {
11-
this.ariaSelected = "false";
20+
21+
this.#multiple = false;
22+
// We cannot use a proper `instanceof` check here as it would create a
23+
// circular dependency.
24+
if (this.parentElement?.tagName === "WOLTLAB-CORE-LIST-BOX") {
25+
this.#multiple = this.parentElement.hasAttribute("multiple");
26+
}
27+
28+
if (this.#multiple) {
29+
this.ariaChecked = String(this.selected);
30+
this.removeAttribute("aria-selected");
31+
} else {
32+
this.ariaSelected = String(this.selected);
33+
this.removeAttribute("aria-checked");
1234
}
1335

1436
if (!this.id) {
1537
this.id = "woltlabCoreListItem" + idCounter++;
1638
}
1739

1840
this.addEventListener("click", () => {
19-
this.selected = true;
20-
21-
const event = new CustomEvent<WoltlabCoreListItemChangePayload>("change", {
22-
detail: {
23-
selected: this.selected,
24-
},
25-
});
26-
this.dispatchEvent(event);
41+
this.toggle();
2742
});
2843

2944
const shadow = this.#getShadow();
@@ -57,7 +72,7 @@ html[data-color-scheme="dark"] :host {
5772
grid-area: icon;
5873
}
5974
60-
:host(:not([selected])) .icon {
75+
:host(:not([aria-selected="true"])) .icon ::slotted(fa-icon) {
6176
visibility: hidden;
6277
}
6378
@@ -71,6 +86,10 @@ html[data-color-scheme="dark"] :host {
7186
white-space: nowrap;
7287
word-wrap: normal;
7388
}
89+
90+
input {
91+
pointer-events: none;
92+
}
7493
`;
7594

7695
const iconWrapper = document.createElement("div");
@@ -89,13 +108,17 @@ html[data-color-scheme="dark"] :host {
89108

90109
shadow.append(style, iconWrapper, contentWrapper);
91110

92-
const icon = document.createElement("fa-icon");
93-
icon.setIcon("check");
94-
icon.slot = "icon";
95-
96111
this.querySelector('slot[name="icon"]')?.remove();
97112

98-
this.append(icon);
113+
if (this.#multiple) {
114+
iconWrapper.append(this.#checkbox);
115+
} else {
116+
const icon = document.createElement("fa-icon");
117+
icon.setIcon("check");
118+
icon.slot = "icon";
119+
120+
this.append(icon);
121+
}
99122
}
100123

101124
#getShadow(): ShadowRoot {
@@ -106,15 +129,47 @@ html[data-color-scheme="dark"] :host {
106129
return this.#shadow;
107130
}
108131

132+
toggle(): void {
133+
let hasChanged = false;
134+
if (this.#multiple) {
135+
this.selected = !this.selected;
136+
137+
hasChanged = true;
138+
} else if (!this.selected) {
139+
this.selected = true;
140+
141+
hasChanged = true;
142+
}
143+
144+
if (hasChanged) {
145+
const event = new CustomEvent<void>("change");
146+
this.dispatchEvent(event);
147+
}
148+
}
149+
109150
get selected(): boolean {
110-
return this.ariaSelected === "true";
151+
return this.ariaSelected === "true" || this.ariaChecked === "true";
111152
}
112153

113154
set selected(selected: boolean) {
114-
if (selected) {
115-
this.ariaSelected = "true";
155+
if (selected === this.selected) {
156+
return;
157+
}
158+
159+
if (this.#multiple) {
160+
this.#checkbox.checked = selected;
161+
162+
if (selected) {
163+
this.ariaChecked = "true";
164+
} else {
165+
this.ariaChecked = "false";
166+
}
116167
} else {
117-
this.ariaSelected = "false";
168+
if (selected) {
169+
this.ariaSelected = "true";
170+
} else {
171+
this.ariaSelected = "false";
172+
}
118173
}
119174
}
120175

@@ -133,14 +188,16 @@ html[data-color-scheme="dark"] :host {
133188
get value(): string {
134189
return this.getAttribute("value") || "";
135190
}
191+
192+
get multiple(): boolean {
193+
return this.#multiple;
194+
}
136195
}
137196

138197
window.customElements.define("woltlab-core-list-item", WoltlabCoreListItemElement);
139198

140-
type WoltlabCoreListItemChangePayload = { selected: boolean };
141-
142199
interface WoltlabCoreListItemEventMap {
143-
change: CustomEvent<WoltlabCoreListItemChangePayload>;
200+
change: CustomEvent<void>;
144201
}
145202

146203
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging

0 commit comments

Comments
 (0)