@lmfaole/basics 0.3.0 → 0.4.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.
Files changed (31) hide show
  1. package/README.md +182 -33
  2. package/basic-styling/components/basic-accordion.css +65 -0
  3. package/basic-styling/components/basic-alert.css +27 -0
  4. package/basic-styling/components/basic-dialog.css +41 -0
  5. package/basic-styling/components/basic-popover.css +54 -0
  6. package/basic-styling/components/basic-summary-table.css +76 -0
  7. package/basic-styling/components/basic-table.css +48 -0
  8. package/basic-styling/components/basic-tabs.css +45 -0
  9. package/basic-styling/components/basic-toast.css +102 -0
  10. package/basic-styling/components/basic-toc.css +30 -0
  11. package/basic-styling/components.css +9 -0
  12. package/basic-styling/global.css +61 -0
  13. package/basic-styling/index.css +2 -0
  14. package/basic-styling/tokens/base.css +19 -0
  15. package/basic-styling/tokens/palette.css +117 -0
  16. package/basic-styling/tokens/palette.tokens.json +1019 -0
  17. package/components/basic-accordion/index.d.ts +5 -5
  18. package/components/basic-accordion/index.js +169 -165
  19. package/components/basic-alert/index.d.ts +53 -0
  20. package/components/basic-alert/index.js +189 -0
  21. package/components/basic-alert/register.d.ts +1 -0
  22. package/components/basic-alert/register.js +3 -0
  23. package/components/basic-summary-table/index.js +188 -42
  24. package/components/basic-table/index.js +203 -145
  25. package/components/basic-toast/index.d.ts +65 -0
  26. package/components/basic-toast/index.js +429 -0
  27. package/components/basic-toast/register.d.ts +1 -0
  28. package/components/basic-toast/register.js +3 -0
  29. package/index.d.ts +2 -0
  30. package/index.js +2 -0
  31. package/package.json +22 -57
@@ -21,7 +21,7 @@ export function getInitialOpenAccordionIndexes(
21
21
  ): number[];
22
22
 
23
23
  /**
24
- * Returns the next enabled accordion trigger index, wrapping around the list
24
+ * Returns the next enabled accordion item index, wrapping around the list
25
25
  * when needed.
26
26
  */
