@svelte-lean/table 0.1.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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/clipboard.d.ts +8 -0
- package/dist/clipboard.js +49 -0
- package/dist/core.d.ts +41 -0
- package/dist/core.js +277 -0
- package/dist/editing.d.ts +16 -0
- package/dist/editing.js +79 -0
- package/dist/export.d.ts +11 -0
- package/dist/export.js +68 -0
- package/dist/grouping.d.ts +5 -0
- package/dist/grouping.js +71 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/messages.d.ts +3 -0
- package/dist/messages.js +72 -0
- package/dist/pivot.d.ts +19 -0
- package/dist/pivot.js +33 -0
- package/dist/server.d.ts +16 -0
- package/dist/server.js +39 -0
- package/dist/state.d.ts +4 -0
- package/dist/state.js +66 -0
- package/dist/style.css +806 -0
- package/dist/table/data-table.d.svelte.ts +41 -0
- package/dist/table/data-table.svelte +1145 -0
- package/dist/table/data-table.svelte.d.ts +41 -0
- package/dist/table/navigation.d.ts +4 -0
- package/dist/table/navigation.js +26 -0
- package/dist/table.svelte.d.ts +92 -0
- package/dist/table.svelte.js +157 -0
- package/dist/types.d.ts +317 -0
- package/dist/types.js +1 -0
- package/package.json +93 -0
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
/**
|
|
3
|
+
* Inline icons: one `<svg>` per use, drawn by the `icon` snippet below. No component instance,
|
|
4
|
+
* no font, no Unicode glyph that depends on the platform font.
|
|
5
|
+
*/
|
|
6
|
+
const ICONS = {
|
|
7
|
+
sort: 'M5 6l3-3 3 3M5 10l3 3 3-3',
|
|
8
|
+
asc: 'M8 13V3M4 7l4-4 4 4',
|
|
9
|
+
desc: 'M8 3v10M4 9l4 4 4-4',
|
|
10
|
+
edit: 'M3 13v-3l7.5-7.5 3 3L6 13z',
|
|
11
|
+
first: 'M7 4L3 8l4 4M13 4L9 8l4 4',
|
|
12
|
+
previous: 'M10 4L6 8l4 4',
|
|
13
|
+
next: 'M6 4l4 4-4 4',
|
|
14
|
+
last: 'M3 4l4 4-4 4M9 4l4 4-4 4',
|
|
15
|
+
expand: 'M6 4l4 4-4 4',
|
|
16
|
+
collapse: 'M4 6l4 4 4-4',
|
|
17
|
+
left: 'M13 8H3M7 4L3 8l4 4',
|
|
18
|
+
right: 'M3 8h10M9 4l4 4-4 4',
|
|
19
|
+
more: 'M3.5 8h.01M8 8h.01M12.5 8h.01'
|
|
20
|
+
} as const;
|
|
21
|
+
type IconName = keyof typeof ICONS;
|
|
22
|
+
/**
|
|
23
|
+
* Widths of the leading control columns. Applications override `--slean-table-selection-width`
|
|
24
|
+
* and `--slean-table-expand-width` in CSS; the fallbacks keep the table correct without
|
|
25
|
+
* `style.css`. Read here, by the `<col>`s and by the pinned-column offsets only.
|
|
26
|
+
*/
|
|
27
|
+
const SELECTION_WIDTH = 'var(--slean-table-selection-width, 44px)';
|
|
28
|
+
const EXPAND_WIDTH = 'var(--slean-table-expand-width, 40px)';
|
|
29
|
+
/** A click on one of these inside a cell belongs to the control, not to the row. */
|
|
30
|
+
const NOT_ROW_CLICK =
|
|
31
|
+
'button,a,input,select,textarea,label,summary,[contenteditable],[role="menuitem"],[data-action],[data-no-row-click]';
|
|
32
|
+
/**
|
|
33
|
+
* Numbered pages around the current one (0-based): the first, the last and the current ±1.
|
|
34
|
+
* `null` marks a gap; a gap of one page shows that page instead.
|
|
35
|
+
*/
|
|
36
|
+
function pageWindow(current: number, count: number): (number | null)[] {
|
|
37
|
+
const pages = [...new Set([0, current - 1, current, current + 1, count - 1])]
|
|
38
|
+
.filter((page) => page >= 0 && page < count)
|
|
39
|
+
.sort((a, b) => a - b);
|
|
40
|
+
const result: (number | null)[] = [];
|
|
41
|
+
let previous = -1;
|
|
42
|
+
for (const page of pages) {
|
|
43
|
+
if (page - previous === 2) result.push(page - 1);
|
|
44
|
+
else if (page - previous > 2) result.push(null);
|
|
45
|
+
result.push(page);
|
|
46
|
+
previous = page;
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<script lang="ts" generics="T">
|
|
53
|
+
import { flushSync, tick, untrack } from 'svelte';
|
|
54
|
+
import type {
|
|
55
|
+
TableProps,
|
|
56
|
+
Column,
|
|
57
|
+
RowId,
|
|
58
|
+
RowNode,
|
|
59
|
+
CellPosition,
|
|
60
|
+
HeaderContext,
|
|
61
|
+
Pin
|
|
62
|
+
} from '../types.js';
|
|
63
|
+
import { createRowModel, valueOf } from '../core.js';
|
|
64
|
+
import { createTable } from '../table.svelte.js';
|
|
65
|
+
import { en } from '../messages.js';
|
|
66
|
+
import { parseEdit } from '../editing.js';
|
|
67
|
+
import { horizontalCell, verticalCell } from './navigation.js';
|
|
68
|
+
let {
|
|
69
|
+
data,
|
|
70
|
+
columns,
|
|
71
|
+
getRowId,
|
|
72
|
+
state: tableState = $bindable({}),
|
|
73
|
+
onStateChange,
|
|
74
|
+
getSubRows,
|
|
75
|
+
getRowModel = createRowModel,
|
|
76
|
+
manual = false,
|
|
77
|
+
rowCount,
|
|
78
|
+
pageCount,
|
|
79
|
+
id,
|
|
80
|
+
label = 'Data table',
|
|
81
|
+
caption,
|
|
82
|
+
title,
|
|
83
|
+
showHeader = true,
|
|
84
|
+
tableLayout = 'fixed',
|
|
85
|
+
searchable = false,
|
|
86
|
+
searchDelay = 0,
|
|
87
|
+
searchText,
|
|
88
|
+
filters = false,
|
|
89
|
+
columnControls = false,
|
|
90
|
+
selection = false,
|
|
91
|
+
isRowSelectable = () => true,
|
|
92
|
+
selectionScope = 'page',
|
|
93
|
+
hideSelectAll = false,
|
|
94
|
+
rowLabel,
|
|
95
|
+
rowDetails,
|
|
96
|
+
expandColumn = false,
|
|
97
|
+
rowExpandable = () => true,
|
|
98
|
+
expandRowByClick = false,
|
|
99
|
+
indentSize = 18,
|
|
100
|
+
onExpand,
|
|
101
|
+
onExpandedRowsChange,
|
|
102
|
+
loading = false,
|
|
103
|
+
fetching = false,
|
|
104
|
+
skeletonRows = 5,
|
|
105
|
+
error,
|
|
106
|
+
onRetry,
|
|
107
|
+
empty,
|
|
108
|
+
toolbar,
|
|
109
|
+
footer,
|
|
110
|
+
summary,
|
|
111
|
+
density = 'comfortable',
|
|
112
|
+
striped = false,
|
|
113
|
+
bordered = false,
|
|
114
|
+
stickyHeader = true,
|
|
115
|
+
height,
|
|
116
|
+
progressive = false,
|
|
117
|
+
pagination = true,
|
|
118
|
+
pageSizeOptions = [10, 20, 50, 100],
|
|
119
|
+
placeholder = '',
|
|
120
|
+
rowHoverable = true,
|
|
121
|
+
dir,
|
|
122
|
+
locale,
|
|
123
|
+
messages,
|
|
124
|
+
onEdit,
|
|
125
|
+
onRowClick,
|
|
126
|
+
onRowDoubleClick,
|
|
127
|
+
onRowContextMenu,
|
|
128
|
+
onScroll,
|
|
129
|
+
onAction,
|
|
130
|
+
rowClass,
|
|
131
|
+
onRowMove,
|
|
132
|
+
cellRange,
|
|
133
|
+
onCellRangeChange,
|
|
134
|
+
class: className = ''
|
|
135
|
+
}: TableProps<T> = $props();
|
|
136
|
+
/**
|
|
137
|
+
* One engine. Props are handed over as getters so the engine follows every prop change.
|
|
138
|
+
* `getSubRows` and `searchText` must stay `undefined` when absent (grouping rejects tree input;
|
|
139
|
+
* search falls back to the columns), hence the property getters instead of wrapper functions.
|
|
140
|
+
*/
|
|
141
|
+
const table = createTable<T>({
|
|
142
|
+
data: () => data,
|
|
143
|
+
columns: () => columns,
|
|
144
|
+
getRowId: (row) => getRowId(row),
|
|
145
|
+
get getSubRows() {
|
|
146
|
+
return getSubRows;
|
|
147
|
+
},
|
|
148
|
+
get searchText() {
|
|
149
|
+
return searchText;
|
|
150
|
+
},
|
|
151
|
+
state: () => tableState,
|
|
152
|
+
onStateChange: (next) => {
|
|
153
|
+
tableState = next;
|
|
154
|
+
onStateChange?.(next);
|
|
155
|
+
},
|
|
156
|
+
getRowModel: (context) => getRowModel(context),
|
|
157
|
+
manual: () => manual,
|
|
158
|
+
rowCount: () => rowCount,
|
|
159
|
+
pageCount: () => pageCount,
|
|
160
|
+
locale: () => locale,
|
|
161
|
+
pagination: () => pagination,
|
|
162
|
+
selection: () => selection,
|
|
163
|
+
isRowSelectable: (row) => isRowSelectable(row),
|
|
164
|
+
selectionScope: () => selectionScope
|
|
165
|
+
});
|
|
166
|
+
const s = $derived(table.state);
|
|
167
|
+
const m = $derived({ ...en, ...messages });
|
|
168
|
+
const cols = $derived(table.visibleColumns);
|
|
169
|
+
const model = $derived(table.model);
|
|
170
|
+
/** The expand toggle has a column of its own when asked for and when anything can expand. */
|
|
171
|
+
const expander = $derived(expandColumn && (!!rowDetails || !!getSubRows));
|
|
172
|
+
/** Every column of a full-width row: the leading control columns and the data columns. */
|
|
173
|
+
const span = $derived(cols.length + (selection ? 1 : 0) + (expander ? 1 : 0));
|
|
174
|
+
let editing = $state<{ id: RowId; column: string; row: T; value: string } | null>(null);
|
|
175
|
+
let editError = $state('');
|
|
176
|
+
let saving = $state(false);
|
|
177
|
+
let dragging = $state<RowId | null>(null);
|
|
178
|
+
let root: HTMLDivElement;
|
|
179
|
+
let viewport: HTMLDivElement;
|
|
180
|
+
let body: HTMLTableSectionElement;
|
|
181
|
+
/** Rendered row nodes by their `data-row-id` text, to resolve the row of an event target. */
|
|
182
|
+
const nodes = $derived(new Map(model.rows.map((n) => [String(n.id), n])));
|
|
183
|
+
/**
|
|
184
|
+
* The row of this table that contains `target`: the `<tr>` whose parent is this `<tbody>`, so
|
|
185
|
+
* a table nested in a detail row never resolves to a row of the outer table or the reverse.
|
|
186
|
+
*/
|
|
187
|
+
function ownRow(target: EventTarget | null): RowNode<T> | undefined {
|
|
188
|
+
let element = target as Element | null;
|
|
189
|
+
while (element && element.parentElement !== body) element = element.parentElement;
|
|
190
|
+
return element ? nodes.get(element.getAttribute('data-row-id') ?? '') : undefined;
|
|
191
|
+
}
|
|
192
|
+
/** This table's own rows and cells, never those of a table nested in a detail row. */
|
|
193
|
+
function ownRows(selector = ''): NodeListOf<HTMLTableRowElement> {
|
|
194
|
+
return body.querySelectorAll<HTMLTableRowElement>(`:scope > tr[data-row-id]${selector}`);
|
|
195
|
+
}
|
|
196
|
+
const rangeBounds = $derived.by((): [number, number, number, number] | null => {
|
|
197
|
+
if (!cellRange) return null;
|
|
198
|
+
const a = model.rows.findIndex((r) => r.id === cellRange.start.row),
|
|
199
|
+
b = model.rows.findIndex((r) => r.id === cellRange.end.row),
|
|
200
|
+
c = cols.findIndex((col) => col.id === cellRange.start.column),
|
|
201
|
+
d = cols.findIndex((col) => col.id === cellRange.end.column);
|
|
202
|
+
return Math.min(a, b, c, d) < 0
|
|
203
|
+
? null
|
|
204
|
+
: [Math.min(a, b), Math.max(a, b), Math.min(c, d), Math.max(c, d)];
|
|
205
|
+
});
|
|
206
|
+
const movable = $derived(
|
|
207
|
+
!!onRowMove &&
|
|
208
|
+
!s.sorting.length &&
|
|
209
|
+
!s.filters.length &&
|
|
210
|
+
!s.search &&
|
|
211
|
+
!getSubRows &&
|
|
212
|
+
getRowModel === createRowModel
|
|
213
|
+
);
|
|
214
|
+
/**
|
|
215
|
+
* Cells are focusable only when the table has cell-level interaction. The body then contributes
|
|
216
|
+
* one Tab stop (APG grid pattern): `activeCell` carries `tabindex="0"`, every other cell `-1`.
|
|
217
|
+
* `current` remembers the last cell the user reached; it falls back to the first rendered data
|
|
218
|
+
* cell when that cell leaves the page (sorting, filtering, pagination, a covering span).
|
|
219
|
+
*/
|
|
220
|
+
const focusableCells = $derived(!!onEdit || !!onCellRangeChange);
|
|
221
|
+
/** Without cell navigation, a clickable row is a Tab stop answering Enter and Space. */
|
|
222
|
+
const focusableRows = $derived(!!onRowClick && !focusableCells);
|
|
223
|
+
let current = $state<CellPosition | null>(null);
|
|
224
|
+
function isRendered(node: RowNode<T>, index: number, col: Column<T>): boolean {
|
|
225
|
+
if (!col.span || node.data === undefined) return true;
|
|
226
|
+
const span = col.span({
|
|
227
|
+
row: node.data,
|
|
228
|
+
value: valueOf(node.data, col),
|
|
229
|
+
rowId: node.id,
|
|
230
|
+
index,
|
|
231
|
+
column: col
|
|
232
|
+
});
|
|
233
|
+
return span?.rows !== 0 && span?.columns !== 0;
|
|
234
|
+
}
|
|
235
|
+
const activeCell = $derived.by((): CellPosition | null => {
|
|
236
|
+
if (!focusableCells) return null;
|
|
237
|
+
const wanted = current;
|
|
238
|
+
if (wanted) {
|
|
239
|
+
const index = model.rows.findIndex((n) => n.id === wanted.row && n.data !== undefined);
|
|
240
|
+
const node = model.rows[index];
|
|
241
|
+
const col = cols.find((c) => c.id === wanted.column);
|
|
242
|
+
if (node && col && isRendered(node, index, col)) return wanted;
|
|
243
|
+
}
|
|
244
|
+
const first = model.rows.find((n) => n.data !== undefined);
|
|
245
|
+
const col = cols[0];
|
|
246
|
+
return first && col ? { row: first.id, column: col.id } : null;
|
|
247
|
+
});
|
|
248
|
+
function cellTabindex(rowId: RowId, column: string): 0 | -1 | undefined {
|
|
249
|
+
if (!focusableCells) return undefined;
|
|
250
|
+
return activeCell?.row === rowId && activeCell.column === column ? 0 : -1;
|
|
251
|
+
}
|
|
252
|
+
function positionOf(cell: HTMLTableCellElement): CellPosition | null {
|
|
253
|
+
const row = nodes.get(cell.dataset.row ?? '')?.id;
|
|
254
|
+
const column = cell.dataset.column;
|
|
255
|
+
return row !== undefined && column ? { row, column } : null;
|
|
256
|
+
}
|
|
257
|
+
function cellElement(position: CellPosition): HTMLTableCellElement | null {
|
|
258
|
+
return body.querySelector<HTMLTableCellElement>(
|
|
259
|
+
`:scope > tr > td[data-row="${CSS.escape(String(position.row))}"][data-column="${CSS.escape(position.column)}"]`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Text direction: the `dir` prop when given, otherwise the nearest `dir` attribute above the
|
|
264
|
+
* root, read at interaction time so no layout and no mirrored state are involved. `dir="auto"`
|
|
265
|
+
* is delegated to the browser through `:dir(rtl)`; where that is unsupported, left-to-right.
|
|
266
|
+
*/
|
|
267
|
+
function isRtl(): boolean {
|
|
268
|
+
if (dir) return dir === 'rtl';
|
|
269
|
+
const value = root?.closest('[dir]')?.getAttribute('dir')?.toLowerCase();
|
|
270
|
+
if (value !== 'auto') return value === 'rtl';
|
|
271
|
+
try {
|
|
272
|
+
return root.matches(':dir(rtl)');
|
|
273
|
+
} catch {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
function toggleExpanded(id: RowId, row?: T, value?: boolean) {
|
|
278
|
+
const was = table.isExpanded(id);
|
|
279
|
+
const open = table.toggleExpanded(id, value);
|
|
280
|
+
if (open === was) return;
|
|
281
|
+
if (row) onExpand?.(open, row);
|
|
282
|
+
onExpandedRowsChange?.(table.state.expanded);
|
|
283
|
+
}
|
|
284
|
+
function expandable(node: { children: unknown[] }, row: T) {
|
|
285
|
+
return node.children.length > 0 || (!!rowDetails && rowExpandable(row));
|
|
286
|
+
}
|
|
287
|
+
/** Accessible name of a row in its controls. */
|
|
288
|
+
function nameOf(node: RowNode<T>, row: T): string {
|
|
289
|
+
return rowLabel ? rowLabel(row) : String(node.id);
|
|
290
|
+
}
|
|
291
|
+
/** Text of a cell without a `cell` snippet; `null` when it is empty (the placeholder shows). */
|
|
292
|
+
function textOf(col: Column<T>, value: unknown, row: T): string | null {
|
|
293
|
+
const text = col.format ? col.format(value, row) : value == null ? '' : String(value);
|
|
294
|
+
return text == null || text === '' ? null : text;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Progressive mounting (`progressive`). The first render is always complete, so server output
|
|
298
|
+
* and hydration agree. Afterwards, a change that brings at least `batch` rows the table has not
|
|
299
|
+
* rendered before (a fetch, a page change) renders the first `batch` rows in the same update and
|
|
300
|
+
* the rest in slices, one macrotask each, sized so a slice takes about `SLICE_MS`. Sorting and
|
|
301
|
+
* filtering rows already on screen renders at once. `shown` is the rendered prefix.
|
|
302
|
+
*/
|
|
303
|
+
const SLICE_MS = 30;
|
|
304
|
+
const batch = $derived(progressive === true ? 100 : progressive || 0);
|
|
305
|
+
let limit = $state(Number.POSITIVE_INFINITY);
|
|
306
|
+
let renderedIds: Set<RowId> | null = null;
|
|
307
|
+
let job = 0;
|
|
308
|
+
const shown = $derived(limit >= model.rows.length ? model.rows : model.rows.slice(0, limit));
|
|
309
|
+
$effect.pre(() => {
|
|
310
|
+
const rows = model.rows;
|
|
311
|
+
const size = batch;
|
|
312
|
+
untrack(() => {
|
|
313
|
+
const previous = renderedIds;
|
|
314
|
+
renderedIds = new Set(rows.map((n) => n.id));
|
|
315
|
+
job++;
|
|
316
|
+
let fresh = 0;
|
|
317
|
+
if (previous && size) for (const n of rows) if (!previous.has(n.id)) fresh++;
|
|
318
|
+
if (!previous || !size || fresh < size) {
|
|
319
|
+
limit = Number.POSITIVE_INFINITY;
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
limit = size;
|
|
323
|
+
continueMount(job, size);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
$effect(() => () => {
|
|
327
|
+
job++;
|
|
328
|
+
});
|
|
329
|
+
function continueMount(id: number, step: number) {
|
|
330
|
+
const channel = new MessageChannel();
|
|
331
|
+
channel.port1.onmessage = () => {
|
|
332
|
+
if (id !== job) return channel.port1.close();
|
|
333
|
+
const start = performance.now();
|
|
334
|
+
limit += step;
|
|
335
|
+
flushSync();
|
|
336
|
+
if (id !== job || limit >= model.rows.length) {
|
|
337
|
+
if (id === job) limit = Number.POSITIVE_INFINITY;
|
|
338
|
+
return channel.port1.close();
|
|
339
|
+
}
|
|
340
|
+
const spent = Math.max(1, performance.now() - start);
|
|
341
|
+
step = Math.min(1000, Math.max(10, Math.round((step * SLICE_MS) / spent)));
|
|
342
|
+
channel.port2.postMessage(null);
|
|
343
|
+
};
|
|
344
|
+
channel.port2.postMessage(null);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Page-sticky header. Without `height` the scroll region only exists to scroll sideways, yet as
|
|
348
|
+
* a scroll container it would also capture the sticky header. While the table fits its width,
|
|
349
|
+
* the region stops being a scroll container (`data-page-sticky`) and the header sticks to the
|
|
350
|
+
* page. One ResizeObserver per table; widths are read only when a size changes.
|
|
351
|
+
*/
|
|
352
|
+
let fits = $state(false);
|
|
353
|
+
$effect(() => {
|
|
354
|
+
if (!stickyHeader || height || typeof ResizeObserver === 'undefined') {
|
|
355
|
+
fits = false;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const table = viewport.firstElementChild as HTMLElement;
|
|
359
|
+
const observer = new ResizeObserver(() => {
|
|
360
|
+
fits = table.offsetWidth <= viewport.clientWidth + 1;
|
|
361
|
+
});
|
|
362
|
+
observer.observe(viewport);
|
|
363
|
+
observer.observe(table);
|
|
364
|
+
return () => observer.disconnect();
|
|
365
|
+
});
|
|
366
|
+
export function scrollTo(config: {
|
|
367
|
+
key?: RowId;
|
|
368
|
+
index?: number;
|
|
369
|
+
top?: number;
|
|
370
|
+
offset?: number;
|
|
371
|
+
align?: ScrollLogicalPosition;
|
|
372
|
+
}) {
|
|
373
|
+
if (!viewport) return;
|
|
374
|
+
if (config.top !== undefined) {
|
|
375
|
+
viewport.scrollTo({ top: config.top, behavior: 'smooth' });
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const row =
|
|
379
|
+
config.key !== undefined
|
|
380
|
+
? ownRows(`[data-row-id="${CSS.escape(String(config.key))}"]`)[0]
|
|
381
|
+
: ownRows()[config.index ?? -1];
|
|
382
|
+
row?.scrollIntoView({ block: config.align ?? 'nearest', behavior: 'smooth' });
|
|
383
|
+
if (row && config.offset) viewport.scrollTop += config.offset;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Sticky offsets of the pinned columns, computed once per column change instead of per cell.
|
|
387
|
+
* Logical insets follow the table's own direction. `edge` marks the last start-pinned and the
|
|
388
|
+
* first end-pinned column, where the stylesheet draws the pin shadow.
|
|
389
|
+
*/
|
|
390
|
+
const pins = $derived.by(() => {
|
|
391
|
+
const result = new Map<string, { pin: Exclude<Pin, false>; style: string; edge?: true }>();
|
|
392
|
+
const lead = [selection && SELECTION_WIDTH, expander && EXPAND_WIDTH].filter(Boolean);
|
|
393
|
+
for (const side of ['left', 'right'] as const) {
|
|
394
|
+
const ordered = side === 'left' ? cols : [...cols].reverse();
|
|
395
|
+
let offset = 0;
|
|
396
|
+
let last: string | undefined;
|
|
397
|
+
for (const col of ordered) {
|
|
398
|
+
if (table.pinOf(col) !== side) continue;
|
|
399
|
+
const inset =
|
|
400
|
+
side === 'left' && lead.length
|
|
401
|
+
? `calc(${offset}px + ${lead.join(' + ')})`
|
|
402
|
+
: `${offset}px`;
|
|
403
|
+
const property = side === 'left' ? 'inset-inline-start' : 'inset-inline-end';
|
|
404
|
+
result.set(col.id, { pin: side, style: `position:sticky;${property}:${inset};z-index:2;` });
|
|
405
|
+
offset += table.columnWidth(col);
|
|
406
|
+
last = col.id;
|
|
407
|
+
}
|
|
408
|
+
const edge = last === undefined ? undefined : result.get(last);
|
|
409
|
+
if (edge) edge.edge = true;
|
|
410
|
+
}
|
|
411
|
+
return result;
|
|
412
|
+
});
|
|
413
|
+
/** Pin attributes of a filter or footer cell. */
|
|
414
|
+
function pinned(col: Column<T>) {
|
|
415
|
+
const pin = pins.get(col.id);
|
|
416
|
+
return pin ? { 'data-pin': pin.pin, 'data-pin-edge': pin.edge, style: pin.style } : {};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* `<col>` width. With `tableLayout="auto"` a column without a declared, resized or pinned width
|
|
420
|
+
* gets none, so the browser sizes it from its content.
|
|
421
|
+
*/
|
|
422
|
+
function colWidth(col: Column<T>): string | undefined {
|
|
423
|
+
return tableLayout === 'auto' &&
|
|
424
|
+
col.width === undefined &&
|
|
425
|
+
s.widths[col.id] === undefined &&
|
|
426
|
+
!pins.has(col.id)
|
|
427
|
+
? undefined
|
|
428
|
+
: `${table.columnWidth(col)}px`;
|
|
429
|
+
}
|
|
430
|
+
function headerContext(column: Column<T>, sort: HeaderContext<T>['sort']): HeaderContext<T> {
|
|
431
|
+
return {
|
|
432
|
+
column,
|
|
433
|
+
sort,
|
|
434
|
+
sorting: s.sorting,
|
|
435
|
+
toggleSort: (multi = false) => table.toggleSort(column.id, multi),
|
|
436
|
+
setSorting: (sorting) => table.setState({ sorting, pageIndex: 0 })
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/** The page status: a manual table without `rowCount` does not know its total. */
|
|
440
|
+
const rowsKnown = $derived(!manual || rowCount !== undefined);
|
|
441
|
+
const headerRowCount = $derived(showHeader ? table.headers.length + (filters ? 1 : 0) : 0);
|
|
442
|
+
/** A manual page is part of a known total: `aria-rowcount` and `aria-rowindex` say where. */
|
|
443
|
+
const ariaRowCount = $derived(
|
|
444
|
+
manual && rowCount !== undefined ? rowCount + headerRowCount : undefined
|
|
445
|
+
);
|
|
446
|
+
const pageSizes = $derived([...new Set([...pageSizeOptions, s.pageSize])]);
|
|
447
|
+
const skeleton = $derived(
|
|
448
|
+
loading && !model.rows.length
|
|
449
|
+
? Array.from({ length: Math.max(0, skeletonRows) }, (_, i) => i)
|
|
450
|
+
: []
|
|
451
|
+
);
|
|
452
|
+
const emptyContext = $derived({
|
|
453
|
+
search: s.search,
|
|
454
|
+
filtered: s.filters.some(
|
|
455
|
+
(f) =>
|
|
456
|
+
f.operator === 'empty' ||
|
|
457
|
+
(f.value != null && f.value !== '' && !(Array.isArray(f.value) && !f.value.length))
|
|
458
|
+
),
|
|
459
|
+
loading
|
|
460
|
+
});
|
|
461
|
+
/**
|
|
462
|
+
* `searchDelay`: the search box and the filter inputs write the state after the last keystroke.
|
|
463
|
+
* Clearing a field applies at once. Timers exist only between a keystroke and its write.
|
|
464
|
+
*/
|
|
465
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
466
|
+
function delayed(key: string, value: string, apply: (value: string) => void) {
|
|
467
|
+
clearTimeout(timers.get(key));
|
|
468
|
+
timers.delete(key);
|
|
469
|
+
if (searchDelay <= 0 || !value) {
|
|
470
|
+
apply(value);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
timers.set(
|
|
474
|
+
key,
|
|
475
|
+
setTimeout(() => {
|
|
476
|
+
timers.delete(key);
|
|
477
|
+
apply(value);
|
|
478
|
+
}, searchDelay)
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
$effect(() => () => {
|
|
482
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
483
|
+
timers.clear();
|
|
484
|
+
});
|
|
485
|
+
function resize(event: PointerEvent, col: Column<T>) {
|
|
486
|
+
const target = event.currentTarget as HTMLElement;
|
|
487
|
+
target.setPointerCapture(event.pointerId);
|
|
488
|
+
const start = event.clientX,
|
|
489
|
+
width = table.columnWidth(col),
|
|
490
|
+
sign = isRtl() ? -1 : 1;
|
|
491
|
+
const move = (e: PointerEvent) =>
|
|
492
|
+
table.resizeColumn(col.id, width + (e.clientX - start) * sign);
|
|
493
|
+
const end = () => {
|
|
494
|
+
target.removeEventListener('pointermove', move);
|
|
495
|
+
target.removeEventListener('pointerup', end);
|
|
496
|
+
target.removeEventListener('pointercancel', end);
|
|
497
|
+
};
|
|
498
|
+
target.addEventListener('pointermove', move);
|
|
499
|
+
target.addEventListener('pointerup', end);
|
|
500
|
+
target.addEventListener('pointercancel', end);
|
|
501
|
+
}
|
|
502
|
+
function editable(row: T, col: Column<T>): boolean {
|
|
503
|
+
return !!onEdit && !!(typeof col.editable === 'function' ? col.editable(row) : col.editable);
|
|
504
|
+
}
|
|
505
|
+
async function startEdit(row: T, col: Column<T>) {
|
|
506
|
+
if (saving || !editable(row, col)) return;
|
|
507
|
+
const id = getRowId(row);
|
|
508
|
+
current = { row: id, column: col.id };
|
|
509
|
+
editing = { id, column: col.id, row, value: String(valueOf(row, col) ?? '') };
|
|
510
|
+
editError = '';
|
|
511
|
+
await tick();
|
|
512
|
+
body
|
|
513
|
+
.querySelector<HTMLInputElement | HTMLSelectElement>(
|
|
514
|
+
':scope > tr > td > .slean-table-editor > :is(input, select)'
|
|
515
|
+
)
|
|
516
|
+
?.focus();
|
|
517
|
+
}
|
|
518
|
+
/** Closes the editor and returns focus to the cell that opened it. */
|
|
519
|
+
async function endEdit() {
|
|
520
|
+
const position = editing ? { row: editing.id, column: editing.column } : null;
|
|
521
|
+
editing = null;
|
|
522
|
+
editError = '';
|
|
523
|
+
if (!position) return;
|
|
524
|
+
await tick();
|
|
525
|
+
cellElement(position)?.focus();
|
|
526
|
+
}
|
|
527
|
+
async function save(col: Column<T>) {
|
|
528
|
+
if (!editing || saving) return;
|
|
529
|
+
try {
|
|
530
|
+
const current = editing,
|
|
531
|
+
value = parseEdit(col, current.value, current.row);
|
|
532
|
+
const problem = col.validate?.(value, current.row);
|
|
533
|
+
if (problem) {
|
|
534
|
+
editError = problem;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
saving = true;
|
|
538
|
+
await onEdit?.({
|
|
539
|
+
row: current.row,
|
|
540
|
+
rowId: current.id,
|
|
541
|
+
columnId: col.id,
|
|
542
|
+
previous: valueOf(current.row, col),
|
|
543
|
+
value
|
|
544
|
+
});
|
|
545
|
+
await endEdit();
|
|
546
|
+
} catch (e) {
|
|
547
|
+
editError = e instanceof Error ? e.message : String(e);
|
|
548
|
+
} finally {
|
|
549
|
+
saving = false;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
function editorKey(e: KeyboardEvent, col: Column<T>) {
|
|
553
|
+
if (e.key === 'Enter') {
|
|
554
|
+
e.preventDefault();
|
|
555
|
+
save(col);
|
|
556
|
+
} else if (e.key === 'Escape' && !saving) {
|
|
557
|
+
e.preventDefault();
|
|
558
|
+
endEdit();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function cellKey(e: KeyboardEvent, row: T, col: Column<T>) {
|
|
562
|
+
if (e.target !== e.currentTarget) return;
|
|
563
|
+
if (e.key === 'Enter' || e.key === 'F2') {
|
|
564
|
+
e.preventDefault();
|
|
565
|
+
startEdit(row, col);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const cell = e.currentTarget as HTMLTableCellElement;
|
|
569
|
+
const rtl = isRtl();
|
|
570
|
+
const horizontal =
|
|
571
|
+
e.key === 'ArrowRight' ? (rtl ? -1 : 1) : e.key === 'ArrowLeft' ? (rtl ? 1 : -1) : 0;
|
|
572
|
+
const vertical = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : 0;
|
|
573
|
+
if (!horizontal && !vertical) return;
|
|
574
|
+
e.preventDefault();
|
|
575
|
+
const next = horizontal
|
|
576
|
+
? horizontalCell(cell, horizontal)
|
|
577
|
+
: verticalCell(cell, vertical as 1 | -1);
|
|
578
|
+
if (!next) return;
|
|
579
|
+
const target = positionOf(next);
|
|
580
|
+
if (target) current = target;
|
|
581
|
+
next.focus();
|
|
582
|
+
if (e.shiftKey && onCellRangeChange && target) {
|
|
583
|
+
onCellRangeChange({
|
|
584
|
+
start: cellRange?.start ?? { row: getRowId(row), column: col.id },
|
|
585
|
+
end: target
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/** Enter and Space on a focused clickable row, delegated to the body: no listener per row. */
|
|
590
|
+
function rowKey(e: KeyboardEvent) {
|
|
591
|
+
if (!focusableRows || (e.key !== 'Enter' && e.key !== ' ')) return;
|
|
592
|
+
const tr = e.target as HTMLElement;
|
|
593
|
+
if (tr.parentElement !== e.currentTarget) return;
|
|
594
|
+
const node = nodes.get(tr.getAttribute('data-row-id') ?? '');
|
|
595
|
+
if (node?.data === undefined) return;
|
|
596
|
+
e.preventDefault();
|
|
597
|
+
if (expandRowByClick && expandable(node, node.data)) toggleExpanded(node.id, node.data);
|
|
598
|
+
onRowClick?.(node.data, e, model.rows.indexOf(node));
|
|
599
|
+
}
|
|
600
|
+
</script>
|
|
601
|
+
|
|
602
|
+
{#snippet icon(name: IconName)}<svg
|
|
603
|
+
class="slean-table-icon"
|
|
604
|
+
viewBox="0 0 16 16"
|
|
605
|
+
width="16"
|
|
606
|
+
height="16"
|
|
607
|
+
aria-hidden="true"
|
|
608
|
+
focusable="false"><path d={ICONS[name]} /></svg
|
|
609
|
+
>{/snippet}
|
|
610
|
+
|
|
611
|
+
<!-- Empty cells of the leading control columns, for rows that hold no controls. -->
|
|
612
|
+
{#snippet leading()}{#if selection}<td class="slean-table-selection"></td>{/if}{#if expander}<td
|
|
613
|
+
class="slean-table-expander"
|
|
614
|
+
style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
|
|
615
|
+
></td>{/if}{/snippet}
|
|
616
|
+
|
|
617
|
+
<!-- `dir` is spread so an absent prop leaves no attribute: for a literal dir={…} Svelte re-assigns
|
|
618
|
+
el.dir on every update (a Chromium workaround), which writes dir="" onto the root. -->
|
|
619
|
+
<div
|
|
620
|
+
bind:this={root}
|
|
621
|
+
{id}
|
|
622
|
+
class="slean-table {className}"
|
|
623
|
+
data-density={density}
|
|
624
|
+
data-striped={striped}
|
|
625
|
+
data-bordered={bordered}
|
|
626
|
+
data-hoverable={rowHoverable}
|
|
627
|
+
data-fetching={fetching || undefined}
|
|
628
|
+
{...dir ? { dir } : {}}
|
|
629
|
+
>
|
|
630
|
+
{#if title}<div class="slean-table-title">{@render title(model)}</div>{/if}
|
|
631
|
+
<!-- The toolbar exists only when something goes in it: an empty flex row would still paint its
|
|
632
|
+
padding as a blank band above the header row (Svelte keeps a text node between the blocks,
|
|
633
|
+
so `:empty` cannot hide it). -->
|
|
634
|
+
{#if searchable || toolbar || selection || columnControls}<div class="slean-table-toolbar">
|
|
635
|
+
{#if searchable}<input
|
|
636
|
+
class="slean-table-search slean-table-input"
|
|
637
|
+
type="search"
|
|
638
|
+
aria-label={m.search}
|
|
639
|
+
placeholder={m.search}
|
|
640
|
+
value={s.search}
|
|
641
|
+
oninput={(e) => delayed('search', e.currentTarget.value, (v) => table.setSearch(v))}
|
|
642
|
+
/>{/if}
|
|
643
|
+
{#if toolbar}{@render toolbar(model)}{/if}
|
|
644
|
+
{#if selection}<span class="slean-table-selected" aria-live="polite"
|
|
645
|
+
>{m.selected(s.selection.length)}</span
|
|
646
|
+
>{/if}
|
|
647
|
+
{#if columnControls}<details class="slean-table-columns">
|
|
648
|
+
<summary>{m.columns}</summary>
|
|
649
|
+
<div class="slean-table-column-panel">
|
|
650
|
+
{#each table.leafColumns as col (col.id)}<div>
|
|
651
|
+
<label
|
|
652
|
+
><input
|
|
653
|
+
type="checkbox"
|
|
654
|
+
class="slean-table-check"
|
|
655
|
+
data-slean="checkbox"
|
|
656
|
+
checked={!s.hidden.includes(col.id)}
|
|
657
|
+
disabled={col.hideable === false ||
|
|
658
|
+
(cols.length === 1 && !s.hidden.includes(col.id))}
|
|
659
|
+
onchange={() => table.toggleColumn(col.id)}
|
|
660
|
+
/>{col.header}</label
|
|
661
|
+
>
|
|
662
|
+
<button
|
|
663
|
+
type="button"
|
|
664
|
+
class="slean-table-button"
|
|
665
|
+
aria-label={`${m.moveLeft}: ${col.header}`}
|
|
666
|
+
onclick={() => table.moveColumn(col.id, -1)}>{@render icon('left')}</button
|
|
667
|
+
><button
|
|
668
|
+
type="button"
|
|
669
|
+
class="slean-table-button"
|
|
670
|
+
aria-label={`${m.moveRight}: ${col.header}`}
|
|
671
|
+
onclick={() => table.moveColumn(col.id, 1)}>{@render icon('right')}</button
|
|
672
|
+
>
|
|
673
|
+
<select
|
|
674
|
+
class="slean-table-input"
|
|
675
|
+
aria-label={`${m.pin}: ${col.header}`}
|
|
676
|
+
value={table.pinOf(col) ?? ''}
|
|
677
|
+
onchange={(e) =>
|
|
678
|
+
table.pin(
|
|
679
|
+
col.id,
|
|
680
|
+
e.currentTarget.value === 'left'
|
|
681
|
+
? 'left'
|
|
682
|
+
: e.currentTarget.value === 'right'
|
|
683
|
+
? 'right'
|
|
684
|
+
: false
|
|
685
|
+
)}
|
|
686
|
+
><option value="">{m.unpin}</option><option value="left">{m.pinLeft}</option
|
|
687
|
+
><option value="right">{m.pinRight}</option></select
|
|
688
|
+
>
|
|
689
|
+
</div>{/each}
|
|
690
|
+
</div>
|
|
691
|
+
</details>{/if}
|
|
692
|
+
</div>{/if}
|
|
693
|
+
{#if error}<div class="slean-table-error" role="alert">
|
|
694
|
+
{error}{#if onRetry}<button type="button" class="slean-table-button" onclick={onRetry}
|
|
695
|
+
>{m.retry}</button
|
|
696
|
+
>{/if}
|
|
697
|
+
</div>{/if}
|
|
698
|
+
{#if loading || fetching}<div
|
|
699
|
+
class="slean-table-status slean-table-visually-hidden"
|
|
700
|
+
role="status"
|
|
701
|
+
>
|
|
702
|
+
{m.loading}
|
|
703
|
+
</div>{/if}
|
|
704
|
+
<!-- A scroll container with a height limit is a Tab stop so keyboard users can scroll it when
|
|
705
|
+
no cell or control inside is focusable (axe: scrollable-region-focusable). -->
|
|
706
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
707
|
+
<div
|
|
708
|
+
bind:this={viewport}
|
|
709
|
+
class="slean-table-scroll"
|
|
710
|
+
data-page-sticky={fits || undefined}
|
|
711
|
+
style:max-height={height}
|
|
712
|
+
tabindex={height ? 0 : undefined}
|
|
713
|
+
role="region"
|
|
714
|
+
aria-label={label}
|
|
715
|
+
onscroll={onScroll}
|
|
716
|
+
>
|
|
717
|
+
<table
|
|
718
|
+
aria-label={label}
|
|
719
|
+
aria-busy={loading || fetching}
|
|
720
|
+
aria-rowcount={ariaRowCount}
|
|
721
|
+
class:slean-table-sticky={stickyHeader}
|
|
722
|
+
style:table-layout={tableLayout}
|
|
723
|
+
>
|
|
724
|
+
{#if caption}<caption>{caption}</caption>{/if}
|
|
725
|
+
<colgroup
|
|
726
|
+
>{#if selection}<col style="width:{SELECTION_WIDTH}" />{/if}{#if expander}<col
|
|
727
|
+
style="width:{EXPAND_WIDTH}"
|
|
728
|
+
/>{/if}{#each cols as col (col.id)}<col
|
|
729
|
+
data-column={col.id}
|
|
730
|
+
style:width={colWidth(col)}
|
|
731
|
+
/>{/each}</colgroup
|
|
732
|
+
>
|
|
733
|
+
{#if showHeader}<thead>
|
|
734
|
+
{#each table.headers as row, level}<tr
|
|
735
|
+
aria-rowindex={ariaRowCount === undefined ? undefined : level + 1}
|
|
736
|
+
>
|
|
737
|
+
{#if selection && level === 0}<th
|
|
738
|
+
scope="col"
|
|
739
|
+
rowspan={table.headers.length}
|
|
740
|
+
class="slean-table-selection"
|
|
741
|
+
>{#if selection === 'multiple' && !hideSelectAll}<input
|
|
742
|
+
type="checkbox"
|
|
743
|
+
class="slean-table-check"
|
|
744
|
+
data-slean="checkbox"
|
|
745
|
+
aria-label={m.selectAll}
|
|
746
|
+
checked={table.allSelected}
|
|
747
|
+
indeterminate={table.someSelected && !table.allSelected}
|
|
748
|
+
disabled={!table.selectable.length}
|
|
749
|
+
onchange={() => table.selectAll()}
|
|
750
|
+
/>{:else}<span class="slean-table-visually-hidden">{m.selectionHeader}</span
|
|
751
|
+
>{/if}</th
|
|
752
|
+
>{/if}
|
|
753
|
+
{#if expander && level === 0}<th
|
|
754
|
+
scope="col"
|
|
755
|
+
rowspan={table.headers.length}
|
|
756
|
+
class="slean-table-expander"
|
|
757
|
+
style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
|
|
758
|
+
><span class="slean-table-visually-hidden">{m.expandHeader}</span></th
|
|
759
|
+
>{/if}
|
|
760
|
+
{#each row as h (h.key)}{@const col = h.column}{@const sort =
|
|
761
|
+
table.sortOf(col)}{@const pin = h.colSpan === 1 ? pins.get(col.id) : undefined}<th
|
|
762
|
+
scope={h.colSpan > 1 ? 'colgroup' : 'col'}
|
|
763
|
+
colspan={h.colSpan}
|
|
764
|
+
rowspan={h.rowSpan}
|
|
765
|
+
class={col.headerClass}
|
|
766
|
+
data-column={col.id}
|
|
767
|
+
data-align={h.colSpan === 1 && col.align !== 'start' ? col.align : undefined}
|
|
768
|
+
data-pin={pin?.pin}
|
|
769
|
+
data-pin-edge={pin?.edge}
|
|
770
|
+
style={pin?.style ?? ''}
|
|
771
|
+
aria-sort={sort ? (sort.desc ? 'descending' : 'ascending') : undefined}
|
|
772
|
+
>
|
|
773
|
+
{#if col.children?.length}{col.header}{:else}<div class="slean-table-heading">
|
|
774
|
+
{#if col.sortable !== false}<button
|
|
775
|
+
type="button"
|
|
776
|
+
class="slean-table-sort"
|
|
777
|
+
onclick={(e) => table.toggleSort(col.id, e.shiftKey)}
|
|
778
|
+
>{#if col.headerCell}{@render col.headerCell(
|
|
779
|
+
headerContext(col, sort)
|
|
780
|
+
)}{:else}{col.header}{/if}<span class="slean-table-sort-icon"
|
|
781
|
+
>{@render icon(
|
|
782
|
+
sort ? (sort.desc ? 'desc' : 'asc') : 'sort'
|
|
783
|
+
)}{#if sort && s.sorting.length > 1}<span
|
|
784
|
+
class="slean-table-visually-hidden">{` ${m.sortOrder} `}</span
|
|
785
|
+
>{sort.index + 1}{/if}</span
|
|
786
|
+
></button
|
|
787
|
+
>{:else if col.headerCell}{@render col.headerCell(
|
|
788
|
+
headerContext(col, sort)
|
|
789
|
+
)}{:else}{col.header}{/if}
|
|
790
|
+
{#if col.resizable !== false}<button
|
|
791
|
+
type="button"
|
|
792
|
+
class="slean-table-resize"
|
|
793
|
+
aria-label={`${m.resize}: ${col.header}`}
|
|
794
|
+
onpointerdown={(e) => resize(e, col)}
|
|
795
|
+
onkeydown={(e) => {
|
|
796
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
797
|
+
e.preventDefault();
|
|
798
|
+
table.resizeColumn(
|
|
799
|
+
col.id,
|
|
800
|
+
table.columnWidth(col) + (e.key === 'ArrowRight' ? 10 : -10)
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
}}
|
|
804
|
+
></button>{/if}
|
|
805
|
+
</div>{/if}
|
|
806
|
+
</th>{/each}
|
|
807
|
+
</tr>{/each}
|
|
808
|
+
<!-- Filter cells are not headers: <td> in <thead>, so a column without a filter is
|
|
809
|
+
not an empty header cell (axe: empty-table-header). -->
|
|
810
|
+
{#if filters}<tr
|
|
811
|
+
class="slean-table-filters"
|
|
812
|
+
aria-rowindex={ariaRowCount === undefined ? undefined : table.headers.length + 1}
|
|
813
|
+
>{@render leading()}{#each cols as col (col.id)}<td {...pinned(col)}
|
|
814
|
+
>{#if col.filterable !== false}<input
|
|
815
|
+
class="slean-table-filter slean-table-input"
|
|
816
|
+
aria-label={m.filter(col.header)}
|
|
817
|
+
placeholder={col.header}
|
|
818
|
+
value={String(s.filters.find((f) => f.id === col.id)?.value ?? '')}
|
|
819
|
+
oninput={(e) =>
|
|
820
|
+
delayed(`filter:${col.id}`, e.currentTarget.value, (v) =>
|
|
821
|
+
table.setFilter(col.id, v)
|
|
822
|
+
)}
|
|
823
|
+
/>{/if}</td
|
|
824
|
+
>{/each}</tr
|
|
825
|
+
>{/if}
|
|
826
|
+
</thead>{/if}
|
|
827
|
+
<!-- Row keys and drags are delegated to the body; the focusable rows are the targets. -->
|
|
828
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
829
|
+
<tbody
|
|
830
|
+
bind:this={body}
|
|
831
|
+
onkeydown={rowKey}
|
|
832
|
+
ondragstart={(e) => {
|
|
833
|
+
const id = ownRow(e.target)?.id;
|
|
834
|
+
if (movable && id !== undefined) dragging = id;
|
|
835
|
+
}}
|
|
836
|
+
ondragover={(e) => {
|
|
837
|
+
if (movable) e.preventDefault();
|
|
838
|
+
}}
|
|
839
|
+
ondrop={(e) => {
|
|
840
|
+
if (!movable) return;
|
|
841
|
+
e.preventDefault();
|
|
842
|
+
const id = ownRow(e.target)?.id;
|
|
843
|
+
if (dragging !== null && id !== undefined) onRowMove?.(dragging, id);
|
|
844
|
+
dragging = null;
|
|
845
|
+
}}
|
|
846
|
+
>
|
|
847
|
+
{#each skeleton as index (index)}<tr class="slean-table-skeleton-row" aria-hidden="true"
|
|
848
|
+
>{@render leading()}{#each cols as col (col.id)}<td
|
|
849
|
+
><span class="slean-table-skeleton"></span></td
|
|
850
|
+
>{/each}</tr
|
|
851
|
+
>{/each}
|
|
852
|
+
{#if !model.rows.length && !skeleton.length}<tr
|
|
853
|
+
><td colspan={Math.max(1, span)} class="slean-table-empty"
|
|
854
|
+
>{#if empty}{@render empty(emptyContext)}{:else}{loading
|
|
855
|
+
? m.loading
|
|
856
|
+
: m.empty}{/if}</td
|
|
857
|
+
></tr
|
|
858
|
+
>{/if}
|
|
859
|
+
{#each shown as node, index (node.id)}
|
|
860
|
+
{#if node.kind === 'group'}<tr class="slean-table-group"
|
|
861
|
+
><th colspan={span} scope="row"
|
|
862
|
+
><button
|
|
863
|
+
type="button"
|
|
864
|
+
class="slean-table-group-toggle"
|
|
865
|
+
aria-expanded={table.isExpanded(node.id)}
|
|
866
|
+
onclick={() => toggleExpanded(node.id)}
|
|
867
|
+
>{@render icon(table.isExpanded(node.id) ? 'collapse' : 'expand')}
|
|
868
|
+
{node.label} <span>({node.count})</span></button
|
|
869
|
+
>{#each cols.filter((c) => c.aggregate) as col (col.id)}<span
|
|
870
|
+
class="slean-table-aggregate"
|
|
871
|
+
>{col.header}: {String(node.aggregates?.[col.id] ?? '')}</span
|
|
872
|
+
>{/each}</th
|
|
873
|
+
></tr
|
|
874
|
+
>
|
|
875
|
+
{:else if node.data !== undefined}{@const row = node.data}{@const open = table.isExpanded(
|
|
876
|
+
node.id
|
|
877
|
+
)}
|
|
878
|
+
<tr
|
|
879
|
+
class={rowClass?.(row, index) ?? ''}
|
|
880
|
+
data-row-id={String(node.id)}
|
|
881
|
+
data-selected={table.isSelected(node.id)}
|
|
882
|
+
data-clickable={onRowClick ? true : undefined}
|
|
883
|
+
aria-rowindex={ariaRowCount === undefined
|
|
884
|
+
? undefined
|
|
885
|
+
: headerRowCount + model.pageIndex * s.pageSize + index + 1}
|
|
886
|
+
tabindex={focusableRows ? 0 : undefined}
|
|
887
|
+
draggable={movable}
|
|
888
|
+
ondblclick={(event) => onRowDoubleClick?.(row, event)}
|
|
889
|
+
oncontextmenu={(event) => onRowContextMenu?.(row, event)}
|
|
890
|
+
>
|
|
891
|
+
<!-- Two static branches: `data-slean` stays a static marker for build-time
|
|
892
|
+
discovery (Tier 0, no runtime); a radio is not the checkbox primitive. -->
|
|
893
|
+
{#if selection === 'single'}<td class="slean-table-selection"
|
|
894
|
+
><input
|
|
895
|
+
type="radio"
|
|
896
|
+
class="slean-table-check"
|
|
897
|
+
name={id ? `${id}-selection` : undefined}
|
|
898
|
+
aria-label={`${m.selectRow}: ${nameOf(node, row)}`}
|
|
899
|
+
checked={table.isSelected(node.id)}
|
|
900
|
+
disabled={!isRowSelectable(row)}
|
|
901
|
+
onchange={() => table.toggleSelection(node.id)}
|
|
902
|
+
/></td
|
|
903
|
+
>{:else if selection}<td class="slean-table-selection"
|
|
904
|
+
><input
|
|
905
|
+
type="checkbox"
|
|
906
|
+
class="slean-table-check"
|
|
907
|
+
data-slean="checkbox"
|
|
908
|
+
aria-label={`${m.selectRow}: ${nameOf(node, row)}`}
|
|
909
|
+
checked={table.isSelected(node.id)}
|
|
910
|
+
disabled={!isRowSelectable(row)}
|
|
911
|
+
onchange={() => table.toggleSelection(node.id)}
|
|
912
|
+
/></td
|
|
913
|
+
>{/if}
|
|
914
|
+
{#if expander}<td
|
|
915
|
+
class="slean-table-expander"
|
|
916
|
+
style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
|
|
917
|
+
>{#if expandable(node, row)}<button
|
|
918
|
+
type="button"
|
|
919
|
+
class="slean-table-button slean-table-expand"
|
|
920
|
+
aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
|
|
921
|
+
aria-expanded={open}
|
|
922
|
+
onclick={() => toggleExpanded(node.id, row)}
|
|
923
|
+
>{@render icon(open ? 'collapse' : 'expand')}</button
|
|
924
|
+
>{/if}</td
|
|
925
|
+
>{/if}
|
|
926
|
+
{#each cols as col, ci (col.id)}{@const value = valueOf(row, col)}{@const context = {
|
|
927
|
+
row,
|
|
928
|
+
value,
|
|
929
|
+
rowId: node.id,
|
|
930
|
+
index,
|
|
931
|
+
column: col
|
|
932
|
+
}}{@const span = col.span?.(context)}{@const pin = pins.get(col.id)}
|
|
933
|
+
{#if span?.rows !== 0 && span?.columns !== 0}<td
|
|
934
|
+
data-range={rangeBounds &&
|
|
935
|
+
index >= rangeBounds[0] &&
|
|
936
|
+
index <= rangeBounds[1] &&
|
|
937
|
+
ci >= rangeBounds[2] &&
|
|
938
|
+
ci <= rangeBounds[3]
|
|
939
|
+
? true
|
|
940
|
+
: undefined}
|
|
941
|
+
data-cell
|
|
942
|
+
data-row={String(node.id)}
|
|
943
|
+
data-column={col.id}
|
|
944
|
+
data-align={col.align !== 'start' ? col.align : undefined}
|
|
945
|
+
data-pin={pin?.pin}
|
|
946
|
+
data-pin-edge={pin?.edge}
|
|
947
|
+
class={typeof col.class === 'function' ? col.class(row) : col.class}
|
|
948
|
+
class:slean-table-ellipsis={!!col.ellipsis}
|
|
949
|
+
title={col.ellipsis &&
|
|
950
|
+
(typeof col.ellipsis === 'boolean' || col.ellipsis.showTitle !== false)
|
|
951
|
+
? String(value ?? '')
|
|
952
|
+
: undefined}
|
|
953
|
+
style={`${pin?.style ?? ''}text-align:${col.align ?? 'start'};`}
|
|
954
|
+
rowspan={span?.rows}
|
|
955
|
+
colspan={span?.columns}
|
|
956
|
+
tabindex={cellTabindex(node.id, col.id)}
|
|
957
|
+
onkeydown={(e) => cellKey(e, row, col)}
|
|
958
|
+
ondblclick={() => startEdit(row, col)}
|
|
959
|
+
onclick={(e) => {
|
|
960
|
+
if (focusableCells) current = { row: node.id, column: col.id };
|
|
961
|
+
const target = e.target as HTMLElement;
|
|
962
|
+
const action = target.closest<HTMLElement>('[data-action]');
|
|
963
|
+
if (action && e.currentTarget.contains(action)) {
|
|
964
|
+
onAction?.(action.dataset.action ?? '', row, action, index);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const control = target.closest(NOT_ROW_CLICK);
|
|
968
|
+
if (control && e.currentTarget.contains(control)) return;
|
|
969
|
+
if (onCellRangeChange) {
|
|
970
|
+
const position = { row: node.id, column: col.id };
|
|
971
|
+
onCellRangeChange({
|
|
972
|
+
start: e.shiftKey && cellRange ? cellRange.start : position,
|
|
973
|
+
end: position
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
if (expandRowByClick && expandable(node, row)) toggleExpanded(node.id, row);
|
|
977
|
+
onRowClick?.(row, e, index);
|
|
978
|
+
}}
|
|
979
|
+
>
|
|
980
|
+
{#if editing?.id === node.id && editing.column === col.id}<div
|
|
981
|
+
class="slean-table-editor"
|
|
982
|
+
>
|
|
983
|
+
{#if col.editor === 'select'}<select
|
|
984
|
+
class="slean-table-input"
|
|
985
|
+
aria-label={col.header}
|
|
986
|
+
bind:value={editing.value}
|
|
987
|
+
disabled={saving}
|
|
988
|
+
onkeydown={(e) => editorKey(e, col)}
|
|
989
|
+
>{#each col.options ?? [] as option (option.value)}<option
|
|
990
|
+
value={option.value}>{option.label}</option
|
|
991
|
+
>{/each}</select
|
|
992
|
+
>{:else}<input
|
|
993
|
+
class="slean-table-input"
|
|
994
|
+
aria-label={col.header}
|
|
995
|
+
type={col.editor === 'number'
|
|
996
|
+
? 'number'
|
|
997
|
+
: col.editor === 'date'
|
|
998
|
+
? 'date'
|
|
999
|
+
: 'text'}
|
|
1000
|
+
value={editing.value}
|
|
1001
|
+
disabled={saving}
|
|
1002
|
+
oninput={(e) => {
|
|
1003
|
+
if (editing) editing.value = e.currentTarget.value;
|
|
1004
|
+
}}
|
|
1005
|
+
onkeydown={(e) => editorKey(e, col)}
|
|
1006
|
+
/>{/if}
|
|
1007
|
+
<button
|
|
1008
|
+
type="button"
|
|
1009
|
+
class="slean-table-button"
|
|
1010
|
+
disabled={saving}
|
|
1011
|
+
onclick={() => save(col)}>{m.save}</button
|
|
1012
|
+
><button
|
|
1013
|
+
type="button"
|
|
1014
|
+
class="slean-table-button"
|
|
1015
|
+
disabled={saving}
|
|
1016
|
+
onclick={endEdit}>{m.cancel}</button
|
|
1017
|
+
>{#if editError}<span role="alert">{editError}</span>{/if}
|
|
1018
|
+
</div>{:else}<div
|
|
1019
|
+
class="slean-table-cell"
|
|
1020
|
+
style:padding-inline-start={ci === 0 && node.depth
|
|
1021
|
+
? `${node.depth * indentSize}px`
|
|
1022
|
+
: undefined}
|
|
1023
|
+
>
|
|
1024
|
+
{#if ci === 0 && movable}<span class="slean-table-row-move"
|
|
1025
|
+
><button
|
|
1026
|
+
type="button"
|
|
1027
|
+
class="slean-table-button"
|
|
1028
|
+
aria-label={`${m.moveUp}: ${nameOf(node, row)}`}
|
|
1029
|
+
disabled={index === 0}
|
|
1030
|
+
onclick={() => {
|
|
1031
|
+
const target = model.rows[index - 1];
|
|
1032
|
+
if (target) onRowMove?.(node.id, target.id);
|
|
1033
|
+
}}>{@render icon('asc')}</button
|
|
1034
|
+
><button
|
|
1035
|
+
type="button"
|
|
1036
|
+
class="slean-table-button"
|
|
1037
|
+
aria-label={`${m.moveDown}: ${nameOf(node, row)}`}
|
|
1038
|
+
disabled={index === model.rows.length - 1}
|
|
1039
|
+
onclick={() => {
|
|
1040
|
+
const target = model.rows[index + 1];
|
|
1041
|
+
if (target) onRowMove?.(node.id, target.id);
|
|
1042
|
+
}}>{@render icon('desc')}</button
|
|
1043
|
+
></span
|
|
1044
|
+
>{/if}
|
|
1045
|
+
{#if ci === 0 && !expander && expandable(node, row)}<button
|
|
1046
|
+
type="button"
|
|
1047
|
+
class="slean-table-button slean-table-expand"
|
|
1048
|
+
aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
|
|
1049
|
+
aria-expanded={open}
|
|
1050
|
+
onclick={() => toggleExpanded(node.id, row)}
|
|
1051
|
+
>{@render icon(open ? 'collapse' : 'expand')}</button
|
|
1052
|
+
>{:else if ci === 0 && !expander && (rowDetails || getSubRows)}<span
|
|
1053
|
+
class="slean-table-expand-spacer"
|
|
1054
|
+
></span>{/if}
|
|
1055
|
+
{#if col.cell}{@render col.cell(context)}{:else}{@const text = textOf(
|
|
1056
|
+
col,
|
|
1057
|
+
value,
|
|
1058
|
+
row
|
|
1059
|
+
)}{#if text !== null}{text}{:else if placeholder}<span
|
|
1060
|
+
class="slean-table-placeholder">{placeholder}</span
|
|
1061
|
+
>{/if}{/if}
|
|
1062
|
+
{#if editable(row, col)}<button
|
|
1063
|
+
type="button"
|
|
1064
|
+
class="slean-table-button slean-table-edit"
|
|
1065
|
+
tabindex="-1"
|
|
1066
|
+
aria-label={`${m.edit}: ${col.header}`}
|
|
1067
|
+
onclick={() => startEdit(row, col)}>{@render icon('edit')}</button
|
|
1068
|
+
>{/if}
|
|
1069
|
+
</div>{/if}
|
|
1070
|
+
</td>{/if}
|
|
1071
|
+
{/each}
|
|
1072
|
+
</tr>
|
|
1073
|
+
{#if rowDetails && open && rowExpandable(row)}<tr class="slean-table-detail"
|
|
1074
|
+
><td colspan={span}
|
|
1075
|
+
>{@render rowDetails(row, {
|
|
1076
|
+
rowId: node.id,
|
|
1077
|
+
index,
|
|
1078
|
+
collapse: () => toggleExpanded(node.id, row, false)
|
|
1079
|
+
})}</td
|
|
1080
|
+
></tr
|
|
1081
|
+
>{/if}
|
|
1082
|
+
{/if}
|
|
1083
|
+
{/each}
|
|
1084
|
+
</tbody>
|
|
1085
|
+
{#if summary}<tfoot
|
|
1086
|
+
><tr
|
|
1087
|
+
>{@render leading()}{#each cols as col (col.id)}<td {...pinned(col)}
|
|
1088
|
+
>{summary[col.id] ?? ''}</td
|
|
1089
|
+
>{/each}</tr
|
|
1090
|
+
></tfoot
|
|
1091
|
+
>{/if}
|
|
1092
|
+
</table>
|
|
1093
|
+
</div>
|
|
1094
|
+
{#if pagination}<div class="slean-table-pagination">
|
|
1095
|
+
<span class="slean-table-page-status" aria-live="polite"
|
|
1096
|
+
>{m.page(model.pageIndex + 1, model.pageCount, rowsKnown ? model.total : undefined)}</span
|
|
1097
|
+
><label class="slean-table-page-size"
|
|
1098
|
+
>{m.pageSize}<select
|
|
1099
|
+
class="slean-table-input"
|
|
1100
|
+
value={s.pageSize}
|
|
1101
|
+
onchange={(e) => table.setPageSize(Number(e.currentTarget.value))}
|
|
1102
|
+
>{#each pageSizes as size (size)}<option value={size}
|
|
1103
|
+
>{size === 0 ? m.allRows : size}</option
|
|
1104
|
+
>{/each}</select
|
|
1105
|
+
></label
|
|
1106
|
+
><span class="slean-table-pager"
|
|
1107
|
+
><button
|
|
1108
|
+
type="button"
|
|
1109
|
+
class="slean-table-button"
|
|
1110
|
+
aria-label={m.first}
|
|
1111
|
+
disabled={model.pageIndex === 0}
|
|
1112
|
+
onclick={() => table.setPage(0)}>{@render icon('first')}</button
|
|
1113
|
+
><button
|
|
1114
|
+
type="button"
|
|
1115
|
+
class="slean-table-button"
|
|
1116
|
+
aria-label={m.previous}
|
|
1117
|
+
disabled={model.pageIndex === 0}
|
|
1118
|
+
onclick={() => table.setPage(model.pageIndex - 1)}>{@render icon('previous')}</button
|
|
1119
|
+
>{#if model.pageCount > 1}<span class="slean-table-pages"
|
|
1120
|
+
>{#each pageWindow(model.pageIndex, model.pageCount) as page, i (page ?? `gap-${i}`)}{#if page === null}<span
|
|
1121
|
+
class="slean-table-page-gap">{@render icon('more')}</span
|
|
1122
|
+
>{:else}<button
|
|
1123
|
+
type="button"
|
|
1124
|
+
class="slean-table-button"
|
|
1125
|
+
aria-label={m.pageNumber(page + 1)}
|
|
1126
|
+
aria-current={page === model.pageIndex ? 'page' : undefined}
|
|
1127
|
+
onclick={() => table.setPage(page)}>{page + 1}</button
|
|
1128
|
+
>{/if}{/each}</span
|
|
1129
|
+
>{/if}<button
|
|
1130
|
+
type="button"
|
|
1131
|
+
class="slean-table-button"
|
|
1132
|
+
aria-label={m.next}
|
|
1133
|
+
disabled={model.pageIndex >= model.pageCount - 1}
|
|
1134
|
+
onclick={() => table.setPage(model.pageIndex + 1)}>{@render icon('next')}</button
|
|
1135
|
+
><button
|
|
1136
|
+
type="button"
|
|
1137
|
+
class="slean-table-button"
|
|
1138
|
+
aria-label={m.last}
|
|
1139
|
+
disabled={model.pageIndex >= model.pageCount - 1}
|
|
1140
|
+
onclick={() => table.setPage(model.pageCount - 1)}>{@render icon('last')}</button
|
|
1141
|
+
></span
|
|
1142
|
+
>
|
|
1143
|
+
</div>{/if}
|
|
1144
|
+
{#if footer}{@render footer(model)}{/if}
|
|
1145
|
+
</div>
|