@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,460 @@
1
+ const ElementBase = globalThis.Element ?? class {};
2
+ const HTMLElementBase = globalThis.HTMLElement ?? class {};
3
+ const HTMLButtonElementBase = globalThis.HTMLButtonElement ?? class {};
4
+
5
+ export const POPOVER_TAG_NAME = "basic-popover";
6
+
7
+ const DEFAULT_LABEL = "Popover";
8
+ const PANEL_SELECTOR = "[data-popover-panel]";
9
+ const TITLE_SELECTOR = "[data-popover-title]";
10
+ const OPEN_SELECTOR = "[data-popover-open]";
11
+ const CLOSE_SELECTOR = "[data-popover-close]";
12
+ const MANAGED_ANCHORED_ATTRIBUTE = "data-basic-popover-anchored";
13
+ const MANAGED_POPOVER_ATTRIBUTE = "data-basic-popover-managed-popover";
14
+ const MANAGED_LABEL_ATTRIBUTE = "data-basic-popover-managed-label";
15
+ const MANAGED_LABELLEDBY_ATTRIBUTE = "data-basic-popover-managed-labelledby";
16
+ const MANAGED_ROLE_ATTRIBUTE = "data-basic-popover-managed-role";
17
+ const MANAGED_MODAL_ATTRIBUTE = "data-basic-popover-managed-modal";
18
+ const MANAGED_POSITION_AREA_VARIABLE = "--basic-popover-position-area";
19
+ const MANAGED_POSITION_TRY_VARIABLE = "--basic-popover-position-try-fallbacks";
20
+ const POPOVER_STYLE_ID = "basic-popover-anchor-styles";
21
+
22
+ let nextPopoverInstanceId = 1;
23
+
24
+ function collectOwnedElements(root, scope, selector) {
25
+ return Array.from(scope.querySelectorAll(selector)).filter(
26
+ (element) => element instanceof HTMLElementBase && element.closest(POPOVER_TAG_NAME) === root,
27
+ );
28
+ }
29
+
30
+ function ensurePopoverAnchorStyles(documentRef) {
31
+ if (!documentRef?.head || documentRef.getElementById(POPOVER_STYLE_ID)) {
32
+ return;
33
+ }
34
+
35
+ const style = documentRef.createElement("style");
36
+ style.id = POPOVER_STYLE_ID;
37
+ style.textContent = `${POPOVER_TAG_NAME} [data-popover-panel][${MANAGED_ANCHORED_ATTRIBUTE}] { inset: auto; margin: 0; position-area: var(${MANAGED_POSITION_AREA_VARIABLE}, bottom); position-try-fallbacks: var(${MANAGED_POSITION_TRY_VARIABLE}, flip-block, flip-inline, flip-block flip-inline); position-try: var(${MANAGED_POSITION_TRY_VARIABLE}, flip-block, flip-inline, flip-block flip-inline); }`;
38
+ documentRef.head.append(style);
39
+ }
40
+
41
+ export function normalizePopoverLabel(value) {
42
+ return value?.trim() || DEFAULT_LABEL;
43
+ }
44
+
45
+ export function normalizePopoverAnchorTrigger(value) {
46
+ if (value == null) {
47
+ return false;
48
+ }
49
+
50
+ const normalized = value.trim().toLowerCase();
51
+ return normalized === "" || normalized === "true";
52
+ }
53
+
54
+ export function normalizePopoverPositionArea(value) {
55
+ return value?.trim() || "bottom";
56
+ }
57
+
58
+ export function getDefaultPopoverPositionTryFallbacks(positionArea) {
59
+ const normalizedArea = normalizePopoverPositionArea(positionArea).toLowerCase();
60
+ const blockDirectionalTokens = [
61
+ "top",
62
+ "bottom",
63
+ "block-start",
64
+ "block-end",
65
+ "y-start",
66
+ "y-end",
67
+ "span-top",
68
+ "span-bottom",
69
+ "span-block-start",
70
+ "span-block-end",
71
+ "span-y-start",
72
+ "span-y-end",
73
+ ];
74
+ const inlineDirectionalTokens = [
75
+ "left",
76
+ "right",
77
+ "inline-start",
78
+ "inline-end",
79
+ "x-start",
80
+ "x-end",
81
+ "span-left",
82
+ "span-right",
83
+ "span-inline-start",
84
+ "span-inline-end",
85
+ "span-x-start",
86
+ "span-x-end",
87
+ ];
88
+ const primaryFlip = blockDirectionalTokens.some((token) => normalizedArea.includes(token))
89
+ ? "flip-block"
90
+ : inlineDirectionalTokens.some((token) => normalizedArea.includes(token))
91
+ ? "flip-inline"
92
+ : "flip-block";
93
+ const secondaryFlip = primaryFlip === "flip-block" ? "flip-inline" : "flip-block";
94
+
95
+ return `${primaryFlip}, ${secondaryFlip}, ${primaryFlip} ${secondaryFlip}`;
96
+ }
97
+
98
+ export function normalizePopoverPositionTryFallbacks(value, positionArea) {
99
+ return value?.trim() || getDefaultPopoverPositionTryFallbacks(positionArea);
100
+ }
101
+
102
+ export function isPopoverOpen(panel) {
103
+ if (!(panel instanceof HTMLElementBase)) {
104
+ return false;
105
+ }
106
+
107
+ try {
108
+ return panel.matches(":popover-open");
109
+ } catch {
110
+ return panel.hasAttribute("data-open");
111
+ }
112
+ }
113
+
114
+ export class PopoverElement extends HTMLElementBase {
115
+ static observedAttributes = [
116
+ "data-anchor-trigger",
117
+ "data-label",
118
+ "data-position-area",
119
+ "data-position-try-fallbacks",
120
+ ];
121
+
122
+ #instanceId = `${POPOVER_TAG_NAME}-${nextPopoverInstanceId++}`;
123
+ #panel = null;
124
+ #panelWithEvents = null;
125
+ #title = null;
126
+ #openButtons = [];
127
+ #closeButtons = [];
128
+ #restoreFocusTo = null;
129
+ #eventsBound = false;
130
+
131
+ connectedCallback() {
132
+ if (!this.#eventsBound) {
133
+ this.addEventListener("click", this.#handleClick);
134
+ this.addEventListener("keydown", this.#handleKeyDown);
135
+ this.ownerDocument?.addEventListener("click", this.#handleDocumentClick);
136
+ this.#eventsBound = true;
137
+ }
138
+
139
+ ensurePopoverAnchorStyles(this.ownerDocument);
140
+ this.#sync();
141
+ }
142
+
143
+ disconnectedCallback() {
144
+ if (this.#eventsBound) {
145
+ this.removeEventListener("click", this.#handleClick);
146
+ this.removeEventListener("keydown", this.#handleKeyDown);
147
+ this.ownerDocument?.removeEventListener("click", this.#handleDocumentClick);
148
+ this.#eventsBound = false;
149
+ }
150
+
151
+ this.#syncPanelEvents(null);
152
+ }
153
+
154
+ attributeChangedCallback() {
155
+ this.#sync();
156
+ }
157
+
158
+ show(opener = null) {
159
+ this.#sync();
160
+
161
+ if (
162
+ !(this.#panel instanceof HTMLElementBase)
163
+ || typeof this.#panel.showPopover !== "function"
164
+ ) {
165
+ return false;
166
+ }
167
+
168
+ if (isPopoverOpen(this.#panel)) {
169
+ this.#applyState();
170
+ return true;
171
+ }
172
+
173
+ const fallbackOpener = opener instanceof HTMLElementBase
174
+ ? opener
175
+ : this.ownerDocument?.activeElement instanceof HTMLElementBase
176
+ ? this.ownerDocument.activeElement
177
+ : null;
178
+
179
+ this.#restoreFocusTo = fallbackOpener;
180
+
181
+ if (this.#shouldAnchorTrigger() && fallbackOpener instanceof HTMLElementBase) {
182
+ try {
183
+ this.#panel.showPopover({ source: fallbackOpener });
184
+ } catch {
185
+ this.#panel.showPopover();
186
+ }
187
+ } else {
188
+ this.#panel.showPopover();
189
+ }
190
+
191
+ this.#applyState();
192
+ return true;
193
+ }
194
+
195
+ hide() {
196
+ if (
197
+ !(this.#panel instanceof HTMLElementBase)
198
+ || typeof this.#panel.hidePopover !== "function"
199
+ || !isPopoverOpen(this.#panel)
200
+ ) {
201
+ return false;
202
+ }
203
+
204
+ this.#panel.hidePopover();
205
+ return true;
206
+ }
207
+
208
+ toggle(opener = null) {
209
+ if (isPopoverOpen(this.#panel)) {
210
+ return this.hide();
211
+ }
212
+
213
+ return this.show(opener);
214
+ }
215
+
216
+ #handleClick = (event) => {
217
+ if (!(event.target instanceof ElementBase)) {
218
+ return;
219
+ }
220
+
221
+ const openButton = event.target.closest(OPEN_SELECTOR);
222
+
223
+ if (
224
+ openButton instanceof HTMLElementBase
225
+ && openButton.closest(POPOVER_TAG_NAME) === this
226
+ ) {
227
+ event.preventDefault();
228
+ this.toggle(openButton);
229
+ return;
230
+ }
231
+
232
+ const closeButton = event.target.closest(CLOSE_SELECTOR);
233
+
234
+ if (
235
+ closeButton instanceof HTMLElementBase
236
+ && closeButton.closest(POPOVER_TAG_NAME) === this
237
+ ) {
238
+ event.preventDefault();
239
+ this.hide();
240
+ }
241
+ };
242
+
243
+ #shouldAnchorTrigger() {
244
+ return normalizePopoverAnchorTrigger(this.getAttribute("data-anchor-trigger"));
245
+ }
246
+
247
+ #getPositionArea() {
248
+ return normalizePopoverPositionArea(this.getAttribute("data-position-area"));
249
+ }
250
+
251
+ #getPositionTryFallbacks() {
252
+ return normalizePopoverPositionTryFallbacks(
253
+ this.getAttribute("data-position-try-fallbacks"),
254
+ this.#getPositionArea(),
255
+ );
256
+ }
257
+
258
+ #handleKeyDown = (event) => {
259
+ if (
260
+ event.key !== "Escape"
261
+ || !isPopoverOpen(this.#panel)
262
+ || !(event.target instanceof ElementBase)
263
+ || !this.contains(event.target)
264
+ ) {
265
+ return;
266
+ }
267
+
268
+ event.preventDefault();
269
+ this.hide();
270
+ };
271
+
272
+ #handleDocumentClick = (event) => {
273
+ if (
274
+ !isPopoverOpen(this.#panel)
275
+ || !(event.target instanceof ElementBase)
276
+ || this.contains(event.target)
277
+ ) {
278
+ return;
279
+ }
280
+
281
+ this.hide();
282
+ };
283
+
284
+ #handleToggle = () => {
285
+ this.#applyState();
286
+
287
+ if (isPopoverOpen(this.#panel)) {
288
+ return;
289
+ }
290
+
291
+ const activeElement = this.ownerDocument?.activeElement;
292
+ const shouldRestoreFocus = !(
293
+ activeElement instanceof HTMLElementBase
294
+ && activeElement !== this.ownerDocument?.body
295
+ && !this.contains(activeElement)
296
+ );
297
+
298
+ if (
299
+ shouldRestoreFocus
300
+ && this.#restoreFocusTo instanceof HTMLElementBase
301
+ && this.#restoreFocusTo.isConnected
302
+ ) {
303
+ this.#restoreFocusTo.focus();
304
+ }
305
+
306
+ this.#restoreFocusTo = null;
307
+ };
308
+
309
+ #sync() {
310
+ const nextPanel = collectOwnedElements(this, this, PANEL_SELECTOR)[0] ?? null;
311
+ const nextTitle = collectOwnedElements(this, this, TITLE_SELECTOR)[0] ?? null;
312
+
313
+ this.#syncPanelEvents(nextPanel instanceof HTMLElementBase ? nextPanel : null);
314
+ this.#panel = nextPanel instanceof HTMLElementBase ? nextPanel : null;
315
+ this.#title = nextTitle instanceof HTMLElementBase ? nextTitle : null;
316
+ this.#openButtons = collectOwnedElements(this, this, OPEN_SELECTOR);
317
+ this.#closeButtons = collectOwnedElements(this, this, CLOSE_SELECTOR);
318
+ this.#applyState();
319
+ }
320
+
321
+ #syncPanelEvents(nextPanel) {
322
+ if (this.#panelWithEvents === nextPanel) {
323
+ return;
324
+ }
325
+
326
+ if (this.#panelWithEvents instanceof HTMLElementBase) {
327
+ this.#panelWithEvents.removeEventListener("toggle", this.#handleToggle);
328
+ }
329
+
330
+ if (nextPanel instanceof HTMLElementBase) {
331
+ nextPanel.addEventListener("toggle", this.#handleToggle);
332
+ }
333
+
334
+ this.#panelWithEvents = nextPanel;
335
+ }
336
+
337
+ #applyState() {
338
+ for (const button of [...this.#openButtons, ...this.#closeButtons]) {
339
+ if (button instanceof HTMLButtonElementBase && !button.hasAttribute("type")) {
340
+ button.type = "button";
341
+ }
342
+ }
343
+
344
+ if (!(this.#panel instanceof HTMLElementBase)) {
345
+ this.toggleAttribute("data-open", false);
346
+ return;
347
+ }
348
+
349
+ const baseId = this.id || this.#instanceId;
350
+ const open = isPopoverOpen(this.#panel);
351
+
352
+ if (!this.#panel.id) {
353
+ this.#panel.id = `${baseId}-panel`;
354
+ }
355
+
356
+ if (this.#title instanceof HTMLElementBase && !this.#title.id) {
357
+ this.#title.id = `${baseId}-title`;
358
+ }
359
+
360
+ if (
361
+ !this.#panel.hasAttribute("popover")
362
+ || this.#panel.hasAttribute(MANAGED_POPOVER_ATTRIBUTE)
363
+ ) {
364
+ this.#panel.setAttribute("popover", "auto");
365
+ this.#panel.setAttribute(MANAGED_POPOVER_ATTRIBUTE, "");
366
+ }
367
+
368
+ if (!this.#panel.hasAttribute("role") || this.#panel.hasAttribute(MANAGED_ROLE_ATTRIBUTE)) {
369
+ this.#panel.setAttribute("role", "dialog");
370
+ this.#panel.setAttribute(MANAGED_ROLE_ATTRIBUTE, "");
371
+ }
372
+
373
+ if (
374
+ !this.#panel.hasAttribute("aria-modal")
375
+ || this.#panel.hasAttribute(MANAGED_MODAL_ATTRIBUTE)
376
+ ) {
377
+ this.#panel.setAttribute("aria-modal", "false");
378
+ this.#panel.setAttribute(MANAGED_MODAL_ATTRIBUTE, "");
379
+ }
380
+
381
+ this.#syncAccessibleLabel();
382
+
383
+ if (this.#shouldAnchorTrigger()) {
384
+ ensurePopoverAnchorStyles(this.ownerDocument);
385
+ this.#panel.setAttribute(MANAGED_ANCHORED_ATTRIBUTE, "");
386
+ this.#panel.style.setProperty(
387
+ MANAGED_POSITION_AREA_VARIABLE,
388
+ this.#getPositionArea(),
389
+ );
390
+ this.#panel.style.setProperty(
391
+ MANAGED_POSITION_TRY_VARIABLE,
392
+ this.#getPositionTryFallbacks(),
393
+ );
394
+ } else {
395
+ this.#panel.removeAttribute(MANAGED_ANCHORED_ATTRIBUTE);
396
+ this.#panel.style.removeProperty(MANAGED_POSITION_AREA_VARIABLE);
397
+ this.#panel.style.removeProperty(MANAGED_POSITION_TRY_VARIABLE);
398
+ }
399
+
400
+ for (const button of this.#openButtons) {
401
+ button.setAttribute("aria-haspopup", "dialog");
402
+ button.setAttribute("aria-controls", this.#panel.id);
403
+ button.setAttribute("aria-expanded", String(open));
404
+ }
405
+
406
+ this.#panel.toggleAttribute("data-open", open);
407
+ this.toggleAttribute("data-open", open);
408
+ }
409
+
410
+ #syncAccessibleLabel() {
411
+ if (!(this.#panel instanceof HTMLElementBase)) {
412
+ return;
413
+ }
414
+
415
+ if (this.#title?.id) {
416
+ if (this.#panel.hasAttribute(MANAGED_LABEL_ATTRIBUTE)) {
417
+ this.#panel.removeAttribute("aria-label");
418
+ this.#panel.removeAttribute(MANAGED_LABEL_ATTRIBUTE);
419
+ }
420
+
421
+ if (
422
+ !this.#panel.hasAttribute("aria-labelledby")
423
+ || this.#panel.hasAttribute(MANAGED_LABELLEDBY_ATTRIBUTE)
424
+ ) {
425
+ this.#panel.setAttribute("aria-labelledby", this.#title.id);
426
+ this.#panel.setAttribute(MANAGED_LABELLEDBY_ATTRIBUTE, "");
427
+ }
428
+
429
+ return;
430
+ }
431
+
432
+ if (this.#panel.hasAttribute(MANAGED_LABELLEDBY_ATTRIBUTE)) {
433
+ this.#panel.removeAttribute("aria-labelledby");
434
+ this.#panel.removeAttribute(MANAGED_LABELLEDBY_ATTRIBUTE);
435
+ }
436
+
437
+ if (
438
+ !this.#panel.hasAttribute("aria-label")
439
+ || this.#panel.hasAttribute(MANAGED_LABEL_ATTRIBUTE)
440
+ ) {
441
+ this.#panel.setAttribute(
442
+ "aria-label",
443
+ normalizePopoverLabel(this.getAttribute("data-label")),
444
+ );
445
+ this.#panel.setAttribute(MANAGED_LABEL_ATTRIBUTE, "");
446
+ }
447
+ }
448
+ }
449
+
450
+ export function definePopover(registry = globalThis.customElements) {
451
+ if (!registry?.get || !registry?.define) {
452
+ return PopoverElement;
453
+ }
454
+
455
+ if (!registry.get(POPOVER_TAG_NAME)) {
456
+ registry.define(POPOVER_TAG_NAME, PopoverElement);
457
+ }
458
+
459
+ return PopoverElement;
460
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { definePopover } from "./index.js";
2
+
3
+ definePopover();
@@ -0,0 +1,69 @@
1
+ import type { TableElement } from "../basic-table";
2
+
3
+ export const SUMMARY_TABLE_TAG_NAME: "basic-summary-table";
4
+
5
+ /**
6
+ * Normalizes configured summary columns from a comma-separated one-based list.
7
+ */
8
+ export function normalizeSummaryColumns(
9
+ value?: string | null,
10
+ ): number[];
11
+
12
+ /**
13
+ * Normalizes the generated footer row label back to `"Totalt"`.
14
+ */
15
+ export function normalizeSummaryTotalLabel(
16
+ value?: string | null,
17
+ ): string;
18
+
19
+ /**
20
+ * Normalizes the optional `Intl.NumberFormat` locale.
21
+ */
22
+ export function normalizeSummaryLocale(
23
+ value?: string | null,
24
+ ): string | undefined;
25
+
26
+ /**
27
+ * Parses common formatted number strings such as `1,200.50` or `1 200,50`.
28
+ */
29
+ export function parseSummaryNumber(
30
+ value?: string | number | null,
31
+ ): number | null;
32
+
33
+ /**
34
+ * Formats a summary value for footer display.
35
+ */
36
+ export function formatSummaryNumber(
37
+ value: number,
38
+ options?: {
39
+ locale?: string;
40
+ fractionDigits?: number;
41
+ },
42
+ ): string;
43
+
44
+ /**
45
+ * Custom element that upgrades a regular summary table with generated footer
46
+ * totals.
47
+ *
48
+ * Attributes:
49
+ * - `data-caption`: optional generated `<caption>` text when the table has none
50
+ * - `data-description`: optional generated description wired through `aria-describedby`
51
+ * - `data-label`: fallback accessible name when the table has neither a caption
52
+ * nor its own label
53
+ * - `data-row-headers`: enables generated row headers in tbody rows
54
+ * - `data-row-header-column`: one-based body column used for row headers
55
+ * - `data-summary-columns`: optional comma-separated one-based columns to total
56
+ * - `data-total-label`: label text used for the generated footer row
57
+ * - `data-locale`: optional locale used to format footer totals
58
+ */
59
+ export class SummaryTableElement extends TableElement {
60
+ static observedAttributes: string[];
61
+ refresh(): void;
62
+ }
63
+
64
+ /**
65
+ * Registers the `basic-summary-table` custom element if it is not already defined.
66
+ */
67
+ export function defineSummaryTable(
68
+ registry?: CustomElementRegistry,
69
+ ): typeof SummaryTableElement;