@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.
@@ -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>