@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,390 @@
1
+ import {
2
+ TableElement,
3
+ normalizeTableCellSpan,
4
+ normalizeTableRowHeaderColumn,
5
+ } from "../basic-table/index.js";
6
+
7
+ const HTMLTableElementBase = globalThis.HTMLTableElement ?? class {};
8
+
9
+ export const SUMMARY_TABLE_TAG_NAME = "basic-summary-table";
10
+
11
+ const DEFAULT_TOTAL_LABEL = "Totalt";
12
+ const GENERATED_SUMMARY_ROW_ATTRIBUTE = "data-basic-summary-table-generated-row";
13
+ const GENERATED_SUMMARY_CELL_ATTRIBUTE = "data-basic-summary-table-generated-cell";
14
+ const GENERATED_SUMMARY_LABEL_ATTRIBUTE = "data-basic-summary-table-generated-label";
15
+
16
+ export function normalizeSummaryColumns(value) {
17
+ if (!value?.trim()) {
18
+ return [];
19
+ }
20
+
21
+ return Array.from(
22
+ new Set(
23
+ value
24
+ .split(",")
25
+ .map((part) => Number.parseInt(part.trim(), 10))
26
+ .filter((column) => Number.isInteger(column) && column > 0),
27
+ ),
28
+ ).sort((left, right) => left - right);
29
+ }
30
+
31
+ export function normalizeSummaryTotalLabel(value) {
32
+ return value?.trim() || DEFAULT_TOTAL_LABEL;
33
+ }
34
+
35
+ export function normalizeSummaryLocale(value) {
36
+ return value?.trim() || undefined;
37
+ }
38
+
39
+ function getDecimalSeparator(value) {
40
+ const text = value.trim();
41
+ const lastComma = text.lastIndexOf(",");
42
+ const lastDot = text.lastIndexOf(".");
43
+
44
+ if (lastComma !== -1 && lastDot !== -1) {
45
+ return lastComma > lastDot ? "," : ".";
46
+ }
47
+
48
+ if (lastComma !== -1) {
49
+ const digitsAfter = text.length - lastComma - 1;
50
+ return digitsAfter > 0 && digitsAfter <= 2 ? "," : null;
51
+ }
52
+
53
+ if (lastDot !== -1) {
54
+ const digitsAfter = text.length - lastDot - 1;
55
+ return digitsAfter > 0 && digitsAfter <= 2 ? "." : null;
56
+ }
57
+
58
+ return null;
59
+ }
60
+
61
+ function countFractionDigits(value) {
62
+ const text = String(value ?? "").trim();
63
+
64
+ if (!text) {
65
+ return 0;
66
+ }
67
+
68
+ const decimalSeparator = getDecimalSeparator(text);
69
+
70
+ if (!decimalSeparator) {
71
+ return 0;
72
+ }
73
+
74
+ return text.length - text.lastIndexOf(decimalSeparator) - 1;
75
+ }
76
+
77
+ export function parseSummaryNumber(value) {
78
+ const text = String(value ?? "").trim();
79
+
80
+ if (!text) {
81
+ return null;
82
+ }
83
+
84
+ const decimalSeparator = getDecimalSeparator(text);
85
+ let normalized = text.replace(/[^\d,.\-]/g, "");
86
+
87
+ if (!normalized || normalized === "-") {
88
+ return null;
89
+ }
90
+
91
+ if (decimalSeparator === ",") {
92
+ normalized = normalized.replace(/\./g, "").replace(",", ".");
93
+ } else if (decimalSeparator === ".") {
94
+ normalized = normalized.replace(/,/g, "");
95
+ } else {
96
+ normalized = normalized.replace(/[.,]/g, "");
97
+ }
98
+
99
+ const parsed = Number.parseFloat(normalized);
100
+ return Number.isFinite(parsed) ? parsed : null;
101
+ }
102
+
103
+ export function formatSummaryNumber(value, { locale, fractionDigits = 0 } = {}) {
104
+ return new Intl.NumberFormat(locale, {
105
+ minimumFractionDigits: fractionDigits,
106
+ maximumFractionDigits: fractionDigits,
107
+ }).format(value);
108
+ }
109
+
110
+ function findCellAtColumnIndex(row, targetColumnIndex) {
111
+ let columnIndex = 0;
112
+
113
+ for (const cell of Array.from(row.cells)) {
114
+ const colSpan = normalizeTableCellSpan(cell.getAttribute("colspan"));
115
+
116
+ if (targetColumnIndex >= columnIndex && targetColumnIndex < columnIndex + colSpan) {
117
+ return cell;
118
+ }
119
+
120
+ columnIndex += colSpan;
121
+ }
122
+
123
+ return null;
124
+ }
125
+
126
+ function getTable(root) {
127
+ const table = root.querySelector("table");
128
+ return table instanceof HTMLTableElementBase && table.closest(root.tagName.toLowerCase()) === root
129
+ ? table
130
+ : null;
131
+ }
132
+
133
+ function getLogicalColumnCount(table) {
134
+ let maxColumns = 0;
135
+
136
+ for (const row of Array.from(table.rows)) {
137
+ let columnCount = 0;
138
+
139
+ for (const cell of Array.from(row.cells)) {
140
+ columnCount += normalizeTableCellSpan(cell.getAttribute("colspan"));
141
+ }
142
+
143
+ maxColumns = Math.max(maxColumns, columnCount);
144
+ }
145
+
146
+ return maxColumns;
147
+ }
148
+
149
+ function getBodyRows(table) {
150
+ if (table.tBodies.length > 0) {
151
+ return Array.from(table.tBodies).flatMap((section) => Array.from(section.rows));
152
+ }
153
+
154
+ return Array.from(table.rows).filter(
155
+ (row) => row.parentElement?.tagName !== "THEAD" && row.parentElement?.tagName !== "TFOOT",
156
+ );
157
+ }
158
+
159
+ function collectSummaryColumns(table, configuredColumns, labelColumnIndex) {
160
+ if (configuredColumns.length > 0) {
161
+ return configuredColumns
162
+ .map((column) => column - 1)
163
+ .filter((column) => column >= 0 && column !== labelColumnIndex);
164
+ }
165
+
166
+ const inferredColumns = new Set();
167
+
168
+ for (const row of getBodyRows(table)) {
169
+ const logicalColumnCount = getLogicalColumnCount(table);
170
+
171
+ for (let columnIndex = 0; columnIndex < logicalColumnCount; columnIndex += 1) {
172
+ if (columnIndex === labelColumnIndex) {
173
+ continue;
174
+ }
175
+
176
+ const cell = findCellAtColumnIndex(row, columnIndex);
177
+ const rawValue = cell?.getAttribute("data-value") ?? cell?.textContent ?? "";
178
+
179
+ if (parseSummaryNumber(rawValue) !== null) {
180
+ inferredColumns.add(columnIndex);
181
+ }
182
+ }
183
+ }
184
+
185
+ return Array.from(inferredColumns).sort((left, right) => left - right);
186
+ }
187
+
188
+ function calculateSummaryTotals(table, summaryColumns) {
189
+ const totals = new Map();
190
+
191
+ for (const columnIndex of summaryColumns) {
192
+ totals.set(columnIndex, {
193
+ total: 0,
194
+ fractionDigits: 0,
195
+ });
196
+ }
197
+
198
+ for (const row of getBodyRows(table)) {
199
+ for (const columnIndex of summaryColumns) {
200
+ const cell = findCellAtColumnIndex(row, columnIndex);
201
+ const rawValue = cell?.getAttribute("data-value") ?? cell?.textContent ?? "";
202
+ const parsedValue = parseSummaryNumber(rawValue);
203
+
204
+ if (parsedValue === null) {
205
+ continue;
206
+ }
207
+
208
+ const current = totals.get(columnIndex);
209
+
210
+ if (!current) {
211
+ continue;
212
+ }
213
+
214
+ current.total += parsedValue;
215
+ current.fractionDigits = Math.max(current.fractionDigits, countFractionDigits(rawValue));
216
+ }
217
+ }
218
+
219
+ return totals;
220
+ }
221
+
222
+ function ensureGeneratedSummaryRow(table) {
223
+ let tfoot = table.tFoot;
224
+
225
+ if (!tfoot) {
226
+ tfoot = document.createElement("tfoot");
227
+ table.append(tfoot);
228
+ }
229
+
230
+ let row = tfoot.querySelector(`tr[${GENERATED_SUMMARY_ROW_ATTRIBUTE}]`);
231
+
232
+ if (!row) {
233
+ row = document.createElement("tr");
234
+ row.setAttribute(GENERATED_SUMMARY_ROW_ATTRIBUTE, "");
235
+ tfoot.append(row);
236
+ }
237
+
238
+ return row;
239
+ }
240
+
241
+ function removeGeneratedSummaryRow(table) {
242
+ table.querySelector(`tr[${GENERATED_SUMMARY_ROW_ATTRIBUTE}]`)?.remove();
243
+ }
244
+
245
+ function syncSummaryFooter(table, {
246
+ labelColumnIndex,
247
+ locale,
248
+ summaryColumns,
249
+ totalLabel,
250
+ totals,
251
+ }) {
252
+ if (summaryColumns.length === 0) {
253
+ removeGeneratedSummaryRow(table);
254
+ return false;
255
+ }
256
+
257
+ const logicalColumnCount = getLogicalColumnCount(table);
258
+ const effectiveLabelColumnIndex = Math.min(labelColumnIndex, Math.max(logicalColumnCount - 1, 0));
259
+ const summaryColumnSet = new Set(summaryColumns.filter((column) => column < logicalColumnCount));
260
+ const row = ensureGeneratedSummaryRow(table);
261
+
262
+ row.replaceChildren();
263
+
264
+ for (let columnIndex = 0; columnIndex < logicalColumnCount; columnIndex += 1) {
265
+ if (columnIndex === effectiveLabelColumnIndex) {
266
+ const labelCell = document.createElement("th");
267
+ labelCell.scope = "row";
268
+ labelCell.textContent = totalLabel;
269
+ labelCell.setAttribute(GENERATED_SUMMARY_LABEL_ATTRIBUTE, "");
270
+ row.append(labelCell);
271
+ continue;
272
+ }
273
+
274
+ const valueCell = document.createElement("td");
275
+ valueCell.setAttribute(GENERATED_SUMMARY_CELL_ATTRIBUTE, "");
276
+
277
+ if (summaryColumnSet.has(columnIndex)) {
278
+ const summary = totals.get(columnIndex) ?? { total: 0, fractionDigits: 0 };
279
+ valueCell.textContent = formatSummaryNumber(summary.total, {
280
+ locale,
281
+ fractionDigits: summary.fractionDigits,
282
+ });
283
+ valueCell.dataset.value = String(summary.total);
284
+ valueCell.dataset.summaryTotal = "";
285
+ } else {
286
+ valueCell.dataset.summaryEmpty = "";
287
+ }
288
+
289
+ row.append(valueCell);
290
+ }
291
+
292
+ return true;
293
+ }
294
+
295
+ export class SummaryTableElement extends TableElement {
296
+ static observedAttributes = [
297
+ ...TableElement.observedAttributes,
298
+ "data-locale",
299
+ "data-summary-columns",
300
+ "data-total-label",
301
+ ];
302
+
303
+ #summaryObserver = null;
304
+ #scheduledFrame = 0;
305
+
306
+ connectedCallback() {
307
+ super.connectedCallback();
308
+ this.#syncSummaryObserver();
309
+ }
310
+
311
+ disconnectedCallback() {
312
+ super.disconnectedCallback();
313
+ this.#summaryObserver?.disconnect();
314
+ this.#summaryObserver = null;
315
+
316
+ if (this.#scheduledFrame !== 0 && typeof window !== "undefined") {
317
+ window.cancelAnimationFrame(this.#scheduledFrame);
318
+ this.#scheduledFrame = 0;
319
+ }
320
+ }
321
+
322
+ refresh() {
323
+ super.refresh();
324
+
325
+ const table = getTable(this);
326
+
327
+ if (!(table instanceof HTMLTableElementBase)) {
328
+ return;
329
+ }
330
+
331
+ const labelColumnIndex = normalizeTableRowHeaderColumn(
332
+ this.getAttribute("data-row-header-column"),
333
+ ) - 1;
334
+ const summaryColumns = collectSummaryColumns(
335
+ table,
336
+ normalizeSummaryColumns(this.getAttribute("data-summary-columns")),
337
+ labelColumnIndex,
338
+ );
339
+ const totals = calculateSummaryTotals(table, summaryColumns);
340
+
341
+ syncSummaryFooter(table, {
342
+ labelColumnIndex,
343
+ locale: normalizeSummaryLocale(this.getAttribute("data-locale")),
344
+ summaryColumns,
345
+ totalLabel: normalizeSummaryTotalLabel(this.getAttribute("data-total-label")),
346
+ totals,
347
+ });
348
+
349
+ super.refresh();
350
+ }
351
+
352
+ #scheduleRefresh() {
353
+ if (this.#scheduledFrame !== 0 || typeof window === "undefined") {
354
+ return;
355
+ }
356
+
357
+ this.#scheduledFrame = window.requestAnimationFrame(() => {
358
+ this.#scheduledFrame = 0;
359
+ this.refresh();
360
+ });
361
+ }
362
+
363
+ #syncSummaryObserver() {
364
+ if (this.#summaryObserver || typeof MutationObserver === "undefined") {
365
+ return;
366
+ }
367
+
368
+ this.#summaryObserver = new MutationObserver(() => {
369
+ this.#scheduleRefresh();
370
+ });
371
+
372
+ this.#summaryObserver.observe(this, {
373
+ subtree: true,
374
+ attributes: true,
375
+ attributeFilter: ["data-value"],
376
+ });
377
+ }
378
+ }
379
+
380
+ export function defineSummaryTable(registry = globalThis.customElements) {
381
+ if (!registry?.get || !registry?.define) {
382
+ return SummaryTableElement;
383
+ }
384
+
385
+ if (!registry.get(SUMMARY_TABLE_TAG_NAME)) {
386
+ registry.define(SUMMARY_TABLE_TAG_NAME, SummaryTableElement);
387
+ }
388
+
389
+ return SummaryTableElement;
390
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { defineSummaryTable } from "./index.js";
2
+
3
+ defineSummaryTable();
@@ -0,0 +1,75 @@
1
+ export const TABLE_TAG_NAME: "basic-table";
2
+
3
+ /**
4
+ * Normalizes unsupported or empty labels back to the default `"Tabell"`.
5
+ */
6
+ export function normalizeTableLabel(
7
+ value?: string | null,
8
+ ): string;
9
+
10
+ /**
11
+ * Normalizes the generated caption text. Empty values disable caption generation.
12
+ */
13
+ export function normalizeTableCaption(
14
+ value?: string | null,
15
+ ): string;
16
+
17
+ /**
18
+ * Normalizes the generated description text. Empty values disable description generation.
19
+ */
20
+ export function normalizeTableDescription(
21
+ value?: string | null,
22
+ ): string;
23
+
24
+ /**
25
+ * Normalizes the root `data-row-headers` attribute into a boolean flag.
26
+ */
27
+ export function normalizeTableRowHeaders(
28
+ value?: string | boolean | null,
29
+ ): boolean;
30
+
31
+ /**
32
+ * Normalizes the root `data-column-headers` attribute into a boolean flag.
33
+ */
34
+ export function normalizeTableColumnHeaders(
35
+ value?: string | boolean | null,
36
+ ): boolean;
37
+
38
+ /**
39
+ * Normalizes invalid row-header-column values back to `1`.
40
+ */
41
+ export function normalizeTableRowHeaderColumn(
42
+ value?: string | null,
43
+ ): number;
44
+
45
+ /**
46
+ * Normalizes invalid `rowspan` and `colspan` values back to `1`.
47
+ */
48
+ export function normalizeTableCellSpan(
49
+ value?: string | null,
50
+ ): number;
51
+
52
+ /**
53
+ * Custom element that upgrades existing table markup with stronger accessible
54
+ * naming and header associations.
55
+ *
56
+ * Attributes:
57
+ * - `data-caption`: optional generated `<caption>` text when the table has none
58
+ * - `data-column-headers`: promotes the first row to column headers
59
+ * - `data-description`: optional generated description wired through `aria-describedby`
60
+ * - `data-label`: fallback accessible name when the table has neither a caption
61
+ * nor its own label
62
+ * - `data-row-header-column`: one-based column index to use for generated row headers
63
+ * - `data-row-headers`: promotes the first cell in each body row to a row header
64
+ */
65
+ export class TableElement extends HTMLElement {
66
+ static observedAttributes: string[];
67
+ refresh(): void;
68
+ }
69
+
70
+ /**
71
+ * Registers the `basic-table` custom element if it is not already defined.
72
+ */
73
+ export function defineTable(
74
+ registry?: CustomElementRegistry,
75
+ ): typeof TableElement;