27
27
  export function findNextEnabledAccordionIndex(
@@ -31,12 +31,12 @@ export function findNextEnabledAccordionIndex(
31
31
  ): number;
32
32
 
33
33
  /**
34
- * Custom element that upgrades existing trigger-and-panel markup into an
35
- * accessible accordion interface.
34
+ * Custom element that coordinates direct child `details` items into an
35
+ * accordion interface.
36
36
  *
37
37
  * Attributes:
38
- * - `data-multiple`: allows multiple panels to stay open
39
- * - `data-collapsible`: allows the last open panel in single mode to close
38
+ * - `data-multiple`: allows multiple items to stay open
39
+ * - `data-collapsible`: allows the last open item in single mode to close
40
40
  */
41
41
  export class AccordionElement extends HTMLElement {
42
42
  static observedAttributes: string[];
@@ -1,22 +1,49 @@
1
1
  const ElementBase = globalThis.Element ?? class {};
2
2
  const HTMLElementBase = globalThis.HTMLElement ?? class {};
3
- const HTMLButtonElementBase = globalThis.HTMLButtonElement ?? class {};
3
+ const HTMLDetailsElementBase = globalThis.HTMLDetailsElement ?? class {};
4
4
 
5
5
  export const ACCORDION_TAG_NAME = "basic-accordion";
6
6
 
7
- const TRIGGER_SELECTOR = "[data-accordion-trigger]";
8
- const PANEL_SELECTOR = "[data-accordion-panel]";
7
+ const SUMMARY_TAG_NAME = "SUMMARY";
9
8
 
10
- let nextAccordionInstanceId = 1;
9
+ function findDirectAccordionSummary(details) {
10
+ return Array.from(details.children).find(
11
+ (child) => child instanceof HTMLElementBase && child.tagName === SUMMARY_TAG_NAME,
12
+ ) ?? null;
13
+ }
14
+
15
+ function collectOwnedAccordionItems(root) {
16
+ return Array.from(root.children).flatMap((child) => {
17
+ if (!(child instanceof HTMLDetailsElementBase)) {
18
+ return [];
19
+ }
20
+
21
+ const summary = findDirectAccordionSummary(child);
22
+
23
+ if (!(summary instanceof HTMLElementBase)) {
24
+ return [];
25
+ }
11
26
 
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
- );
27
+ return [{ details: child, summary }];
28
+ });
16
29
  }
17
30
 
18
- function isAccordionItemDisabled(trigger) {
19
- return trigger.hasAttribute("disabled") || trigger.getAttribute("aria-disabled") === "true";
31
+ function isAccordionItemDisabled(details) {
32
+ return details.hasAttribute("data-disabled")
33
+ || details.hasAttribute("disabled")
34
+ || details.getAttribute("aria-disabled") === "true";
35
+ }
36
+
37
+ function getOpenAccordionIndexes(itemStates) {
38
+ const openIndexes = [];
39
+
40
+ for (let index = 0; index < itemStates.length; index += 1) {
41
+ if (itemStates[index]?.open && !itemStates[index]?.disabled) {
42
+ openIndexes.push(index);
43
+ }
44
+ }
45
+
46
+ return openIndexes;
20
47
  }
21
48
 
22
49
  function findFirstEnabledAccordionIndex(itemStates) {
@@ -97,12 +124,9 @@ export function findNextEnabledAccordionIndex(itemStates, startIndex, direction)
97
124
  export class AccordionElement extends HTMLElementBase {
98
125
  static observedAttributes = ["data-collapsible", "data-multiple"];
99
126
 
100
- #instanceId = `${ACCORDION_TAG_NAME}-${nextAccordionInstanceId++}`;
101
- #triggers = [];
102
- #panels = [];
103
- #openIndexes = new Set();
104
- #focusIndex = -1;
127
+ #items = [];
105
128
  #eventsBound = false;
129
+ #isSyncingState = false;
106
130
 
107
131
  connectedCallback() {
108
132
  if (!this.#eventsBound) {
@@ -115,13 +139,17 @@ export class AccordionElement extends HTMLElementBase {
115
139
  }
116
140
 
117
141
  disconnectedCallback() {
118
- if (!this.#eventsBound) {
119
- return;
142
+ if (this.#eventsBound) {
143
+ this.removeEventListener("click", this.#handleClick);
144
+ this.removeEventListener("keydown", this.#handleKeyDown);
145
+ this.#eventsBound = false;
120
146
  }
121
147
 
122
- this.removeEventListener("click", this.#handleClick);
123
- this.removeEventListener("keydown", this.#handleKeyDown);
124
- this.#eventsBound = false;
148
+ for (const { details } of this.#items) {
149
+ details.removeEventListener("toggle", this.#handleToggle);
150
+ }
151
+
152
+ this.#items = [];
125
153
  }
126
154
 
127
155
  attributeChangedCallback() {
@@ -133,22 +161,28 @@ export class AccordionElement extends HTMLElementBase {
133
161
  return;
134
162
  }
135
163
 
136
- const trigger = event.target.closest(TRIGGER_SELECTOR);
164
+ const summary = event.target.closest("summary");
165
+ const summaryIndex = this.#findSummaryIndex(summary);
137
166
 
138
- if (
139
- !(trigger instanceof HTMLElementBase)
140
- || trigger.closest(ACCORDION_TAG_NAME) !== this
141
- ) {
167
+ if (summaryIndex === -1) {
142
168
  return;
143
169
  }
144
170
 
145
- const triggerIndex = this.#triggers.indexOf(trigger);
171
+ const itemStates = this.#getItemStates();
146
172
 
147
- if (triggerIndex === -1) {
173
+ if (itemStates[summaryIndex]?.disabled) {
174
+ event.preventDefault();
148
175
  return;
149
176
  }
150
177
 
151
- this.#toggleIndex(triggerIndex, { focus: true });
178
+ if (
179
+ !this.#isMultiple()
180
+ && !this.#isCollapsible()
181
+ && itemStates[summaryIndex]?.open
182
+ && getOpenAccordionIndexes(itemStates).length === 1
183
+ ) {
184
+ event.preventDefault();
185
+ }
152
186
  };
153
187
 
154
188
  #handleKeyDown = (event) => {
@@ -156,17 +190,9 @@ export class AccordionElement extends HTMLElementBase {
156
190
  return;
157
191
  }
158
192
 
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
-
193
+ const currentSummary = event.target.closest("summary");
194
+ const currentIndex = this.#findSummaryIndex(currentSummary);
168
195
  const itemStates = this.#getItemStates();
169
- const currentIndex = this.#triggers.indexOf(currentTrigger);
170
196
  let nextIndex = -1;
171
197
 
172
198
  if (currentIndex === -1 || currentIndex >= itemStates.length) {
@@ -189,7 +215,21 @@ export class AccordionElement extends HTMLElementBase {
189
215
  case " ":
190
216
  case "Enter":
191
217
  event.preventDefault();
192
- this.#toggleIndex(currentIndex, { focus: true });
218
+
219
+ if (itemStates[currentIndex]?.disabled) {
220
+ return;
221
+ }
222
+
223
+ if (
224
+ !this.#isMultiple()
225
+ && !this.#isCollapsible()
226
+ && itemStates[currentIndex]?.open
227
+ && getOpenAccordionIndexes(itemStates).length === 1
228
+ ) {
229
+ return;
230
+ }
231
+
232
+ this.#items[currentIndex].details.open = !this.#items[currentIndex].details.open;
193
233
  return;
194
234
  default:
195
235
  return;
@@ -200,19 +240,30 @@ export class AccordionElement extends HTMLElementBase {
200
240
  }
201
241
 
202
242
  event.preventDefault();
203
- this.#focusIndex = nextIndex;
204
- this.#applyState({ focus: true });
243
+ this.#items[nextIndex]?.summary.focus();
205
244
  };
206
245
 
207
- #getItemStates() {
208
- const pairCount = Math.min(this.#triggers.length, this.#panels.length);
246
+ #handleToggle = (event) => {
247
+ if (this.#isSyncingState) {
248
+ return;
249
+ }
209
250
 
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
- }));
251
+ const details = event.currentTarget;
252
+ const preferredIndex = this.#items.findIndex((item) => item.details === details);
253
+
254
+ if (preferredIndex === -1) {
255
+ return;
256
+ }
257
+
258
+ this.#sync({ preferredIndex });
259
+ };
260
+
261
+ #findSummaryIndex(summary) {
262
+ if (!(summary instanceof HTMLElementBase)) {
263
+ return -1;
264
+ }
265
+
266
+ return this.#items.findIndex((item) => item.summary === summary);
216
267
  }
217
268
 
218
269
  #isCollapsible() {
@@ -223,154 +274,107 @@ export class AccordionElement extends HTMLElementBase {
223
274
  return this.hasAttribute("data-multiple");
224
275
  }
225
276
 
226
- #getNextFocusableIndex(itemStates) {
227
- for (const openIndex of this.#openIndexes) {
228
- if (!itemStates[openIndex]?.disabled) {
229
- return openIndex;
277
+ #getItemStates({ includeDataOpen = false } = {}) {
278
+ return this.#items.map(({ details }) => ({
279
+ disabled: isAccordionItemDisabled(details),
280
+ open: details.hasAttribute("open")
281
+ || (includeDataOpen && details.hasAttribute("data-open")),
282
+ }));
283
+ }
284
+
285
+ #sync({ resetOpen = false, preferredIndex = -1 } = {}) {
286
+ const previousDetails = this.#items.map((item) => item.details);
287
+ const nextItems = collectOwnedAccordionItems(this);
288
+ const nextDetails = nextItems.map((item) => item.details);
289
+
290
+ for (const details of previousDetails) {
291
+ if (!nextDetails.includes(details)) {
292
+ details.removeEventListener("toggle", this.#handleToggle);
230
293
  }
231
294
  }
232
295
 
233
- return findFirstEnabledAccordionIndex(itemStates);
234
- }
296
+ for (const details of nextDetails) {
297
+ if (!previousDetails.includes(details)) {
298
+ details.addEventListener("toggle", this.#handleToggle);
299
+ }
300
+ }
235
301
 
236
- #sync({ resetOpen = false } = {}) {
237
- this.#triggers = collectOwnedElements(this, this, TRIGGER_SELECTOR);
238
- this.#panels = collectOwnedElements(this, this, PANEL_SELECTOR);
302
+ this.#items = nextItems;
303
+ this.#normalizeOpenState({
304
+ itemStates: this.#getItemStates({ includeDataOpen: resetOpen }),
305
+ resetOpen,
306
+ preferredIndex,
307
+ });
308
+ this.#applyState();
309
+ }
239
310
 
240
- const itemStates = this.#getItemStates();
311
+ #normalizeOpenState({ itemStates, resetOpen = false, preferredIndex = -1 }) {
312
+ let openIndexes = [];
241
313
 
242
314
  if (resetOpen) {
243
- this.#openIndexes = new Set(
244
- getInitialOpenAccordionIndexes(itemStates, {
245
- multiple: this.#isMultiple(),
246
- collapsible: this.#isCollapsible(),
247
- }),
248
- );
315
+ openIndexes = getInitialOpenAccordionIndexes(itemStates, {
316
+ multiple: this.#isMultiple(),
317
+ collapsible: this.#isCollapsible(),
318
+ });
319
+ } else if (this.#isMultiple()) {
320
+ openIndexes = getOpenAccordionIndexes(itemStates);
249
321
  } 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
- }
322
+ openIndexes = getOpenAccordionIndexes(itemStates);
257
323
 
258
324
  if (
259
- !this.#isMultiple()
260
- && nextOpenIndexes.length === 0
261
- && !this.#isCollapsible()
325
+ preferredIndex !== -1
326
+ && openIndexes.includes(preferredIndex)
327
+ && !itemStates[preferredIndex]?.disabled
262
328
  ) {
329
+ openIndexes = [preferredIndex];
330
+ } else if (openIndexes.length > 0) {
331
+ openIndexes = [openIndexes[0]];
332
+ } else if (!this.#isCollapsible()) {
263
333
  const fallbackIndex = findFirstEnabledAccordionIndex(itemStates);
264
334
 
265
335
  if (fallbackIndex !== -1) {
266
- nextOpenIndexes.push(fallbackIndex);
336
+ openIndexes = [fallbackIndex];
267
337
  }
268
338
  }
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
339
  }
276
340
 
277
- this.#applyState();
278
- }
341
+ const openIndexSet = new Set(openIndexes);
279
342
 
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
- }
343
+ this.#isSyncingState = true;
298
344
 
299
- trigger.setAttribute("aria-expanded", String(open));
300
- trigger.tabIndex = focusable ? 0 : -1;
301
- trigger.toggleAttribute("data-open", open);
345
+ try {
346
+ for (let index = 0; index < this.#items.length; index += 1) {
347
+ const details = this.#items[index].details;
348
+ const open = !itemStates[index]?.disabled && openIndexSet.has(index);
302
349
 
303
- if (panel) {
304
- if (!panel.id) {
305
- panel.id = `${baseId}-panel-${index + 1}`;
350
+ if (details.open !== open) {
351
+ details.open = open;
306
352
  }
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
353
  }
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();
354
+ } finally {
355
+ this.#isSyncingState = false;
339
356
  }
340
357
  }
341
358
 
342
- #toggleIndex(index, { focus = false } = {}) {
343
- const itemStates = this.#getItemStates();
359
+ #applyState() {
360
+ for (const { details, summary } of this.#items) {
361
+ const disabled = isAccordionItemDisabled(details);
362
+ const open = !disabled && details.open;
344
363
 
345
- if (index < 0 || index >= itemStates.length || itemStates[index]?.disabled) {
346
- return;
347
- }
364
+ if (disabled && details.open) {
365
+ details.open = false;
366
+ }
348
367
 
349
- const nextOpenIndexes = new Set(this.#openIndexes);
350
- const isOpen = nextOpenIndexes.has(index);
368
+ details.toggleAttribute("data-open", open);
351
369
 
352
- if (this.#isMultiple()) {
353
- if (isOpen) {
354
- nextOpenIndexes.delete(index);
370
+ if (disabled) {
371
+ summary.setAttribute("aria-disabled", "true");
372
+ summary.tabIndex = -1;
355
373
  } else {
356
- nextOpenIndexes.add(index);
374
+ summary.removeAttribute("aria-disabled");
375
+ summary.tabIndex = 0;
357
376
  }
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
377
  }
370
-
371
- this.#openIndexes = nextOpenIndexes;
372
- this.#focusIndex = index;
373
- this.#applyState({ focus });
374
378
  }
375
379
  }
376
380
 
@@ -0,0 +1,53 @@
1
+ export const ALERT_TAG_NAME: "basic-alert";
2
+
3
+ /**
4
+ * Normalizes unsupported or empty labels back to the default `"Alert"`.
5
+ */
6
+ export function normalizeAlertLabel(
7
+ value?: string | null,
8
+ ): string;
9
+
10
+ /**
11
+ * Normalizes unsupported live-region values back to `"assertive"`.
12
+ */
13
+ export function normalizeAlertLive(
14
+ value?: string | null,
15
+ ): "assertive" | "polite";
16
+
17
+ /**
18
+ * Maps an alert live setting to the matching ARIA role.
19
+ */
20
+ export function getAlertRoleForLive(
21
+ value?: string | null,
22
+ ): "alert" | "status";
23
+
24
+ /**
25
+ * Normalizes the optional `data-open` attribute into a boolean flag, using the
26
+ * current `hidden` state as a fallback.
27
+ */
28
+ export function normalizeAlertOpen(
29
+ value?: string | null,
30
+ hidden?: boolean,
31
+ ): boolean;
32
+
33
+ /**
34
+ * Custom element that upgrades inline content into a dismissible live-region
35
+ * alert.
36
+ *
37
+ * Attributes:
38
+ * - `data-label`: fallback accessible name when the alert has no title
39
+ * - `data-live`: chooses between `alert` and `status` semantics
40
+ * - `data-open`: optional managed visibility flag
41
+ */
42
+ export class AlertElement extends HTMLElement {
43
+ static observedAttributes: string[];
44
+ show(): boolean;
45
+ hide(): boolean;
46
+ }
47
+
48
+ /**
49
+ * Registers the `basic-alert` custom element if it is not already defined.
50
+ */
51
+ export function defineAlert(
52
+ registry?: CustomElementRegistry,
53
+ ): typeof AlertElement;