legacy.css 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,265 @@
1
+ import { eventTargetElement, isElement, isLegacyCollection } from "../internal";
2
+ import type { LegacyCloseEvent, LegacyFocusableElement, LegacyRequiredApi, LegacyTarget } from "../internal";
3
+ const openDialogs: HTMLDialogElement[] = [];
4
+ const openerMap = new WeakMap<HTMLDialogElement, HTMLElement | null>();
5
+ const fallbackDialogs = new WeakSet<HTMLDialogElement>();
6
+ const wiredDialogs = new WeakSet<HTMLDialogElement>();
7
+ const supportsNativeDialog = (dialog: HTMLDialogElement): boolean => typeof dialog.showModal === "function";
8
+ let scrollLockCount = 0;
9
+ let previousBodyOverflow = "";
10
+ let previousHtmlOverflow = "";
11
+ const focusableSelector = [
12
+ 'a[href]',
13
+ 'button:not([disabled])',
14
+ 'input:not([disabled])',
15
+ 'select:not([disabled])',
16
+ 'textarea:not([disabled])',
17
+ '[tabindex]:not([tabindex="-1"])',
18
+ ].join(", ");
19
+
20
+ export function installModal(legacy: LegacyRequiredApi): void {
21
+ function resolveDialog(target: LegacyTarget<HTMLDialogElement>): HTMLDialogElement | null {
22
+ if (!target) {
23
+ return null;
24
+ }
25
+
26
+ if (isLegacyCollection(target)) {
27
+ return resolveDialog(target[0]);
28
+ }
29
+
30
+ if (typeof target === "string") {
31
+ return document.querySelector(target);
32
+ }
33
+
34
+ if (typeof HTMLDialogElement !== "undefined" && target instanceof HTMLDialogElement) {
35
+ return target;
36
+ }
37
+
38
+ if (isElement(target) && target.nodeName === "DIALOG") {
39
+ return target as HTMLDialogElement;
40
+ }
41
+
42
+ return null;
43
+ }
44
+
45
+ function getFocusableElement(dialog: HTMLDialogElement): LegacyFocusableElement | null {
46
+ return dialog.querySelector("[autofocus], [data-modal-autofocus], " + focusableSelector);
47
+ }
48
+
49
+ function focusDialog(dialog: HTMLDialogElement): void {
50
+ const focusTarget = getFocusableElement(dialog);
51
+
52
+ if (focusTarget) {
53
+ try {
54
+ focusTarget.focus({ preventScroll: true });
55
+ } catch (error) {
56
+ focusTarget.focus();
57
+ }
58
+ return;
59
+ }
60
+
61
+ /* v8 ignore next -- branch depends on caller-provided dialog markup */
62
+ if (!dialog.hasAttribute("tabindex")) {
63
+ dialog.setAttribute("tabindex", "-1");
64
+ }
65
+
66
+ try {
67
+ dialog.focus({ preventScroll: true });
68
+ } catch (error) {
69
+ dialog.focus();
70
+ }
71
+ }
72
+
73
+ function lockScroll(): void {
74
+ if (scrollLockCount > 0) {
75
+ scrollLockCount += 1;
76
+ return;
77
+ }
78
+
79
+ previousBodyOverflow = document.body.style.overflow;
80
+ previousHtmlOverflow = document.documentElement.style.overflow;
81
+ document.body.style.overflow = "hidden";
82
+ document.documentElement.style.overflow = "hidden";
83
+ scrollLockCount = 1;
84
+ }
85
+
86
+ function unlockScroll(): void {
87
+ /* v8 ignore next -- defensive guard for private scroll-lock bookkeeping */
88
+ if (scrollLockCount === 0) {
89
+ return;
90
+ }
91
+
92
+ scrollLockCount -= 1;
93
+
94
+ if (scrollLockCount > 0) {
95
+ return;
96
+ }
97
+
98
+ document.body.style.overflow = previousBodyOverflow;
99
+ document.documentElement.style.overflow = previousHtmlOverflow;
100
+ }
101
+
102
+ function removeFromOpenDialogs(dialog: HTMLDialogElement): void {
103
+ const index = openDialogs.indexOf(dialog);
104
+
105
+ /* v8 ignore next -- defensive removal branch for repeated native close events */
106
+ if (index >= 0) {
107
+ openDialogs.splice(index, 1);
108
+ }
109
+ }
110
+
111
+ function restoreFocus(dialog: HTMLDialogElement): void {
112
+ const opener = openerMap.get(dialog);
113
+
114
+ /* v8 ignore next -- branch combines browser focus state and opener lifecycle */
115
+ if (opener && typeof opener.focus === "function" && document.contains(opener)) {
116
+ try {
117
+ opener.focus({ preventScroll: true });
118
+ } catch (error) {
119
+ opener.focus();
120
+ }
121
+ }
122
+
123
+ openerMap.delete(dialog);
124
+ }
125
+
126
+ function handleClose(event: Event | LegacyCloseEvent): void {
127
+ const dialog = event.currentTarget as HTMLDialogElement;
128
+
129
+ removeFromOpenDialogs(dialog);
130
+ dialog.removeAttribute("aria-modal");
131
+ restoreFocus(dialog);
132
+
133
+ if (fallbackDialogs.has(dialog)) {
134
+ fallbackDialogs.delete(dialog);
135
+ unlockScroll();
136
+
137
+ if (scrollLockCount === 0) {
138
+ document.removeEventListener("keydown", handleKeydown);
139
+ }
140
+ }
141
+ }
142
+
143
+ function closeDialogElement(dialog: HTMLDialogElement | null, returnValue = ""): HTMLDialogElement | null {
144
+ if (!dialog) {
145
+ return null;
146
+ }
147
+
148
+ if (!dialog.open) {
149
+ return dialog;
150
+ }
151
+
152
+ if (typeof dialog.close === "function") {
153
+ dialog.close(returnValue);
154
+ } else {
155
+ dialog.removeAttribute("open");
156
+ handleClose({ currentTarget: dialog });
157
+ }
158
+
159
+ return dialog;
160
+ }
161
+
162
+ function handleBackdropClick(event: MouseEvent): void {
163
+ const dialog = event.currentTarget as HTMLDialogElement;
164
+ const target = eventTargetElement(event);
165
+
166
+ if (target === dialog) {
167
+ closeDialogElement(dialog);
168
+ return;
169
+ }
170
+
171
+ /* v8 ignore next -- click target shape is browser-dispatched and covered at API level */
172
+ if (target && target.closest("[data-modal-close]")) {
173
+ closeDialogElement(dialog);
174
+ }
175
+ }
176
+
177
+ function handleKeydown(event: KeyboardEvent): void {
178
+ if (event.key !== "Escape") {
179
+ return;
180
+ }
181
+
182
+ const dialog = openDialogs[openDialogs.length - 1];
183
+
184
+ /* v8 ignore next -- fallback Escape listener is only installed while a fallback dialog is open */
185
+ if (!dialog || !fallbackDialogs.has(dialog)) {
186
+ return;
187
+ }
188
+
189
+ event.preventDefault();
190
+ closeDialogElement(dialog);
191
+ }
192
+
193
+ function wireDialog(dialog: HTMLDialogElement): void {
194
+ if (wiredDialogs.has(dialog)) {
195
+ return;
196
+ }
197
+
198
+ wiredDialogs.add(dialog);
199
+ dialog.addEventListener("close", handleClose);
200
+ dialog.addEventListener("click", handleBackdropClick);
201
+ }
202
+
203
+ function openDialogElement(dialog: HTMLDialogElement | null): HTMLDialogElement | null {
204
+ if (!dialog) {
205
+ return null;
206
+ }
207
+
208
+ wireDialog(dialog);
209
+
210
+ if (dialog.open) {
211
+ return dialog;
212
+ }
213
+
214
+ /* v8 ignore next -- activeElement is browser-owned state */
215
+ openerMap.set(dialog, document.activeElement instanceof HTMLElement ? document.activeElement : null);
216
+ dialog.setAttribute("aria-modal", "true");
217
+
218
+ try {
219
+ if (dialog.isConnected && supportsNativeDialog(dialog)) {
220
+ dialog.showModal();
221
+ } else {
222
+ throw new Error("dialog.showModal is unavailable");
223
+ }
224
+ } catch (error) {
225
+ dialog.setAttribute("open", "");
226
+ fallbackDialogs.add(dialog);
227
+ lockScroll();
228
+ }
229
+
230
+ /* v8 ignore next -- duplicate open state is guarded by the public open early-return */
231
+ if (!openDialogs.includes(dialog)) {
232
+ openDialogs.push(dialog);
233
+ }
234
+
235
+ focusDialog(dialog);
236
+
237
+ if (fallbackDialogs.has(dialog)) {
238
+ document.addEventListener("keydown", handleKeydown);
239
+ }
240
+
241
+ return dialog;
242
+ }
243
+
244
+ function toggleDialogElement(dialog: HTMLDialogElement | null): HTMLDialogElement | null {
245
+ if (!dialog) {
246
+ return null;
247
+ }
248
+
249
+ return dialog.open ? closeDialogElement(dialog) : openDialogElement(dialog);
250
+ }
251
+
252
+ legacy.modal = {
253
+ open(target) {
254
+ return openDialogElement(resolveDialog(target));
255
+ },
256
+ close(target, returnValue) {
257
+ return closeDialogElement(resolveDialog(target), returnValue);
258
+ },
259
+ toggle(target) {
260
+ return toggleDialogElement(resolveDialog(target));
261
+ },
262
+ };
263
+
264
+
265
+ }
@@ -0,0 +1,390 @@
1
+ import { currentTargetElement, eventTargetElement, isElement, isLegacyCollection, isSelectElement } from "../internal";
2
+ import type { LegacyMultiselectState, LegacyRequiredApi, LegacyTarget } from "../internal";
3
+ const wiredMultiselects = new WeakSet<HTMLSelectElement>();
4
+ const multiselectMap = new WeakMap<HTMLSelectElement, LegacyMultiselectState>();
5
+ const multiselectSelector = 'select[multiple][data-multiselect], select[multiple].multiselect-source';
6
+ let multiselectId = 0;
7
+
8
+ export function installMultiselect(legacy: LegacyRequiredApi): void {
9
+ function resolveMultiselect(target: LegacyTarget): HTMLSelectElement | null {
10
+ if (!target) {
11
+ return null;
12
+ }
13
+
14
+ if (isLegacyCollection(target)) {
15
+ return resolveMultiselect(target[0]);
16
+ }
17
+
18
+ if (typeof target === "string") {
19
+ return resolveMultiselect(document.querySelector(target));
20
+ }
21
+
22
+ if (!isElement(target)) {
23
+ return null;
24
+ }
25
+
26
+ if (isSelectElement(target) && target.matches("select[multiple]")) {
27
+ return target;
28
+ }
29
+
30
+ const rootElement = target.matches(".multiselect")
31
+ ? target
32
+ : target.closest(".multiselect");
33
+
34
+ return rootElement && isSelectElement(rootElement.previousElementSibling)
35
+ ? rootElement.previousElementSibling
36
+ : null;
37
+ }
38
+
39
+ function getMultiselectPlaceholder(select: HTMLSelectElement): string {
40
+ return select.getAttribute("data-placeholder") || "Select options";
41
+ }
42
+
43
+ function getMultiselectName(select: HTMLSelectElement): string {
44
+ const ariaLabel = select.getAttribute("aria-label");
45
+
46
+ if (ariaLabel) {
47
+ return ariaLabel;
48
+ }
49
+
50
+ if (!select.id) {
51
+ return "";
52
+ }
53
+
54
+ try {
55
+ const label = document.querySelector('label[for="' + CSS.escape(select.id) + '"]');
56
+
57
+ return label && label.textContent ? label.textContent.trim() : "";
58
+ } catch (error) {
59
+ return "";
60
+ }
61
+ }
62
+
63
+ function getMultiselectSelectedOptions(select: HTMLSelectElement): HTMLOptionElement[] {
64
+ return Array.from(select.options).filter((option) => option.selected);
65
+ }
66
+
67
+ function getMultiselectButtonText(select: HTMLSelectElement): string {
68
+ const selectedOptions = getMultiselectSelectedOptions(select);
69
+
70
+ if (selectedOptions.length === 0) {
71
+ return getMultiselectPlaceholder(select);
72
+ }
73
+
74
+ if (selectedOptions.length <= 2) {
75
+ return selectedOptions.map((option) => option.text).join(", ");
76
+ }
77
+
78
+ return selectedOptions.length + " selected";
79
+ }
80
+
81
+ function getMultiselectOptions(rootElement: Element): HTMLButtonElement[] {
82
+ return Array.from(rootElement.querySelectorAll<HTMLButtonElement>(".multiselect-option"));
83
+ }
84
+
85
+ function updateMultiselect(select: HTMLSelectElement): HTMLSelectElement {
86
+ const state = multiselectMap.get(select);
87
+
88
+ /* v8 ignore next -- update is wired after setup has created state */
89
+ if (!state) {
90
+ return select;
91
+ }
92
+
93
+ state.label.textContent = getMultiselectButtonText(select);
94
+ state.options.forEach((button, index) => {
95
+ const option = select.options[index];
96
+
97
+ button.setAttribute("aria-selected", option.selected ? "true" : "false");
98
+ button.setAttribute("aria-disabled", option.disabled ? "true" : "false");
99
+ button.disabled = option.disabled || select.disabled;
100
+ });
101
+
102
+ state.toggle.disabled = select.disabled;
103
+
104
+ return select;
105
+ }
106
+
107
+ function closeMultiselect(select: HTMLSelectElement | null): HTMLSelectElement | null {
108
+ if (!select) {
109
+ return null;
110
+ }
111
+
112
+ const state = multiselectMap.get(select);
113
+
114
+ if (!state) {
115
+ return select;
116
+ }
117
+
118
+ state.root.classList.remove("is-open");
119
+ state.toggle.setAttribute("aria-expanded", "false");
120
+
121
+ return select;
122
+ }
123
+
124
+ function openMultiselect(select: HTMLSelectElement | null): HTMLSelectElement | null {
125
+ if (!select) {
126
+ return null;
127
+ }
128
+
129
+ const state = multiselectMap.get(select);
130
+
131
+ if (!state || select.disabled) {
132
+ return select;
133
+ }
134
+
135
+ document.querySelectorAll(".multiselect.is-open").forEach((rootElement) => {
136
+ const currentSelect = rootElement.previousElementSibling;
137
+
138
+ /* v8 ignore next -- only sibling multiselect controls are closed */
139
+ if (isSelectElement(currentSelect) && currentSelect !== select) {
140
+ closeMultiselect(currentSelect);
141
+ }
142
+ });
143
+
144
+ state.root.classList.add("is-open");
145
+ state.toggle.setAttribute("aria-expanded", "true");
146
+
147
+ return select;
148
+ }
149
+
150
+ function toggleMultiselect(select: HTMLSelectElement | null): HTMLSelectElement | null {
151
+ if (!select) {
152
+ return null;
153
+ }
154
+
155
+ const state = multiselectMap.get(select);
156
+
157
+ if (!state) {
158
+ return select;
159
+ }
160
+
161
+ return state.root.classList.contains("is-open")
162
+ ? closeMultiselect(select)
163
+ : openMultiselect(select);
164
+ }
165
+
166
+ function toggleMultiselectOption(select: HTMLSelectElement, index: number): void {
167
+ const option = select.options[index];
168
+
169
+ if (!option || option.disabled || select.disabled) {
170
+ return;
171
+ }
172
+
173
+ option.selected = !option.selected;
174
+ updateMultiselect(select);
175
+ select.dispatchEvent(new Event("change", { bubbles: true }));
176
+ }
177
+
178
+ function focusMultiselectOption(rootElement: Element, index: number): void {
179
+ const options = getMultiselectOptions(rootElement).filter((option) => !option.disabled);
180
+ const option = options[index];
181
+
182
+ /* v8 ignore next -- keyboard movement normally resolves an enabled option */
183
+ if (option) {
184
+ option.focus();
185
+ }
186
+ }
187
+
188
+ function handleMultiselectClick(event: MouseEvent): void {
189
+ const select = resolveMultiselect(currentTargetElement(event));
190
+ const target = eventTargetElement(event);
191
+ /* v8 ignore next -- browser click events provide element targets */
192
+ const option = target ? target.closest(".multiselect-option") : null;
193
+
194
+ /* v8 ignore next -- multiselect click listeners are attached only to resolved controls */
195
+ if (!select) {
196
+ return;
197
+ }
198
+
199
+ if (option) {
200
+ toggleMultiselectOption(select, Number(option.getAttribute("data-index")));
201
+ return;
202
+ }
203
+
204
+ /* v8 ignore next -- delegated click no-op branch for menu background clicks */
205
+ if (target && target.closest(".multiselect-toggle")) {
206
+ toggleMultiselect(select);
207
+ }
208
+ }
209
+
210
+ function handleMultiselectKeydown(event: KeyboardEvent): void {
211
+ const select = resolveMultiselect(currentTargetElement(event));
212
+ /* v8 ignore next -- keydown listeners are attached after setup creates state */
213
+ const state = select ? multiselectMap.get(select) : null;
214
+ const target = eventTargetElement(event);
215
+
216
+ if (!state || !target || !select) {
217
+ return;
218
+ }
219
+
220
+ if (event.key === "Escape") {
221
+ closeMultiselect(select);
222
+ state.toggle.focus();
223
+ return;
224
+ }
225
+
226
+ if (event.target === state.toggle) {
227
+ if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
228
+ event.preventDefault();
229
+ openMultiselect(select);
230
+ focusMultiselectOption(state.root, 0);
231
+ }
232
+
233
+ return;
234
+ }
235
+
236
+ const options = getMultiselectOptions(state.root).filter((option) => !option.disabled);
237
+ const currentIndex = options.indexOf(target as HTMLButtonElement);
238
+
239
+ if (currentIndex < 0) {
240
+ return;
241
+ }
242
+
243
+ if (event.key === "ArrowDown") {
244
+ event.preventDefault();
245
+ focusMultiselectOption(state.root, (currentIndex + 1) % options.length);
246
+ } else if (event.key === "ArrowUp") {
247
+ event.preventDefault();
248
+ focusMultiselectOption(state.root, (currentIndex - 1 + options.length) % options.length);
249
+ } else if (event.key === "Home") {
250
+ event.preventDefault();
251
+ focusMultiselectOption(state.root, 0);
252
+ } else if (event.key === "End") {
253
+ event.preventDefault();
254
+ focusMultiselectOption(state.root, options.length - 1);
255
+ } else if (event.key === "Enter" || event.key === " ") {
256
+ event.preventDefault();
257
+ toggleMultiselectOption(select, Number(target.getAttribute("data-index")));
258
+ }
259
+ }
260
+
261
+ function handleDocumentMultiselectClick(event: MouseEvent): void {
262
+ const target = eventTargetElement(event);
263
+
264
+ if (!target) {
265
+ return;
266
+ }
267
+
268
+ document.querySelectorAll(".multiselect.is-open").forEach((rootElement) => {
269
+ if (!rootElement.contains(target)) {
270
+ /* v8 ignore next 5 -- document close tolerates malformed multiselect markup */
271
+ closeMultiselect(
272
+ isSelectElement(rootElement.previousElementSibling)
273
+ ? rootElement.previousElementSibling
274
+ : null
275
+ );
276
+ }
277
+ });
278
+ }
279
+
280
+ function createMultiselectOption(option: HTMLOptionElement, index: number, rootId: string): HTMLButtonElement {
281
+ const button = document.createElement("button");
282
+
283
+ button.className = "multiselect-option";
284
+ button.type = "button";
285
+ button.id = rootId + "-option-" + index;
286
+ button.setAttribute("role", "option");
287
+ button.setAttribute("data-index", String(index));
288
+ button.textContent = option.text;
289
+
290
+ return button;
291
+ }
292
+
293
+ function setupMultiselect(target: LegacyTarget): HTMLSelectElement | null {
294
+ const select = resolveMultiselect(target);
295
+
296
+ if (!select || !select.matches("select[multiple]")) {
297
+ return null;
298
+ }
299
+
300
+ if (wiredMultiselects.has(select)) {
301
+ updateMultiselect(select);
302
+ return select;
303
+ }
304
+
305
+ const rootElement = document.createElement("div");
306
+ const toggle = document.createElement("button");
307
+ const label = document.createElement("span");
308
+ const menu = document.createElement("div");
309
+ /* v8 ignore next -- id/name/default id selection is covered by setup variants */
310
+ const rootId = select.id || select.name || "multiselect-" + ++multiselectId;
311
+ const menuId = rootId + "-menu";
312
+ const options = Array.from(select.options).map((option, index) =>
313
+ createMultiselectOption(option, index, rootId)
314
+ );
315
+
316
+ rootElement.className = "multiselect";
317
+ toggle.className = "multiselect-toggle";
318
+ toggle.id = rootId + "-toggle";
319
+ toggle.type = "button";
320
+ toggle.setAttribute("aria-expanded", "false");
321
+ toggle.setAttribute("aria-haspopup", "listbox");
322
+ toggle.setAttribute("aria-controls", menuId);
323
+ label.className = "multiselect-label";
324
+ menu.className = "multiselect-menu";
325
+ menu.id = menuId;
326
+ menu.setAttribute("role", "listbox");
327
+ menu.setAttribute("aria-multiselectable", "true");
328
+
329
+ const name = getMultiselectName(select);
330
+
331
+ if (name) {
332
+ toggle.setAttribute("aria-label", name);
333
+ }
334
+
335
+ toggle.append(label);
336
+
337
+ if (options.length === 0) {
338
+ const empty = document.createElement("div");
339
+
340
+ empty.className = "multiselect-empty";
341
+ empty.textContent = "No options";
342
+ menu.append(empty);
343
+ } else {
344
+ options.forEach((option) => menu.append(option));
345
+ }
346
+
347
+ rootElement.append(toggle, menu);
348
+ select.classList.add("multiselect-source");
349
+ select.after(rootElement);
350
+
351
+ multiselectMap.set(select, { label, menu, options, root: rootElement, toggle });
352
+ wiredMultiselects.add(select);
353
+ rootElement.addEventListener("click", handleMultiselectClick);
354
+ rootElement.addEventListener("keydown", handleMultiselectKeydown);
355
+ select.addEventListener("change", () => updateMultiselect(select));
356
+
357
+ if (select.form) {
358
+ select.form.addEventListener("reset", () => {
359
+ setTimeout(() => updateMultiselect(select), 0);
360
+ });
361
+ }
362
+
363
+ updateMultiselect(select);
364
+
365
+ return select;
366
+ }
367
+
368
+ legacy.multiselect = {
369
+ setup(target) {
370
+ return setupMultiselect(target);
371
+ },
372
+ open(target) {
373
+ return openMultiselect(resolveMultiselect(target));
374
+ },
375
+ close(target) {
376
+ return closeMultiselect(resolveMultiselect(target));
377
+ },
378
+ toggle(target) {
379
+ return toggleMultiselect(resolveMultiselect(target));
380
+ },
381
+ };
382
+
383
+ document.addEventListener("DOMContentLoaded", function () {
384
+ document.querySelectorAll(multiselectSelector).forEach(setupMultiselect);
385
+ });
386
+
387
+ document.addEventListener("click", handleDocumentMultiselectClick);
388
+
389
+
390
+ }