@cl-light-ds/ui 0.1.0 → 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.
- package/package.json +1 -1
- package/src/components/accordion/CHANGELOG.md +41 -41
- package/src/components/accordion/index.ts +4 -4
- package/src/components/alert/CHANGELOG.md +5 -5
- package/src/components/alert/index.ts +4 -4
- package/src/components/avatar/CHANGELOG.md +44 -44
- package/src/components/avatar/index.ts +6 -6
- package/src/components/badge/CHANGELOG.md +49 -49
- package/src/components/badge/index.ts +2 -2
- package/src/components/breadcrumb/CHANGELOG.md +24 -24
- package/src/components/breadcrumb/index.ts +5 -5
- package/src/components/button/CHANGELOG.md +41 -41
- package/src/components/button/index.ts +2 -2
- package/src/components/button-group/CHANGELOG.md +34 -34
- package/src/components/button-group/index.ts +3 -3
- package/src/components/card/CHANGELOG.md +162 -119
- package/src/components/card/Card.astro +23 -8
- package/src/components/card/CardContent.astro +1 -1
- package/src/components/card/CardDescription.astro +1 -1
- package/src/components/card/CardMedia.astro +1 -1
- package/src/components/card/CardMediaActions.astro +21 -0
- package/src/components/card/CardMediaSelect.astro +21 -0
- package/src/components/card/index.ts +12 -10
- package/src/components/carousel/CHANGELOG.md +5 -5
- package/src/components/carousel/index.ts +3 -3
- package/src/components/checkbox/CHANGELOG.md +58 -58
- package/src/components/checkbox/Checkbox.astro +2 -2
- package/src/components/checkbox/index.ts +1 -1
- package/src/components/collapsible/CHANGELOG.md +5 -5
- package/src/components/collapsible/index.ts +4 -4
- package/src/components/combobox/CHANGELOG.md +27 -27
- package/src/components/combobox/index.ts +11 -11
- package/src/components/date/CHANGELOG.md +18 -18
- package/src/components/date/index.ts +2 -2
- package/src/components/dialog/CHANGELOG.md +39 -39
- package/src/components/dialog/index.ts +9 -9
- package/src/components/dropdown-menu/CHANGELOG.md +194 -194
- package/src/components/dropdown-menu/DropdownMenuItemContent.astro +79 -79
- package/src/components/dropdown-menu/DropdownMenuPortal.astro +13 -13
- package/src/components/dropdown-menu/DropdownMenuRadioGroup.astro +13 -13
- package/src/components/dropdown-menu/dropdown-menu-item-variants.ts +24 -24
- package/src/components/dropdown-menu/index.ts +12 -12
- package/src/components/field/CHANGELOG.md +33 -33
- package/src/components/field/index.ts +10 -10
- package/src/components/hover-card/CHANGELOG.md +20 -20
- package/src/components/hover-card/index.ts +3 -3
- package/src/components/input/CHANGELOG.md +29 -29
- package/src/components/input/index.ts +2 -2
- package/src/components/input-group/CHANGELOG.md +49 -49
- package/src/components/input-group/controlClasses.ts +2 -2
- package/src/components/input-group/index.ts +6 -6
- package/src/components/item/CHANGELOG.md +27 -27
- package/src/components/item/index.ts +10 -10
- package/src/components/kbd/CHANGELOG.md +5 -5
- package/src/components/kbd/index.ts +2 -2
- package/src/components/label/CHANGELOG.md +28 -28
- package/src/components/label/index.ts +2 -2
- package/src/components/link-group/CHANGELOG.md +75 -75
- package/src/components/link-group/index.ts +5 -5
- package/src/components/link-group/link-group-list-variants.ts +15 -15
- package/src/components/marquee/CHANGELOG.md +5 -5
- package/src/components/marquee/index.ts +2 -2
- package/src/components/native-select/CHANGELOG.md +5 -5
- package/src/components/native-select/NativeSelectOption.astro +9 -9
- package/src/components/native-select/index.ts +3 -3
- package/src/components/navigation-menu/CHANGELOG.md +63 -63
- package/src/components/navigation-menu/index.ts +10 -10
- package/src/components/organisms/lightning-sidebar-nav/CHANGELOG.md +50 -0
- package/src/components/organisms/lightning-sidebar-nav/LightningSidebarNav.astro +176 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNav.astro +50 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNavGroup.astro +74 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNavItem.astro +94 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNavProBadge.astro +26 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNavSection.astro +71 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNavSkeleton.astro +67 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNavSubItem.astro +69 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNavUsage.astro +78 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNavWorkspaceAvatar.astro +46 -0
- package/src/components/organisms/lightning-sidebar-nav/SidebarNavWorkspaceSwitcher.astro +130 -0
- package/src/components/organisms/lightning-sidebar-nav/icons.ts +46 -0
- package/src/components/organisms/lightning-sidebar-nav/index.ts +18 -0
- package/src/components/organisms/lightning-sidebar-nav/types.ts +77 -0
- package/src/components/popover/CHANGELOG.md +20 -5
- package/src/components/popover/Popover.astro +1 -0
- package/src/components/popover/PopoverContent.astro +6 -5
- package/src/components/popover/PopoverHeader.astro +1 -1
- package/src/components/popover/PopoverTrigger.astro +7 -1
- package/src/components/popover/index.ts +7 -7
- package/src/components/radio-group/CHANGELOG.md +31 -9
- package/src/components/radio-group/RadioGroup.astro +1 -1
- package/src/components/radio-group/RadioGroupItem.astro +12 -3
- package/src/components/radio-group/index.ts +3 -3
- package/src/components/select/CHANGELOG.md +39 -39
- package/src/components/select/index.ts +8 -8
- package/src/components/separator/CHANGELOG.md +5 -5
- package/src/components/separator/index.ts +2 -2
- package/src/components/skeleton/CHANGELOG.md +5 -5
- package/src/components/skeleton/index.ts +1 -1
- package/src/components/slider/CHANGELOG.md +5 -5
- package/src/components/slider/index.ts +1 -1
- package/src/components/spinner/CHANGELOG.md +18 -5
- package/src/components/spinner/Spinner.astro +3 -2
- package/src/components/spinner/index.ts +1 -1
- package/src/components/sticky-surface/CHANGELOG.md +5 -5
- package/src/components/sticky-surface/index.ts +2 -2
- package/src/components/switch/CHANGELOG.md +29 -9
- package/src/components/switch/Switch.astro +4 -4
- package/src/components/switch/index.ts +1 -1
- package/src/components/table/CHANGELOG.md +24 -5
- package/src/components/table/Table.astro +120 -12
- package/src/components/table/TableBody.astro +1 -2
- package/src/components/table/TableBulkToolbar.astro +37 -0
- package/src/components/table/TableCell.astro +25 -1
- package/src/components/table/TableCellText.astro +17 -0
- package/src/components/table/TableColumnMenu.astro +50 -0
- package/src/components/table/TableEditableCell.astro +287 -0
- package/src/components/table/TableEmpty.astro +17 -0
- package/src/components/table/TableFolderCell.astro +38 -0
- package/src/components/table/TableFooter.astro +1 -1
- package/src/components/table/TableHead.astro +71 -2
- package/src/components/table/TableHeader.astro +1 -1
- package/src/components/table/TablePagination.astro +57 -0
- package/src/components/table/TableRow.astro +32 -1
- package/src/components/table/TableSelectAllBanner.astro +34 -0
- package/src/components/table/TableSelectCell.astro +50 -0
- package/src/components/table/TableSkeletonRow.astro +21 -0
- package/src/components/table/index.ts +19 -9
- package/src/components/table/table-cell-variants.ts +29 -0
- package/src/components/tabs/CHANGELOG.md +5 -5
- package/src/components/tabs/index.ts +6 -6
- package/src/components/textarea/CHANGELOG.md +22 -5
- package/src/components/textarea/Textarea.astro +25 -3
- package/src/components/textarea/index.ts +2 -2
- package/src/components/toggle/CHANGELOG.md +23 -5
- package/src/components/toggle/Toggle.astro +13 -1
- package/src/components/toggle/index.ts +1 -1
- package/src/components/toggle-group/CHANGELOG.md +18 -5
- package/src/components/toggle-group/ToggleGroup.astro +14 -2
- package/src/components/toggle-group/index.ts +2 -2
- package/src/components/tooltip/CHANGELOG.md +22 -5
- package/src/components/tooltip/Tooltip.astro +26 -0
- package/src/components/tooltip/TooltipContent.astro +34 -9
- package/src/components/tooltip/index.ts +3 -3
- package/src/lib/table-controller.ts +1090 -0
- package/src/lib/toggle-group-controller.ts +113 -113
- package/src/lib/toggle-shared.ts +27 -26
- package/src/lib/utils.ts +6 -6
- package/src/styles/globals.css +197 -177
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behaviour for `<Table interactive>`.
|
|
3
|
+
*
|
|
4
|
+
* The controller never sorts, pages or persists data. It manages UI state
|
|
5
|
+
* (sort indicators, selection, edit lifecycle, tree visibility, column layout,
|
|
6
|
+
* keyboard focus) and reports intent through bubbling `table:*` events on the
|
|
7
|
+
* `[data-slot="table-root"]` element. The app responds by re-rendering rows;
|
|
8
|
+
* a MutationObserver re-applies layout and selection state to the new rows.
|
|
9
|
+
*
|
|
10
|
+
* Outbound events (all bubble, all cancelable unless noted):
|
|
11
|
+
* - `table:sort` `{ column, direction: "asc" | "desc" | null }`
|
|
12
|
+
* - `table:edit` `{ rowId, column, value, previous, respond(promise) }`
|
|
13
|
+
* Call `respond` with a promise; reject it (Error or string)
|
|
14
|
+
* to show the error state. `preventDefault()` rejects the edit.
|
|
15
|
+
* - `table:selection` `{ rowIds, allResults }` (not cancelable)
|
|
16
|
+
* - `table:page` `{ page, pageSize }`
|
|
17
|
+
* - `table:toggle-row` `{ rowId, expanded }`
|
|
18
|
+
* - `table:columns` `{ widths, hidden }` (not cancelable)
|
|
19
|
+
*
|
|
20
|
+
* Inbound event:
|
|
21
|
+
* - `table:set` `{ loading?: boolean, totalCount?: number, page?: number }`
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type SortState = "asc" | "desc" | "none";
|
|
25
|
+
type EditValue = string | string[];
|
|
26
|
+
|
|
27
|
+
export interface TableEditDetail {
|
|
28
|
+
rowId: string | null;
|
|
29
|
+
column: string;
|
|
30
|
+
value: EditValue;
|
|
31
|
+
previous: EditValue;
|
|
32
|
+
respond: (result: unknown) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TableSortDetail {
|
|
36
|
+
column: string;
|
|
37
|
+
direction: "asc" | "desc" | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TableSetDetail {
|
|
41
|
+
loading?: boolean;
|
|
42
|
+
totalCount?: number;
|
|
43
|
+
page?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const bound = new WeakSet<HTMLElement>();
|
|
47
|
+
const MIN_COLUMN_WIDTH = 64;
|
|
48
|
+
const ERROR_TIMEOUT = 4000;
|
|
49
|
+
|
|
50
|
+
export function initTables(scope: ParentNode = document) {
|
|
51
|
+
scope
|
|
52
|
+
.querySelectorAll<HTMLElement>('[data-slot="table-root"][data-interactive]')
|
|
53
|
+
.forEach((root) => {
|
|
54
|
+
if (bound.has(root)) return;
|
|
55
|
+
bound.add(root);
|
|
56
|
+
createTable(root);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function errorMessage(error: unknown, fallback: string): string {
|
|
61
|
+
if (typeof error === "string" && error) return error;
|
|
62
|
+
if (error instanceof Error && error.message) return error.message;
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseValue(td: HTMLElement): EditValue {
|
|
67
|
+
const raw = td.dataset.value ?? "";
|
|
68
|
+
if (td.dataset.editor !== "multiselect") return raw;
|
|
69
|
+
try {
|
|
70
|
+
const parsed: unknown = JSON.parse(raw || "[]");
|
|
71
|
+
return Array.isArray(parsed) ? parsed.map(String) : [];
|
|
72
|
+
} catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function serializeValue(td: HTMLElement, value: EditValue): string {
|
|
78
|
+
return td.dataset.editor === "multiselect" ? JSON.stringify(value) : String(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sameValue(a: EditValue, b: EditValue): boolean {
|
|
82
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
83
|
+
return a.length === b.length && a.every((v) => b.includes(v));
|
|
84
|
+
}
|
|
85
|
+
return a === b;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isTextEntry(el: Element | null): boolean {
|
|
89
|
+
return !!el && (el.matches("input:not([type=checkbox]), textarea, select") || !!el.closest('[data-slot="table-cell-editor"]'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createTable(root: HTMLElement) {
|
|
93
|
+
const tableElement = root.querySelector<HTMLTableElement>('table[data-slot="table"]');
|
|
94
|
+
if (!tableElement) return;
|
|
95
|
+
const table: HTMLTableElement = tableElement;
|
|
96
|
+
|
|
97
|
+
const live = root.querySelector<HTMLElement>('[data-slot="table-live"]');
|
|
98
|
+
const pagination = root.querySelector<HTMLElement>('[data-slot="table-pagination"]');
|
|
99
|
+
const banner = root.querySelector<HTMLElement>('[data-slot="table-select-all-banner"]');
|
|
100
|
+
const columnMenu = root.querySelector<HTMLElement>("[data-table-column-menu]");
|
|
101
|
+
const pinCount = Number(root.dataset.pinnedColumns ?? 0) || 0;
|
|
102
|
+
|
|
103
|
+
let allResults = false;
|
|
104
|
+
let activeText: HTMLTableCellElement | null = null;
|
|
105
|
+
let current: HTMLTableCellElement | null = null;
|
|
106
|
+
const hiddenColumns = new Set<string>();
|
|
107
|
+
const visibleErrors = new Set<HTMLTableCellElement>();
|
|
108
|
+
const errorTimers = new WeakMap<HTMLElement, number>();
|
|
109
|
+
const boundWidgets = new WeakSet<Element>();
|
|
110
|
+
|
|
111
|
+
table.setAttribute("role", "grid");
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------- helpers
|
|
114
|
+
|
|
115
|
+
const emit = <T,>(name: string, detail: T, cancelable = true) =>
|
|
116
|
+
root.dispatchEvent(new CustomEvent<T>(name, { bubbles: true, cancelable, detail }));
|
|
117
|
+
|
|
118
|
+
const announce = (message: string) => {
|
|
119
|
+
if (!live) return;
|
|
120
|
+
live.textContent = "";
|
|
121
|
+
window.setTimeout(() => {
|
|
122
|
+
live.textContent = message;
|
|
123
|
+
}, 50);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const headerRow = () => table.tHead?.rows[table.tHead.rows.length - 1] ?? null;
|
|
127
|
+
|
|
128
|
+
const bodyRows = () =>
|
|
129
|
+
Array.from(table.tBodies).flatMap((tbody) =>
|
|
130
|
+
Array.from(tbody.rows).filter((r) => r.dataset.slot === "table-row"),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const allRows = () => Array.from(table.rows);
|
|
134
|
+
|
|
135
|
+
const cellLabel = (td: HTMLElement) => td.dataset.label ?? td.dataset.column ?? "value";
|
|
136
|
+
|
|
137
|
+
const hasTree = () => !!table.querySelector("tbody tr[data-folder]");
|
|
138
|
+
|
|
139
|
+
const totalCount = () => {
|
|
140
|
+
const value = Number(root.dataset.totalCount ?? pagination?.dataset.totalCount);
|
|
141
|
+
return Number.isFinite(value) && value > 0 ? value : null;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------- sorting
|
|
145
|
+
|
|
146
|
+
const syncSortAvailability = () => {
|
|
147
|
+
const tree = hasTree();
|
|
148
|
+
table.querySelectorAll<HTMLButtonElement>('[data-slot="table-sort-button"]').forEach((button) => {
|
|
149
|
+
button.disabled = tree;
|
|
150
|
+
button.title = tree ? "Sorting is not available in tree tables" : "";
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const setSortState = (th: HTMLElement, state: SortState) => {
|
|
155
|
+
th.dataset.sort = state;
|
|
156
|
+
th.setAttribute("aria-sort", state === "asc" ? "ascending" : state === "desc" ? "descending" : "none");
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const cycleSort = (th: HTMLTableCellElement) => {
|
|
160
|
+
if (hasTree()) return;
|
|
161
|
+
const column = th.dataset.column;
|
|
162
|
+
if (!column) return;
|
|
163
|
+
const sortable = Array.from(table.querySelectorAll<HTMLTableCellElement>("th[data-sortable]"));
|
|
164
|
+
const previous = sortable.map((h) => [h, (h.dataset.sort as SortState) ?? "none"] as const);
|
|
165
|
+
const now = (th.dataset.sort as SortState) ?? "none";
|
|
166
|
+
const next: SortState = now === "asc" ? "desc" : now === "desc" ? "none" : "asc";
|
|
167
|
+
|
|
168
|
+
sortable.forEach((h) => setSortState(h, h === th ? next : "none"));
|
|
169
|
+
|
|
170
|
+
const accepted = emit<TableSortDetail>("table:sort", {
|
|
171
|
+
column,
|
|
172
|
+
direction: next === "none" ? null : next,
|
|
173
|
+
});
|
|
174
|
+
if (!accepted) {
|
|
175
|
+
previous.forEach(([h, state]) => setSortState(h, state));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const name = th.textContent?.trim() || column;
|
|
179
|
+
announce(next === "none" ? "Sorting cleared" : `Sorted by ${name}, ${next === "asc" ? "ascending" : "descending"}`);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// -------------------------------------------------------------- selection
|
|
183
|
+
|
|
184
|
+
const rowCheckbox = (tr: HTMLTableRowElement) =>
|
|
185
|
+
tr.querySelector<HTMLInputElement>('input[data-table-select="row"]');
|
|
186
|
+
|
|
187
|
+
const headerCheckbox = () => table.querySelector<HTMLInputElement>('input[data-table-select="all"]');
|
|
188
|
+
|
|
189
|
+
const selectionRows = () => bodyRows().filter((tr) => rowCheckbox(tr));
|
|
190
|
+
|
|
191
|
+
const descendantsOf = (tr: HTMLTableRowElement) => {
|
|
192
|
+
const id = tr.dataset.rowId;
|
|
193
|
+
if (!id) return [];
|
|
194
|
+
const ids = new Set([id]);
|
|
195
|
+
const result: HTMLTableRowElement[] = [];
|
|
196
|
+
for (const row of bodyRows()) {
|
|
197
|
+
const parent = row.dataset.parentId;
|
|
198
|
+
if (parent && ids.has(parent)) {
|
|
199
|
+
result.push(row);
|
|
200
|
+
if (row.dataset.rowId) ids.add(row.dataset.rowId);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const setText = (selector: string, text: string, scope: ParentNode = root) =>
|
|
207
|
+
scope.querySelectorAll(selector).forEach((el) => {
|
|
208
|
+
el.textContent = text;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const syncSelection = () => {
|
|
212
|
+
const rows = selectionRows();
|
|
213
|
+
const boxes = rows.map((tr) => rowCheckbox(tr)!).filter((b) => !b.disabled);
|
|
214
|
+
let checkedCount = 0;
|
|
215
|
+
|
|
216
|
+
rows.forEach((tr) => {
|
|
217
|
+
const checked = rowCheckbox(tr)!.checked;
|
|
218
|
+
if (checked) {
|
|
219
|
+
checkedCount += 1;
|
|
220
|
+
tr.dataset.state = "selected";
|
|
221
|
+
tr.setAttribute("aria-selected", "true");
|
|
222
|
+
} else {
|
|
223
|
+
if (tr.dataset.state === "selected") delete tr.dataset.state;
|
|
224
|
+
tr.setAttribute("aria-selected", "false");
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const everyChecked = boxes.length > 0 && boxes.every((b) => b.checked);
|
|
229
|
+
const header = headerCheckbox();
|
|
230
|
+
if (header) {
|
|
231
|
+
header.checked = everyChecked;
|
|
232
|
+
header.indeterminate = checkedCount > 0 && !everyChecked;
|
|
233
|
+
}
|
|
234
|
+
if (!everyChecked) allResults = false;
|
|
235
|
+
|
|
236
|
+
const total = totalCount();
|
|
237
|
+
const count = allResults && total ? total : checkedCount;
|
|
238
|
+
root.toggleAttribute("data-has-selection", checkedCount > 0);
|
|
239
|
+
setText('[data-slot="table-selection-count"]', String(count));
|
|
240
|
+
|
|
241
|
+
if (banner) {
|
|
242
|
+
const show = everyChecked && total !== null && total > boxes.length;
|
|
243
|
+
banner.hidden = !show;
|
|
244
|
+
setText('[data-slot="table-page-count"]', String(boxes.length), banner);
|
|
245
|
+
setText('[data-slot="table-total-count"]', String(total ?? 0), banner);
|
|
246
|
+
banner.querySelectorAll<HTMLElement>("[data-banner-page]").forEach((el) => (el.hidden = allResults));
|
|
247
|
+
banner.querySelectorAll<HTMLElement>("[data-banner-all]").forEach((el) => (el.hidden = !allResults));
|
|
248
|
+
}
|
|
249
|
+
return { rows, checkedCount, count };
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const selectionChanged = () => {
|
|
253
|
+
const { rows, count } = syncSelection();
|
|
254
|
+
const rowIds = rows
|
|
255
|
+
.filter((tr) => rowCheckbox(tr)!.checked)
|
|
256
|
+
.map((tr) => tr.dataset.rowId)
|
|
257
|
+
.filter((id): id is string => !!id);
|
|
258
|
+
emit("table:selection", { rowIds, allResults }, false);
|
|
259
|
+
announce(count === 0 ? "No rows selected" : `${count} selected`);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const clearSelection = () => {
|
|
263
|
+
allResults = false;
|
|
264
|
+
selectionRows().forEach((tr) => (rowCheckbox(tr)!.checked = false));
|
|
265
|
+
selectionChanged();
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ------------------------------------------------------------------- tree
|
|
269
|
+
|
|
270
|
+
const applyTree = () => {
|
|
271
|
+
const hiddenParents = new Set<string>();
|
|
272
|
+
for (const tr of bodyRows()) {
|
|
273
|
+
const parent = tr.dataset.parentId;
|
|
274
|
+
const hiddenByParent = !!parent && hiddenParents.has(parent);
|
|
275
|
+
tr.hidden = hiddenByParent;
|
|
276
|
+
const id = tr.dataset.rowId;
|
|
277
|
+
const folder = tr.hasAttribute("data-folder");
|
|
278
|
+
const expanded = tr.hasAttribute("data-expanded");
|
|
279
|
+
if (id && (hiddenByParent || (folder && !expanded))) hiddenParents.add(id);
|
|
280
|
+
if (folder) {
|
|
281
|
+
tr.setAttribute("aria-expanded", String(expanded));
|
|
282
|
+
const toggle = tr.querySelector<HTMLElement>('[data-slot="table-row-toggle"]');
|
|
283
|
+
toggle?.setAttribute("aria-label", expanded ? "Collapse row" : "Expand row");
|
|
284
|
+
toggle?.setAttribute("aria-expanded", String(expanded));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const setExpanded = (tr: HTMLTableRowElement, expanded: boolean) => {
|
|
290
|
+
if (!tr.hasAttribute("data-folder") || tr.hasAttribute("data-expanded") === expanded) return;
|
|
291
|
+
tr.toggleAttribute("data-expanded", expanded);
|
|
292
|
+
applyTree();
|
|
293
|
+
emit("table:toggle-row", { rowId: tr.dataset.rowId ?? null, expanded });
|
|
294
|
+
const name = tr.querySelector("[data-folder-cell]")?.textContent?.trim() ?? "Row";
|
|
295
|
+
announce(`${name} ${expanded ? "expanded" : "collapsed"}`);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------- columns
|
|
299
|
+
|
|
300
|
+
const columnCells = (index: number, column: string | undefined, headerLength: number) =>
|
|
301
|
+
allRows().flatMap((tr) => {
|
|
302
|
+
if (tr.cells.length === headerLength) return tr.cells[index] ? [tr.cells[index]] : [];
|
|
303
|
+
if (!column) return [];
|
|
304
|
+
return Array.from(tr.cells).filter((c) => c.dataset.column === column);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const applyColumns = () => {
|
|
308
|
+
const header = headerRow();
|
|
309
|
+
if (!header) return;
|
|
310
|
+
Array.from(header.cells).forEach((th, index) => {
|
|
311
|
+
const column = th.dataset.column;
|
|
312
|
+
const hide = !!column && hiddenColumns.has(column);
|
|
313
|
+
columnCells(index, column, header.cells.length).forEach((cell) => (cell.hidden = hide));
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const applyPinned = () => {
|
|
318
|
+
const header = headerRow();
|
|
319
|
+
if (!header || pinCount <= 0) return;
|
|
320
|
+
const ths = Array.from(header.cells);
|
|
321
|
+
const pinned = ths.map((_, i) => i).filter((i) => !ths[i]!.hidden).slice(0, pinCount);
|
|
322
|
+
const offsets = new Map<number, number>();
|
|
323
|
+
let left = 0;
|
|
324
|
+
pinned.forEach((i) => {
|
|
325
|
+
offsets.set(i, left);
|
|
326
|
+
left += ths[i]!.getBoundingClientRect().width;
|
|
327
|
+
});
|
|
328
|
+
const last = pinned[pinned.length - 1];
|
|
329
|
+
ths.forEach((th, index) => {
|
|
330
|
+
columnCells(index, th.dataset.column, ths.length).forEach((cell) => {
|
|
331
|
+
const offset = offsets.get(index);
|
|
332
|
+
if (offset === undefined) {
|
|
333
|
+
delete cell.dataset.pinned;
|
|
334
|
+
delete cell.dataset.pinnedLast;
|
|
335
|
+
cell.style.left = "";
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
cell.dataset.pinned = "";
|
|
339
|
+
cell.toggleAttribute("data-pinned-last", index === last);
|
|
340
|
+
cell.style.left = `${offset}px`;
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const emitColumns = () => {
|
|
346
|
+
const widths: Record<string, number> = {};
|
|
347
|
+
headerRow()
|
|
348
|
+
?.querySelectorAll<HTMLElement>("th[data-column]")
|
|
349
|
+
.forEach((th) => {
|
|
350
|
+
if (th.style.width) widths[th.dataset.column!] = Math.round(parseFloat(th.style.width));
|
|
351
|
+
});
|
|
352
|
+
emit("table:columns", { widths, hidden: Array.from(hiddenColumns) }, false);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const setColumnWidth = (th: HTMLElement, width: number) => {
|
|
356
|
+
const px = `${Math.max(MIN_COLUMN_WIDTH, Math.round(width))}px`;
|
|
357
|
+
th.style.width = px;
|
|
358
|
+
th.style.minWidth = px;
|
|
359
|
+
applyPinned();
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const bindResize = (handle: HTMLElement) => {
|
|
363
|
+
handle.addEventListener("pointerdown", (event) => {
|
|
364
|
+
const th = handle.closest("th");
|
|
365
|
+
if (!th) return;
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
event.stopPropagation();
|
|
368
|
+
const startX = event.clientX;
|
|
369
|
+
const startWidth = th.getBoundingClientRect().width;
|
|
370
|
+
handle.setPointerCapture(event.pointerId);
|
|
371
|
+
root.dataset.resizing = "";
|
|
372
|
+
const move = (e: PointerEvent) => setColumnWidth(th, startWidth + e.clientX - startX);
|
|
373
|
+
const up = () => {
|
|
374
|
+
handle.removeEventListener("pointermove", move);
|
|
375
|
+
handle.removeEventListener("pointerup", up);
|
|
376
|
+
handle.removeEventListener("pointercancel", up);
|
|
377
|
+
delete root.dataset.resizing;
|
|
378
|
+
emitColumns();
|
|
379
|
+
};
|
|
380
|
+
handle.addEventListener("pointermove", move);
|
|
381
|
+
handle.addEventListener("pointerup", up);
|
|
382
|
+
handle.addEventListener("pointercancel", up);
|
|
383
|
+
});
|
|
384
|
+
// A click on the handle must not toggle the sort button underneath.
|
|
385
|
+
handle.addEventListener("click", (e) => e.stopPropagation());
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
if (columnMenu) {
|
|
389
|
+
let columns: string[] = [];
|
|
390
|
+
let visible: string[] = [];
|
|
391
|
+
try {
|
|
392
|
+
columns = JSON.parse(columnMenu.dataset.columns ?? "[]");
|
|
393
|
+
visible = JSON.parse(columnMenu.dataset.defaultValues ?? "[]");
|
|
394
|
+
} catch {
|
|
395
|
+
/* malformed attributes: treat every column as visible */
|
|
396
|
+
}
|
|
397
|
+
columns.filter((c) => !visible.includes(c)).forEach((c) => hiddenColumns.add(c));
|
|
398
|
+
columnMenu.addEventListener("dropdown-menu:values-change", (event) => {
|
|
399
|
+
const { values } = (event as CustomEvent<{ values: string[] }>).detail;
|
|
400
|
+
hiddenColumns.clear();
|
|
401
|
+
columns.filter((c) => !values.includes(c)).forEach((c) => hiddenColumns.add(c));
|
|
402
|
+
applyColumns();
|
|
403
|
+
applyPinned();
|
|
404
|
+
ensureTabStop();
|
|
405
|
+
emitColumns();
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ----------------------------------------------------------------- errors
|
|
410
|
+
|
|
411
|
+
// Bubbles are moved to <body> on first use: pinned (sticky, z-indexed) cells
|
|
412
|
+
// form stacking contexts that would paint later rows over the bubble.
|
|
413
|
+
const errorElements = new WeakMap<HTMLElement, HTMLElement>();
|
|
414
|
+
const errorElement = (td: HTMLElement) => {
|
|
415
|
+
let el = errorElements.get(td) ?? null;
|
|
416
|
+
if (!el) {
|
|
417
|
+
el = td.querySelector<HTMLElement>('[data-slot="table-cell-error"]');
|
|
418
|
+
if (!el) return null;
|
|
419
|
+
errorElements.set(td, el);
|
|
420
|
+
document.body.append(el);
|
|
421
|
+
}
|
|
422
|
+
return el;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const positionError = (td: HTMLTableCellElement) => {
|
|
426
|
+
const el = errorElement(td);
|
|
427
|
+
if (!el) return;
|
|
428
|
+
const rect = td.getBoundingClientRect();
|
|
429
|
+
el.style.left = `${rect.left + 8}px`;
|
|
430
|
+
el.style.top = `${rect.bottom + 4}px`;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const showError = (td: HTMLTableCellElement, message: string, autoHide = false) => {
|
|
434
|
+
const el = errorElement(td);
|
|
435
|
+
if (!el) return;
|
|
436
|
+
el.textContent = message;
|
|
437
|
+
el.id ||= `table-error-${Math.random().toString(36).slice(2)}`;
|
|
438
|
+
el.hidden = false;
|
|
439
|
+
td.querySelector("[data-table-input]")?.setAttribute("aria-describedby", el.id);
|
|
440
|
+
visibleErrors.add(td);
|
|
441
|
+
positionError(td);
|
|
442
|
+
window.clearTimeout(errorTimers.get(td));
|
|
443
|
+
if (autoHide) errorTimers.set(td, window.setTimeout(() => hideError(td), ERROR_TIMEOUT));
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const hideError = (td: HTMLTableCellElement) => {
|
|
447
|
+
const el = errorElement(td);
|
|
448
|
+
if (el) el.hidden = true;
|
|
449
|
+
td.querySelector("[data-table-input]")?.removeAttribute("aria-describedby");
|
|
450
|
+
visibleErrors.delete(td);
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const repositionErrors = () =>
|
|
454
|
+
visibleErrors.forEach((td) => (td.isConnected ? positionError(td) : hideError(td)));
|
|
455
|
+
root.querySelector('[data-slot="table-container"]')?.addEventListener("scroll", repositionErrors, { passive: true });
|
|
456
|
+
window.addEventListener("scroll", repositionErrors, { passive: true, capture: true });
|
|
457
|
+
window.addEventListener("resize", () => {
|
|
458
|
+
repositionErrors();
|
|
459
|
+
applyPinned();
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------- editing
|
|
463
|
+
|
|
464
|
+
const requestEdit = (td: HTMLTableCellElement, value: EditValue, previous: EditValue): Promise<unknown> => {
|
|
465
|
+
let pending: Promise<unknown> | undefined;
|
|
466
|
+
const accepted = emit<TableEditDetail>("table:edit", {
|
|
467
|
+
rowId: td.closest("tr")?.dataset.rowId ?? null,
|
|
468
|
+
column: td.dataset.column ?? "",
|
|
469
|
+
value,
|
|
470
|
+
previous,
|
|
471
|
+
respond: (result) => {
|
|
472
|
+
pending = Promise.resolve(result);
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
if (!accepted) return Promise.reject(new Error("This change was not accepted."));
|
|
476
|
+
return pending ?? Promise.resolve(undefined);
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const textInput = (td: HTMLElement) => td.querySelector<HTMLInputElement>("input[data-table-input]");
|
|
480
|
+
const confirmButton = (td: HTMLElement) => td.querySelector<HTMLButtonElement>('[data-table-action="confirm"]');
|
|
481
|
+
|
|
482
|
+
const validateText = (td: HTMLTableCellElement, showMessage: boolean) => {
|
|
483
|
+
const input = textInput(td);
|
|
484
|
+
if (!input) return true;
|
|
485
|
+
const valid = input.checkValidity();
|
|
486
|
+
const confirm = confirmButton(td);
|
|
487
|
+
if (confirm) confirm.disabled = !valid;
|
|
488
|
+
if (valid) {
|
|
489
|
+
input.removeAttribute("aria-invalid");
|
|
490
|
+
hideError(td);
|
|
491
|
+
} else if (showMessage) {
|
|
492
|
+
input.setAttribute("aria-invalid", "true");
|
|
493
|
+
showError(td, input.validationMessage);
|
|
494
|
+
}
|
|
495
|
+
return valid;
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const renderTextValue = (td: HTMLElement, value: string) => {
|
|
499
|
+
const target = td.querySelector<HTMLElement>('[data-slot="table-cell-value"]');
|
|
500
|
+
if (!target) return;
|
|
501
|
+
target.textContent = value || td.dataset.placeholder || "";
|
|
502
|
+
target.classList.toggle("text-muted-foreground", !value);
|
|
503
|
+
target.classList.toggle("font-normal", !value);
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const cancelText = (td: HTMLTableCellElement, refocus = true) => {
|
|
507
|
+
const input = textInput(td);
|
|
508
|
+
td.dataset.state = "idle";
|
|
509
|
+
input?.removeAttribute("aria-invalid");
|
|
510
|
+
hideError(td);
|
|
511
|
+
if (activeText === td) activeText = null;
|
|
512
|
+
if (refocus) focusCell(td);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const startText = (td: HTMLTableCellElement) => {
|
|
516
|
+
const input = textInput(td);
|
|
517
|
+
if (!input || td.dataset.state === "saving") return;
|
|
518
|
+
if (activeText && activeText !== td) {
|
|
519
|
+
const other = activeText;
|
|
520
|
+
if (textInput(other)?.value !== (other.dataset.value ?? "")) {
|
|
521
|
+
textInput(other)?.focus();
|
|
522
|
+
announce(`Save or cancel the ${cellLabel(other)} edit first`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
cancelText(other, false);
|
|
526
|
+
}
|
|
527
|
+
activeText = td;
|
|
528
|
+
input.value = td.dataset.value ?? "";
|
|
529
|
+
td.dataset.state = "editing";
|
|
530
|
+
validateText(td, false);
|
|
531
|
+
input.focus();
|
|
532
|
+
input.select();
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const commitText = async (td: HTMLTableCellElement) => {
|
|
536
|
+
const input = textInput(td);
|
|
537
|
+
if (!input || td.dataset.state === "saving") return;
|
|
538
|
+
if (!validateText(td, true)) {
|
|
539
|
+
input.focus();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
const previous = td.dataset.value ?? "";
|
|
543
|
+
const value = input.value;
|
|
544
|
+
if (value === previous) {
|
|
545
|
+
cancelText(td);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
td.dataset.state = "saving";
|
|
549
|
+
td.setAttribute("aria-busy", "true");
|
|
550
|
+
input.readOnly = true;
|
|
551
|
+
try {
|
|
552
|
+
const result = await requestEdit(td, value, previous);
|
|
553
|
+
const saved = typeof result === "string" ? result : value;
|
|
554
|
+
td.dataset.value = saved;
|
|
555
|
+
renderTextValue(td, saved);
|
|
556
|
+
td.dataset.state = "idle";
|
|
557
|
+
activeText = null;
|
|
558
|
+
hideError(td);
|
|
559
|
+
focusCell(td);
|
|
560
|
+
announce(`${cellLabel(td)} saved`);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
td.dataset.state = "error";
|
|
563
|
+
input.setAttribute("aria-invalid", "true");
|
|
564
|
+
const message = errorMessage(error, "Could not save the change.");
|
|
565
|
+
showError(td, message);
|
|
566
|
+
input.focus();
|
|
567
|
+
announce(`${cellLabel(td)} not saved. ${message}`);
|
|
568
|
+
} finally {
|
|
569
|
+
input.readOnly = false;
|
|
570
|
+
td.removeAttribute("aria-busy");
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
// -------------------------------------------------------- choice editors
|
|
575
|
+
|
|
576
|
+
const renderChoice = (td: HTMLTableCellElement, value: EditValue) => {
|
|
577
|
+
const target = td.querySelector<HTMLElement>('[data-slot="table-cell-value"]');
|
|
578
|
+
const template = td.querySelector<HTMLTemplateElement>('template[data-slot="table-cell-templates"]');
|
|
579
|
+
if (!target || !template) return;
|
|
580
|
+
const values = Array.isArray(value) ? value : value ? [value] : [];
|
|
581
|
+
const option = (v: string) =>
|
|
582
|
+
template.content.querySelector<HTMLElement>(`[data-option="${CSS.escape(v)}"]`);
|
|
583
|
+
|
|
584
|
+
if (td.dataset.editor === "combobox") {
|
|
585
|
+
const text = values.map((v) => option(v)?.textContent?.trim() ?? v).join(", ");
|
|
586
|
+
renderTextValue(td, text);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const max = Number(td.dataset.maxVisible ?? 2) || 2;
|
|
591
|
+
const nodes: Node[] = values.slice(0, max).map((v) => {
|
|
592
|
+
const node = option(v)?.cloneNode(true) as HTMLElement | undefined;
|
|
593
|
+
if (node) {
|
|
594
|
+
node.removeAttribute("data-option");
|
|
595
|
+
return node;
|
|
596
|
+
}
|
|
597
|
+
return document.createTextNode(v);
|
|
598
|
+
});
|
|
599
|
+
if (values.length > max) {
|
|
600
|
+
const more = template.content.querySelector("[data-option-overflow]")?.cloneNode(true) as HTMLElement | undefined;
|
|
601
|
+
if (more) {
|
|
602
|
+
more.removeAttribute("data-option-overflow");
|
|
603
|
+
more.textContent = `+${values.length - max}`;
|
|
604
|
+
nodes.push(more);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (nodes.length === 0) {
|
|
608
|
+
const empty = document.createElement("span");
|
|
609
|
+
empty.className = "text-muted-foreground font-normal";
|
|
610
|
+
empty.textContent = td.dataset.placeholder ?? "";
|
|
611
|
+
nodes.push(empty);
|
|
612
|
+
}
|
|
613
|
+
target.replaceChildren(...nodes);
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const commitChoice = async (
|
|
617
|
+
td: HTMLTableCellElement,
|
|
618
|
+
value: EditValue,
|
|
619
|
+
previous: EditValue,
|
|
620
|
+
revert: (value: EditValue) => void,
|
|
621
|
+
) => {
|
|
622
|
+
if (sameValue(value, previous)) return;
|
|
623
|
+
td.dataset.value = serializeValue(td, value);
|
|
624
|
+
renderChoice(td, value);
|
|
625
|
+
td.dataset.state = "saving";
|
|
626
|
+
td.setAttribute("aria-busy", "true");
|
|
627
|
+
hideError(td);
|
|
628
|
+
try {
|
|
629
|
+
await requestEdit(td, value, previous);
|
|
630
|
+
announce(`${cellLabel(td)} saved`);
|
|
631
|
+
} catch (error) {
|
|
632
|
+
td.dataset.value = serializeValue(td, previous);
|
|
633
|
+
renderChoice(td, previous);
|
|
634
|
+
revert(previous);
|
|
635
|
+
const message = errorMessage(error, "Could not save the change.");
|
|
636
|
+
showError(td, message, true);
|
|
637
|
+
announce(`${cellLabel(td)} not saved. ${message}`);
|
|
638
|
+
} finally {
|
|
639
|
+
td.dataset.state = "idle";
|
|
640
|
+
td.removeAttribute("aria-busy");
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const bindMenuCell = (menu: HTMLElement) => {
|
|
645
|
+
const td = menu.closest<HTMLTableCellElement>("td[data-editable]");
|
|
646
|
+
if (!td) return;
|
|
647
|
+
const restore = (value: EditValue) =>
|
|
648
|
+
menu.dispatchEvent(
|
|
649
|
+
new CustomEvent("dropdown-menu:set", {
|
|
650
|
+
detail: Array.isArray(value) ? { values: value, source: "restore" } : { value, source: "restore" },
|
|
651
|
+
}),
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
if (td.dataset.editor === "select") {
|
|
655
|
+
menu.addEventListener("dropdown-menu:value-change", (event) => {
|
|
656
|
+
const { value, previousValue, source } = (
|
|
657
|
+
event as CustomEvent<{ value: string | null; previousValue: string | null; source: string }>
|
|
658
|
+
).detail;
|
|
659
|
+
if (source === "programmatic" || source === "restore") return;
|
|
660
|
+
void commitChoice(td, value ?? "", previousValue ?? "", restore);
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Multiselect: preview while open, commit once when the menu closes.
|
|
666
|
+
let snapshot: string[] | null = null;
|
|
667
|
+
let working: string[] = [];
|
|
668
|
+
menu.addEventListener("dropdown-menu:open-change", (event) => {
|
|
669
|
+
const { open } = (event as CustomEvent<{ open: boolean }>).detail;
|
|
670
|
+
if (open) {
|
|
671
|
+
snapshot = parseValue(td) as string[];
|
|
672
|
+
working = [...snapshot];
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (!snapshot) return;
|
|
676
|
+
const before = snapshot;
|
|
677
|
+
snapshot = null;
|
|
678
|
+
if (sameValue(working, before)) {
|
|
679
|
+
renderChoice(td, before);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
void commitChoice(td, working, before, restore);
|
|
683
|
+
});
|
|
684
|
+
menu.addEventListener("dropdown-menu:values-change", (event) => {
|
|
685
|
+
const { values, source } = (event as CustomEvent<{ values: string[]; source: string }>).detail;
|
|
686
|
+
if (source === "programmatic" || source === "restore" || !snapshot) return;
|
|
687
|
+
working = values;
|
|
688
|
+
renderChoice(td, values);
|
|
689
|
+
});
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
const comboboxOf = (td: HTMLElement) => td.querySelector<HTMLElement>("[data-table-combobox]");
|
|
693
|
+
|
|
694
|
+
const startCombobox = (td: HTMLTableCellElement) => {
|
|
695
|
+
const combobox = comboboxOf(td);
|
|
696
|
+
if (!combobox || td.dataset.state === "saving") return;
|
|
697
|
+
td.dataset.state = "editing";
|
|
698
|
+
const input = combobox.querySelector<HTMLInputElement>('[data-slot="combobox-input"]');
|
|
699
|
+
input?.focus();
|
|
700
|
+
combobox.dispatchEvent(new CustomEvent("combobox:set", { detail: { open: true } }));
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
const bindCombobox = (combobox: HTMLElement) => {
|
|
704
|
+
const td = combobox.closest<HTMLTableCellElement>("td[data-editable]");
|
|
705
|
+
if (!td) return;
|
|
706
|
+
const restore = (value: EditValue) =>
|
|
707
|
+
combobox.dispatchEvent(new CustomEvent("combobox:set", { detail: { value: String(value) || null } }));
|
|
708
|
+
const close = () => {
|
|
709
|
+
if (td.dataset.state !== "editing") return;
|
|
710
|
+
td.dataset.state = "idle";
|
|
711
|
+
restore(td.dataset.value ?? "");
|
|
712
|
+
focusCell(td);
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
combobox.addEventListener("combobox:change", (event) => {
|
|
716
|
+
if (td.dataset.state !== "editing") return;
|
|
717
|
+
const { value } = (event as CustomEvent<{ value: string | null }>).detail;
|
|
718
|
+
const previous = td.dataset.value ?? "";
|
|
719
|
+
td.dataset.state = "idle";
|
|
720
|
+
focusCell(td);
|
|
721
|
+
if (value !== null && value !== previous) void commitChoice(td, value, previous, restore);
|
|
722
|
+
});
|
|
723
|
+
combobox.addEventListener("combobox:open-change", (event) => {
|
|
724
|
+
if ((event as CustomEvent<{ open: boolean }>).detail.open) return;
|
|
725
|
+
// Let a pending `combobox:change` land first.
|
|
726
|
+
window.setTimeout(close, 0);
|
|
727
|
+
});
|
|
728
|
+
combobox.addEventListener("focusout", (event) => {
|
|
729
|
+
const next = (event as FocusEvent).relatedTarget as Node | null;
|
|
730
|
+
if (next && combobox.contains(next)) return;
|
|
731
|
+
window.setTimeout(() => {
|
|
732
|
+
if (!combobox.contains(document.activeElement)) close();
|
|
733
|
+
}, 0);
|
|
734
|
+
});
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
// --------------------------------------------------------------- keyboard
|
|
738
|
+
|
|
739
|
+
const visibleCells = (tr: HTMLTableRowElement) => Array.from(tr.cells).filter((c) => !c.hidden);
|
|
740
|
+
const gridRows = () => allRows().filter((tr) => !tr.hidden && visibleCells(tr).length > 0);
|
|
741
|
+
|
|
742
|
+
function focusCell(cell: HTMLTableCellElement) {
|
|
743
|
+
if (current && current !== cell) current.tabIndex = -1;
|
|
744
|
+
current = cell;
|
|
745
|
+
cell.tabIndex = 0;
|
|
746
|
+
cell.focus();
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function ensureTabStop() {
|
|
750
|
+
const cells = Array.from(table.querySelectorAll<HTMLTableCellElement>("th, td"));
|
|
751
|
+
cells.forEach((cell) => {
|
|
752
|
+
if (cell !== current) cell.tabIndex = -1;
|
|
753
|
+
});
|
|
754
|
+
// Inner controls are reached with Enter/Space, not Tab (ARIA grid pattern).
|
|
755
|
+
table
|
|
756
|
+
.querySelectorAll<HTMLElement>("th button, th input, td button, td input[type=checkbox], td a[href]")
|
|
757
|
+
.forEach((el) => {
|
|
758
|
+
if (!el.closest('[data-slot="table-cell-editor"]')) el.tabIndex = -1;
|
|
759
|
+
});
|
|
760
|
+
if (!current || !table.contains(current) || current.hidden || current.closest("tr")?.hidden) {
|
|
761
|
+
current = gridRows()[0] ? visibleCells(gridRows()[0]!)[0] ?? null : null;
|
|
762
|
+
}
|
|
763
|
+
if (current) current.tabIndex = 0;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const moveFocus = (cell: HTMLTableCellElement, key: string, ctrl: boolean) => {
|
|
767
|
+
const rows = gridRows();
|
|
768
|
+
const tr = cell.closest("tr");
|
|
769
|
+
let rowIndex = tr ? rows.indexOf(tr) : -1;
|
|
770
|
+
if (rowIndex < 0) return;
|
|
771
|
+
let colIndex = visibleCells(tr!).indexOf(cell);
|
|
772
|
+
const lastRow = rows.length - 1;
|
|
773
|
+
switch (key) {
|
|
774
|
+
case "ArrowUp":
|
|
775
|
+
rowIndex = Math.max(0, rowIndex - 1);
|
|
776
|
+
break;
|
|
777
|
+
case "ArrowDown":
|
|
778
|
+
rowIndex = Math.min(lastRow, rowIndex + 1);
|
|
779
|
+
break;
|
|
780
|
+
case "ArrowLeft":
|
|
781
|
+
colIndex = Math.max(0, colIndex - 1);
|
|
782
|
+
break;
|
|
783
|
+
case "ArrowRight":
|
|
784
|
+
colIndex += 1;
|
|
785
|
+
break;
|
|
786
|
+
case "Home":
|
|
787
|
+
colIndex = 0;
|
|
788
|
+
if (ctrl) rowIndex = 0;
|
|
789
|
+
break;
|
|
790
|
+
case "End":
|
|
791
|
+
colIndex = Number.MAX_SAFE_INTEGER;
|
|
792
|
+
if (ctrl) rowIndex = lastRow;
|
|
793
|
+
break;
|
|
794
|
+
case "PageUp":
|
|
795
|
+
rowIndex = Math.max(0, rowIndex - 10);
|
|
796
|
+
break;
|
|
797
|
+
case "PageDown":
|
|
798
|
+
rowIndex = Math.min(lastRow, rowIndex + 10);
|
|
799
|
+
break;
|
|
800
|
+
}
|
|
801
|
+
const target = visibleCells(rows[rowIndex]!);
|
|
802
|
+
const next = target[Math.min(colIndex, target.length - 1)];
|
|
803
|
+
if (next) focusCell(next);
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const activate = (cell: HTMLTableCellElement) => {
|
|
807
|
+
if (cell.matches('td[data-editable][data-editor="text"]')) return startText(cell);
|
|
808
|
+
if (cell.matches('td[data-editable][data-editor="combobox"]')) return startCombobox(cell);
|
|
809
|
+
const control = cell.querySelector<HTMLElement>(
|
|
810
|
+
'[data-slot="table-cell-display"], [data-slot="table-sort-button"], [data-slot="table-row-toggle"], input[type=checkbox], button, a[href]',
|
|
811
|
+
);
|
|
812
|
+
if (!control || control.closest('[data-slot="table-cell-editor"]')) return;
|
|
813
|
+
// Checkboxes toggle in place; menus and buttons take focus so it returns to them.
|
|
814
|
+
if (!control.matches("input[type=checkbox]")) control.focus();
|
|
815
|
+
control.click();
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
const NAV_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"]);
|
|
819
|
+
|
|
820
|
+
// Capture phase so arrow keys on a focused trigger navigate the grid
|
|
821
|
+
// instead of opening its menu.
|
|
822
|
+
table.addEventListener(
|
|
823
|
+
"keydown",
|
|
824
|
+
(event) => {
|
|
825
|
+
const target = event.target as HTMLElement;
|
|
826
|
+
if (isTextEntry(target)) return;
|
|
827
|
+
const cell = target.closest<HTMLTableCellElement>("th, td");
|
|
828
|
+
if (!cell || cell.closest("table") !== table || !NAV_KEYS.has(event.key)) return;
|
|
829
|
+
|
|
830
|
+
const tr = cell.closest("tr");
|
|
831
|
+
if (tr?.hasAttribute("data-folder") && cell.hasAttribute("data-folder-cell")) {
|
|
832
|
+
const expanded = tr.hasAttribute("data-expanded");
|
|
833
|
+
if (event.key === "ArrowRight" && !expanded) {
|
|
834
|
+
event.preventDefault();
|
|
835
|
+
event.stopPropagation();
|
|
836
|
+
return setExpanded(tr, true);
|
|
837
|
+
}
|
|
838
|
+
if (event.key === "ArrowLeft" && expanded) {
|
|
839
|
+
event.preventDefault();
|
|
840
|
+
event.stopPropagation();
|
|
841
|
+
return setExpanded(tr, false);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (event.altKey && cell.tagName === "TH" && cell.querySelector('[data-slot="table-resize-handle"]')) {
|
|
845
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
846
|
+
event.preventDefault();
|
|
847
|
+
event.stopPropagation();
|
|
848
|
+
setColumnWidth(cell, cell.getBoundingClientRect().width + (event.key === "ArrowRight" ? 16 : -16));
|
|
849
|
+
emitColumns();
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
event.preventDefault();
|
|
854
|
+
event.stopPropagation();
|
|
855
|
+
moveFocus(cell, event.key, event.ctrlKey || event.metaKey);
|
|
856
|
+
},
|
|
857
|
+
true,
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
table.addEventListener("keydown", (event) => {
|
|
861
|
+
const target = event.target as HTMLElement;
|
|
862
|
+
const cell = target.closest<HTMLTableCellElement>("th, td");
|
|
863
|
+
if (!cell || cell.closest("table") !== table) return;
|
|
864
|
+
|
|
865
|
+
// Text editor keys.
|
|
866
|
+
if (target.matches("input[data-table-input]")) {
|
|
867
|
+
if (event.key === "Enter") {
|
|
868
|
+
event.preventDefault();
|
|
869
|
+
void commitText(cell);
|
|
870
|
+
} else if (event.key === "Escape") {
|
|
871
|
+
event.preventDefault();
|
|
872
|
+
event.stopPropagation();
|
|
873
|
+
cancelText(cell);
|
|
874
|
+
}
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (isTextEntry(target)) return;
|
|
878
|
+
if (target !== cell) return; // inner controls handle their own Enter/Space
|
|
879
|
+
|
|
880
|
+
if (event.key === "Enter" || event.key === "F2") {
|
|
881
|
+
event.preventDefault();
|
|
882
|
+
activate(cell);
|
|
883
|
+
} else if (event.key === " ") {
|
|
884
|
+
const checkbox = cell.querySelector<HTMLInputElement>("input[type=checkbox]");
|
|
885
|
+
if (checkbox) {
|
|
886
|
+
event.preventDefault();
|
|
887
|
+
checkbox.click();
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
table.addEventListener("focusin", (event) => {
|
|
893
|
+
const cell = (event.target as HTMLElement).closest<HTMLTableCellElement>("th, td");
|
|
894
|
+
if (!cell || cell.closest("table") !== table || cell === current) return;
|
|
895
|
+
if (current) current.tabIndex = -1;
|
|
896
|
+
current = cell;
|
|
897
|
+
cell.tabIndex = 0;
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// ----------------------------------------------------------------- clicks
|
|
901
|
+
|
|
902
|
+
root.addEventListener("click", (event) => {
|
|
903
|
+
const target = event.target as HTMLElement;
|
|
904
|
+
|
|
905
|
+
const action = target.closest<HTMLElement>("[data-table-action]");
|
|
906
|
+
if (action && root.contains(action)) {
|
|
907
|
+
const td = action.closest<HTMLTableCellElement>("td");
|
|
908
|
+
switch (action.dataset.tableAction) {
|
|
909
|
+
case "confirm":
|
|
910
|
+
if (td) void commitText(td);
|
|
911
|
+
return;
|
|
912
|
+
case "cancel":
|
|
913
|
+
if (td) cancelText(td);
|
|
914
|
+
return;
|
|
915
|
+
case "clear-selection":
|
|
916
|
+
clearSelection();
|
|
917
|
+
return;
|
|
918
|
+
case "select-all-results":
|
|
919
|
+
allResults = true;
|
|
920
|
+
selectionChanged();
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const sort = target.closest<HTMLButtonElement>('[data-slot="table-sort-button"]');
|
|
926
|
+
if (sort && table.contains(sort)) {
|
|
927
|
+
const th = sort.closest("th");
|
|
928
|
+
if (th) cycleSort(th);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const toggle = target.closest<HTMLElement>('[data-slot="table-row-toggle"]');
|
|
933
|
+
if (toggle && table.contains(toggle)) {
|
|
934
|
+
const tr = toggle.closest("tr");
|
|
935
|
+
if (tr) setExpanded(tr, !tr.hasAttribute("data-expanded"));
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const display = target.closest<HTMLElement>('[data-slot="table-cell-display"]');
|
|
940
|
+
const td = display?.closest<HTMLTableCellElement>("td[data-editable]");
|
|
941
|
+
if (display && td && table.contains(td)) {
|
|
942
|
+
if (td.dataset.editor === "text") startText(td);
|
|
943
|
+
else if (td.dataset.editor === "combobox") startCombobox(td);
|
|
944
|
+
// select/multiselect: the dropdown trigger opens itself.
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const page = target.closest<HTMLButtonElement>("[data-table-page]");
|
|
948
|
+
if (page && pagination?.contains(page)) goToPage(page.dataset.tablePage ?? "");
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
table.addEventListener("input", (event) => {
|
|
952
|
+
const input = event.target as HTMLElement;
|
|
953
|
+
if (!input.matches("input[data-table-input]")) return;
|
|
954
|
+
const td = input.closest<HTMLTableCellElement>("td");
|
|
955
|
+
if (!td) return;
|
|
956
|
+
if (td.dataset.state === "error") td.dataset.state = "editing";
|
|
957
|
+
validateText(td, true);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
table.addEventListener("change", (event) => {
|
|
961
|
+
const input = event.target as HTMLInputElement;
|
|
962
|
+
const kind = input.dataset?.tableSelect;
|
|
963
|
+
if (!kind) return;
|
|
964
|
+
if (kind === "all") {
|
|
965
|
+
allResults = false;
|
|
966
|
+
selectionRows().forEach((tr) => {
|
|
967
|
+
const box = rowCheckbox(tr)!;
|
|
968
|
+
if (!box.disabled) box.checked = input.checked;
|
|
969
|
+
});
|
|
970
|
+
} else {
|
|
971
|
+
const tr = input.closest("tr");
|
|
972
|
+
if (!input.checked) allResults = false;
|
|
973
|
+
if (tr?.hasAttribute("data-folder")) {
|
|
974
|
+
descendantsOf(tr).forEach((row) => {
|
|
975
|
+
const box = rowCheckbox(row);
|
|
976
|
+
if (box && !box.disabled) box.checked = input.checked;
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
selectionChanged();
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
// ------------------------------------------------------------- pagination
|
|
984
|
+
|
|
985
|
+
const paginationState = () => {
|
|
986
|
+
const page = Number(pagination?.dataset.page ?? 1) || 1;
|
|
987
|
+
const pageSize = Number(pagination?.dataset.pageSize ?? 10) || 10;
|
|
988
|
+
const total = Number(pagination?.dataset.totalCount ?? 0) || 0;
|
|
989
|
+
return { page, pageSize, total, pageCount: Math.max(1, Math.ceil(total / pageSize)) };
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
const renderPagination = () => {
|
|
993
|
+
if (!pagination) return;
|
|
994
|
+
const { page, pageSize, total, pageCount } = paginationState();
|
|
995
|
+
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
|
996
|
+
const to = Math.min(page * pageSize, total);
|
|
997
|
+
setText('[data-slot="table-pagination-summary"]', `Showing ${from}–${to} of ${total}`, pagination);
|
|
998
|
+
setText('[data-slot="table-pagination-status"]', `Page ${page} of ${pageCount}`, pagination);
|
|
999
|
+
pagination.querySelectorAll<HTMLButtonElement>("[data-table-page]").forEach((button) => {
|
|
1000
|
+
const dir = button.dataset.tablePage;
|
|
1001
|
+
button.disabled = dir === "first" || dir === "prev" ? page <= 1 : page >= pageCount;
|
|
1002
|
+
});
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const setPage = (page: number, pageSize: number) => {
|
|
1006
|
+
if (!pagination) return;
|
|
1007
|
+
const accepted = emit("table:page", { page, pageSize });
|
|
1008
|
+
if (!accepted) return;
|
|
1009
|
+
pagination.dataset.page = String(page);
|
|
1010
|
+
pagination.dataset.pageSize = String(pageSize);
|
|
1011
|
+
allResults = false;
|
|
1012
|
+
renderPagination();
|
|
1013
|
+
announce(`Page ${page}`);
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
function goToPage(direction: string) {
|
|
1017
|
+
const { page, pageSize, pageCount } = paginationState();
|
|
1018
|
+
const next =
|
|
1019
|
+
direction === "first" ? 1 : direction === "prev" ? page - 1 : direction === "next" ? page + 1 : pageCount;
|
|
1020
|
+
const clamped = Math.min(pageCount, Math.max(1, next));
|
|
1021
|
+
if (clamped !== page) setPage(clamped, pageSize);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
pagination?.addEventListener("change", (event) => {
|
|
1025
|
+
const select = event.target as HTMLSelectElement;
|
|
1026
|
+
if (!select.matches("[data-table-page-size]")) return;
|
|
1027
|
+
setPage(1, Number(select.value) || 10);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// ---------------------------------------------------------------- inbound
|
|
1031
|
+
|
|
1032
|
+
root.addEventListener("table:set", (event) => {
|
|
1033
|
+
const detail = (event as CustomEvent<TableSetDetail>).detail ?? {};
|
|
1034
|
+
if (detail.loading !== undefined) {
|
|
1035
|
+
root.toggleAttribute("data-loading", detail.loading);
|
|
1036
|
+
table.setAttribute("aria-busy", String(detail.loading));
|
|
1037
|
+
}
|
|
1038
|
+
if (detail.totalCount !== undefined) {
|
|
1039
|
+
root.dataset.totalCount = String(detail.totalCount);
|
|
1040
|
+
if (pagination) pagination.dataset.totalCount = String(detail.totalCount);
|
|
1041
|
+
}
|
|
1042
|
+
if (detail.page !== undefined && pagination) pagination.dataset.page = String(detail.page);
|
|
1043
|
+
renderPagination();
|
|
1044
|
+
syncSelection();
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
// ------------------------------------------------------------------- sync
|
|
1048
|
+
|
|
1049
|
+
const bindWidgets = () => {
|
|
1050
|
+
table.querySelectorAll<HTMLElement>("[data-table-menu]").forEach((menu) => {
|
|
1051
|
+
if (boundWidgets.has(menu)) return;
|
|
1052
|
+
boundWidgets.add(menu);
|
|
1053
|
+
bindMenuCell(menu);
|
|
1054
|
+
});
|
|
1055
|
+
table.querySelectorAll<HTMLElement>("[data-table-combobox]").forEach((combobox) => {
|
|
1056
|
+
if (boundWidgets.has(combobox)) return;
|
|
1057
|
+
boundWidgets.add(combobox);
|
|
1058
|
+
bindCombobox(combobox);
|
|
1059
|
+
});
|
|
1060
|
+
table.querySelectorAll<HTMLElement>('[data-slot="table-resize-handle"]').forEach((handle) => {
|
|
1061
|
+
if (boundWidgets.has(handle)) return;
|
|
1062
|
+
boundWidgets.add(handle);
|
|
1063
|
+
bindResize(handle);
|
|
1064
|
+
});
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
const sync = () => {
|
|
1068
|
+
repositionErrors();
|
|
1069
|
+
bindWidgets();
|
|
1070
|
+
applyTree();
|
|
1071
|
+
applyColumns();
|
|
1072
|
+
applyPinned();
|
|
1073
|
+
syncSortAvailability();
|
|
1074
|
+
syncSelection();
|
|
1075
|
+
ensureTabStop();
|
|
1076
|
+
};
|
|
1077
|
+
|
|
1078
|
+
let scheduled = false;
|
|
1079
|
+
new MutationObserver(() => {
|
|
1080
|
+
if (scheduled) return;
|
|
1081
|
+
scheduled = true;
|
|
1082
|
+
requestAnimationFrame(() => {
|
|
1083
|
+
scheduled = false;
|
|
1084
|
+
sync();
|
|
1085
|
+
});
|
|
1086
|
+
}).observe(table, { childList: true, subtree: true });
|
|
1087
|
+
|
|
1088
|
+
sync();
|
|
1089
|
+
renderPagination();
|
|
1090
|
+
}
|