@lmfaole/basics 0.1.1 → 0.3.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,387 @@
1
+ const ElementBase = globalThis.Element ?? class {};
2
+ const HTMLElementBase = globalThis.HTMLElement ?? class {};
3
+ const HTMLButtonElementBase = globalThis.HTMLButtonElement ?? class {};
4
+
5
+ export const ACCORDION_TAG_NAME = "basic-accordion";
6
+
7
+ const TRIGGER_SELECTOR = "[data-accordion-trigger]";
8
+ const PANEL_SELECTOR = "[data-accordion-panel]";
9
+
10
+ let nextAccordionInstanceId = 1;
11
+
12
+ function collectOwnedElements(root, scope, selector) {
13
+ return Array.from(scope.querySelectorAll(selector)).filter(
14
+ (element) => element instanceof HTMLElementBase && element.closest(ACCORDION_TAG_NAME) === root,
15
+ );
16
+ }
17
+
18
+ function isAccordionItemDisabled(trigger) {
19
+ return trigger.hasAttribute("disabled") || trigger.getAttribute("aria-disabled") === "true";
20
+ }
21
+
22
+ function findFirstEnabledAccordionIndex(itemStates) {
23
+ for (let index = 0; index < itemStates.length; index += 1) {
24
+ if (!itemStates[index]?.disabled) {
25
+ return index;
26
+ }
27
+ }
28
+
29
+ return -1;
30
+ }
31
+
32
+ function findLastEnabledAccordionIndex(itemStates) {
33
+ for (let index = itemStates.length - 1; index >= 0; index -= 1) {
34
+ if (!itemStates[index]?.disabled) {
35
+ return index;
36
+ }
37
+ }
38
+
39
+ return -1;
40
+ }
41
+
42
+ export function getInitialOpenAccordionIndexes(
43
+ itemStates,
44
+ { multiple = false, collapsible = false } = {},
45
+ ) {
46
+ const explicitOpenIndexes = [];
47
+
48
+ for (let index = 0; index < itemStates.length; index += 1) {
49
+ const itemState = itemStates[index];
50
+
51
+ if (itemState?.open && !itemState.disabled) {
52
+ explicitOpenIndexes.push(index);
53
+ }
54
+ }
55
+
56
+ if (multiple) {
57
+ return explicitOpenIndexes;
58
+ }
59
+
60
+ if (explicitOpenIndexes.length > 0) {
61
+ return [explicitOpenIndexes[0]];
62
+ }
63
+
64
+ if (collapsible) {
65
+ return [];
66
+ }
67
+
68
+ const firstEnabledIndex = findFirstEnabledAccordionIndex(itemStates);
69
+ return firstEnabledIndex === -1 ? [] : [firstEnabledIndex];
70
+ }
71
+
72
+ export function findNextEnabledAccordionIndex(itemStates, startIndex, direction) {
73
+ if (itemStates.length === 0) {
74
+ return -1;
75
+ }
76
+
77
+ const step = direction < 0 ? -1 : 1;
78
+ let nextIndex = startIndex;
79
+
80
+ for (let checked = 0; checked < itemStates.length; checked += 1) {
81
+ nextIndex += step;
82
+
83
+ if (nextIndex < 0) {
84
+ nextIndex = itemStates.length - 1;
85
+ } else if (nextIndex >= itemStates.length) {
86
+ nextIndex = 0;
87
+ }
88
+
89
+ if (!itemStates[nextIndex]?.disabled) {
90
+ return nextIndex;
91
+ }
92
+ }
93
+
94
+ return -1;
95
+ }
96
+
97
+ export class AccordionElement extends HTMLElementBase {
98
+ static observedAttributes = ["data-collapsible", "data-multiple"];
99
+
100
+ #instanceId = `${ACCORDION_TAG_NAME}-${nextAccordionInstanceId++}`;
101
+ #triggers = [];
102
+ #panels = [];
103
+ #openIndexes = new Set();
104
+ #focusIndex = -1;
105
+ #eventsBound = false;
106
+
107
+ connectedCallback() {
108
+ if (!this.#eventsBound) {
109
+ this.addEventListener("click", this.#handleClick);
110
+ this.addEventListener("keydown", this.#handleKeyDown);
111
+ this.#eventsBound = true;
112
+ }
113
+
114
+ this.#sync({ resetOpen: true });
115
+ }
116
+
117
+ disconnectedCallback() {
118
+ if (!this.#eventsBound) {
119
+ return;
120
+ }
121
+
122
+ this.removeEventListener("click", this.#handleClick);
123
+ this.removeEventListener("keydown", this.#handleKeyDown);
124
+ this.#eventsBound = false;
125
+ }
126
+
127
+ attributeChangedCallback() {
128
+ this.#sync({ resetOpen: true });
129
+ }
130
+
131
+ #handleClick = (event) => {
132
+ if (!(event.target instanceof ElementBase)) {
133
+ return;
134
+ }
135
+
136
+ const trigger = event.target.closest(TRIGGER_SELECTOR);
137
+
138
+ if (
139
+ !(trigger instanceof HTMLElementBase)
140
+ || trigger.closest(ACCORDION_TAG_NAME) !== this
141
+ ) {
142
+ return;
143
+ }
144
+
145
+ const triggerIndex = this.#triggers.indexOf(trigger);
146
+
147
+ if (triggerIndex === -1) {
148
+ return;
149
+ }
150
+
151
+ this.#toggleIndex(triggerIndex, { focus: true });
152
+ };
153
+
154
+ #handleKeyDown = (event) => {
155
+ if (!(event.target instanceof ElementBase)) {
156
+ return;
157
+ }
158
+
159
+ const currentTrigger = event.target.closest(TRIGGER_SELECTOR);
160
+
161
+ if (
162
+ !(currentTrigger instanceof HTMLElementBase)
163
+ || currentTrigger.closest(ACCORDION_TAG_NAME) !== this
164
+ ) {
165
+ return;
166
+ }
167
+
168
+ const itemStates = this.#getItemStates();
169
+ const currentIndex = this.#triggers.indexOf(currentTrigger);
170
+ let nextIndex = -1;
171
+
172
+ if (currentIndex === -1 || currentIndex >= itemStates.length) {
173
+ return;
174
+ }
175
+
176
+ switch (event.key) {
177
+ case "ArrowDown":
178
+ nextIndex = findNextEnabledAccordionIndex(itemStates, currentIndex, 1);
179
+ break;
180
+ case "ArrowUp":
181
+ nextIndex = findNextEnabledAccordionIndex(itemStates, currentIndex, -1);
182
+ break;
183
+ case "Home":
184
+ nextIndex = findFirstEnabledAccordionIndex(itemStates);
185
+ break;
186
+ case "End":
187
+ nextIndex = findLastEnabledAccordionIndex(itemStates);
188
+ break;
189
+ case " ":
190
+ case "Enter":
191
+ event.preventDefault();
192
+ this.#toggleIndex(currentIndex, { focus: true });
193
+ return;
194
+ default:
195
+ return;
196
+ }
197
+
198
+ if (nextIndex === -1) {
199
+ return;
200
+ }
201
+
202
+ event.preventDefault();
203
+ this.#focusIndex = nextIndex;
204
+ this.#applyState({ focus: true });
205
+ };
206
+
207
+ #getItemStates() {
208
+ const pairCount = Math.min(this.#triggers.length, this.#panels.length);
209
+
210
+ return this.#triggers.slice(0, pairCount).map((trigger, index) => ({
211
+ disabled: isAccordionItemDisabled(trigger),
212
+ open: trigger.hasAttribute("data-open")
213
+ || trigger.getAttribute("aria-expanded") === "true"
214
+ || this.#panels[index]?.hasAttribute("data-open"),
215
+ }));
216
+ }
217
+
218
+ #isCollapsible() {
219
+ return this.hasAttribute("data-collapsible");
220
+ }
221
+
222
+ #isMultiple() {
223
+ return this.hasAttribute("data-multiple");
224
+ }
225
+
226
+ #getNextFocusableIndex(itemStates) {
227
+ for (const openIndex of this.#openIndexes) {
228
+ if (!itemStates[openIndex]?.disabled) {
229
+ return openIndex;
230
+ }
231
+ }
232
+
233
+ return findFirstEnabledAccordionIndex(itemStates);
234
+ }
235
+
236
+ #sync({ resetOpen = false } = {}) {
237
+ this.#triggers = collectOwnedElements(this, this, TRIGGER_SELECTOR);
238
+ this.#panels = collectOwnedElements(this, this, PANEL_SELECTOR);
239
+
240
+ const itemStates = this.#getItemStates();
241
+
242
+ if (resetOpen) {
243
+ this.#openIndexes = new Set(
244
+ getInitialOpenAccordionIndexes(itemStates, {
245
+ multiple: this.#isMultiple(),
246
+ collapsible: this.#isCollapsible(),
247
+ }),
248
+ );
249
+ } else {
250
+ const nextOpenIndexes = Array.from(this.#openIndexes).filter(
251
+ (index) => index >= 0 && index < itemStates.length && !itemStates[index]?.disabled,
252
+ );
253
+
254
+ if (!this.#isMultiple() && nextOpenIndexes.length > 1) {
255
+ nextOpenIndexes.splice(1);
256
+ }
257
+
258
+ if (
259
+ !this.#isMultiple()
260
+ && nextOpenIndexes.length === 0
261
+ && !this.#isCollapsible()
262
+ ) {
263
+ const fallbackIndex = findFirstEnabledAccordionIndex(itemStates);
264
+
265
+ if (fallbackIndex !== -1) {
266
+ nextOpenIndexes.push(fallbackIndex);
267
+ }
268
+ }
269
+
270
+ this.#openIndexes = new Set(nextOpenIndexes);
271
+ }
272
+
273
+ if (resetOpen || itemStates[this.#focusIndex]?.disabled || this.#focusIndex >= itemStates.length) {
274
+ this.#focusIndex = this.#getNextFocusableIndex(itemStates);
275
+ }
276
+
277
+ this.#applyState();
278
+ }
279
+
280
+ #applyState({ focus = false } = {}) {
281
+ const pairCount = Math.min(this.#triggers.length, this.#panels.length);
282
+ const baseId = this.id || this.#instanceId;
283
+
284
+ for (let index = 0; index < this.#triggers.length; index += 1) {
285
+ const trigger = this.#triggers[index];
286
+ const panel = index < pairCount ? this.#panels[index] : null;
287
+ const disabled = index >= pairCount || isAccordionItemDisabled(trigger);
288
+ const open = !disabled && this.#openIndexes.has(index);
289
+ const focusable = !disabled && index === this.#focusIndex;
290
+
291
+ if (!trigger.id) {
292
+ trigger.id = `${baseId}-trigger-${index + 1}`;
293
+ }
294
+
295
+ if (trigger instanceof HTMLButtonElementBase && !trigger.hasAttribute("type")) {
296
+ trigger.type = "button";
297
+ }
298
+
299
+ trigger.setAttribute("aria-expanded", String(open));
300
+ trigger.tabIndex = focusable ? 0 : -1;
301
+ trigger.toggleAttribute("data-open", open);
302
+
303
+ if (panel) {
304
+ if (!panel.id) {
305
+ panel.id = `${baseId}-panel-${index + 1}`;
306
+ }
307
+
308
+ trigger.setAttribute("aria-controls", panel.id);
309
+ } else {
310
+ trigger.removeAttribute("aria-controls");
311
+ }
312
+ }
313
+
314
+ for (let index = 0; index < this.#panels.length; index += 1) {
315
+ const panel = this.#panels[index];
316
+ const trigger = this.#triggers[index];
317
+ const open = index < pairCount
318
+ && !isAccordionItemDisabled(trigger)
319
+ && this.#openIndexes.has(index);
320
+
321
+ if (!panel.id) {
322
+ panel.id = `${baseId}-panel-${index + 1}`;
323
+ }
324
+
325
+ panel.setAttribute("role", "region");
326
+
327
+ if (trigger?.id) {
328
+ panel.setAttribute("aria-labelledby", trigger.id);
329
+ } else {
330
+ panel.removeAttribute("aria-labelledby");
331
+ }
332
+
333
+ panel.hidden = !open;
334
+ panel.toggleAttribute("data-open", open);
335
+ }
336
+
337
+ if (focus && this.#focusIndex !== -1) {
338
+ this.#triggers[this.#focusIndex]?.focus();
339
+ }
340
+ }
341
+
342
+ #toggleIndex(index, { focus = false } = {}) {
343
+ const itemStates = this.#getItemStates();
344
+
345
+ if (index < 0 || index >= itemStates.length || itemStates[index]?.disabled) {
346
+ return;
347
+ }
348
+
349
+ const nextOpenIndexes = new Set(this.#openIndexes);
350
+ const isOpen = nextOpenIndexes.has(index);
351
+
352
+ if (this.#isMultiple()) {
353
+ if (isOpen) {
354
+ nextOpenIndexes.delete(index);
355
+ } else {
356
+ nextOpenIndexes.add(index);
357
+ }
358
+ } else if (isOpen) {
359
+ if (!this.#isCollapsible()) {
360
+ this.#focusIndex = index;
361
+ this.#applyState({ focus });
362
+ return;
363
+ }
364
+
365
+ nextOpenIndexes.clear();
366
+ } else {
367
+ nextOpenIndexes.clear();
368
+ nextOpenIndexes.add(index);
369
+ }
370
+
371
+ this.#openIndexes = nextOpenIndexes;
372
+ this.#focusIndex = index;
373
+ this.#applyState({ focus });
374
+ }
375
+ }
376
+
377
+ export function defineAccordion(registry = globalThis.customElements) {
378
+ if (!registry?.get || !registry?.define) {
379
+ return AccordionElement;
380
+ }
381
+
382
+ if (!registry.get(ACCORDION_TAG_NAME)) {
383
+ registry.define(ACCORDION_TAG_NAME, AccordionElement);
384
+ }
385
+
386
+ return AccordionElement;
387
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { defineAccordion } from "./index.js";
2
+
3
+ defineAccordion();
@@ -0,0 +1,36 @@
1
+ export const DIALOG_TAG_NAME: "basic-dialog";
2
+
3
+ /**
4
+ * Normalizes the root `data-backdrop-close` attribute into a boolean flag.
5
+ */
6
+ export function normalizeDialogBackdropClose(
7
+ value?: string | null,
8
+ ): boolean;
9
+
10
+ /**
11
+ * Normalizes unsupported or empty labels back to the default `"Dialog"`.
12
+ */
13
+ export function normalizeDialogLabel(
14
+ value?: string | null,
15
+ ): string;
16
+
17
+ /**
18
+ * Custom element that upgrades native `<dialog>` markup into a modal dialog
19
+ * flow with open and close triggers.
20
+ *
21
+ * Attributes:
22
+ * - `data-label`: fallback accessible name when the dialog has no title
23
+ * - `data-backdrop-close`: allows clicks on the dialog backdrop to close it
24
+ */
25
+ export class DialogElement extends HTMLElement {
26
+ static observedAttributes: string[];
27
+ showModal(opener?: HTMLElement | null): boolean;
28
+ close(returnValue?: string): boolean;
29
+ }
30
+
31
+ /**
32
+ * Registers the `basic-dialog` custom element if it is not already defined.
33
+ */
34
+ export function defineDialog(
35
+ registry?: CustomElementRegistry,
36
+ ): typeof DialogElement;