@svelte-lean/table 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +6 -1
- package/dist/style.css +221 -9
- package/dist/table/data-table.svelte +990 -329
- package/dist/table/navigation.js +20 -2
- package/dist/table.svelte.js +34 -1
- package/dist/types.d.ts +25 -5
- package/package.json +6 -3
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
</script>
|
|
51
51
|
|
|
52
52
|
<script lang="ts" generics="T">
|
|
53
|
-
import { flushSync, tick, untrack } from 'svelte';
|
|
53
|
+
import { flushSync, tick, untrack, type Snippet } from 'svelte';
|
|
54
|
+
import { BROWSER } from 'esm-env';
|
|
54
55
|
import type {
|
|
55
56
|
TableProps,
|
|
56
57
|
Column,
|
|
@@ -58,9 +59,10 @@
|
|
|
58
59
|
RowNode,
|
|
59
60
|
CellPosition,
|
|
60
61
|
HeaderContext,
|
|
62
|
+
CellContext,
|
|
61
63
|
Pin
|
|
62
64
|
} from '../types.js';
|
|
63
|
-
import { createRowModel, valueOf } from '../core.js';
|
|
65
|
+
import { createRowModel, valueOf, type HeaderCell } from '../core.js';
|
|
64
66
|
import { createTable } from '../table.svelte.js';
|
|
65
67
|
import { en } from '../messages.js';
|
|
66
68
|
import { parseEdit } from '../editing.js';
|
|
@@ -82,6 +84,7 @@
|
|
|
82
84
|
title,
|
|
83
85
|
showHeader = true,
|
|
84
86
|
tableLayout = 'fixed',
|
|
87
|
+
layout = 'table',
|
|
85
88
|
searchable = false,
|
|
86
89
|
searchDelay = 0,
|
|
87
90
|
searchText,
|
|
@@ -116,6 +119,7 @@
|
|
|
116
119
|
progressive = false,
|
|
117
120
|
pagination = true,
|
|
118
121
|
pageSizeOptions = [10, 20, 50, 100],
|
|
122
|
+
pageSizeSelect = 'native',
|
|
119
123
|
placeholder = '',
|
|
120
124
|
rowHoverable = true,
|
|
121
125
|
dir,
|
|
@@ -133,6 +137,15 @@
|
|
|
133
137
|
onCellRangeChange,
|
|
134
138
|
class: className = ''
|
|
135
139
|
}: TableProps<T> = $props();
|
|
140
|
+
/** Ids of this instance's own relationships (the page-size label and listbox); SSR-stable. */
|
|
141
|
+
const uid = $props.id();
|
|
142
|
+
/**
|
|
143
|
+
* The Select primitive's root marker, spread rather than written: @svelte-lean/vite registers
|
|
144
|
+
* the behavior of every marker it reads in markup, and a marker here would put the select
|
|
145
|
+
* behavior in every application that renders a table. The application that sets
|
|
146
|
+
* `pageSizeSelect="custom"` imports `@svelte-lean/primitives/select/register` itself.
|
|
147
|
+
*/
|
|
148
|
+
const SELECT_ROOT = { 'data-slean': 'select' } as const;
|
|
136
149
|
/**
|
|
137
150
|
* One engine. Props are handed over as getters so the engine follows every prop change.
|
|
138
151
|
* `getSubRows` and `searchText` must stay `undefined` when absent (grouping rejects tree input;
|
|
@@ -177,21 +190,32 @@
|
|
|
177
190
|
let dragging = $state<RowId | null>(null);
|
|
178
191
|
let root: HTMLDivElement;
|
|
179
192
|
let viewport: HTMLDivElement;
|
|
180
|
-
let
|
|
193
|
+
let tableElement: HTMLTableElement;
|
|
181
194
|
/** Rendered row nodes by their `data-row-id` text, to resolve the row of an event target. */
|
|
182
195
|
const nodes = $derived(new Map(model.rows.map((n) => [String(n.id), n])));
|
|
183
196
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
197
|
+
* This table's own body rows: a `<tr>` in one of its `<tbody>` elements. A table nested in a
|
|
198
|
+
* detail row has its own, so a target never resolves to a row of the other table.
|
|
186
199
|
*/
|
|
200
|
+
function isOwnRow(element: Element | null): element is HTMLTableRowElement {
|
|
201
|
+
const section = element?.parentElement;
|
|
202
|
+
return (
|
|
203
|
+
element?.tagName === 'TR' &&
|
|
204
|
+
section?.tagName === 'TBODY' &&
|
|
205
|
+
section.parentElement === tableElement
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
/** The row node of the body row that contains `target`. */
|
|
187
209
|
function ownRow(target: EventTarget | null): RowNode<T> | undefined {
|
|
188
210
|
let element = target as Element | null;
|
|
189
|
-
while (element && element
|
|
211
|
+
while (element && !isOwnRow(element)) element = element.parentElement;
|
|
190
212
|
return element ? nodes.get(element.getAttribute('data-row-id') ?? '') : undefined;
|
|
191
213
|
}
|
|
192
|
-
/** This table's own rows
|
|
214
|
+
/** This table's own rows, in document order across its body sections. */
|
|
193
215
|
function ownRows(selector = ''): NodeListOf<HTMLTableRowElement> {
|
|
194
|
-
return
|
|
216
|
+
return tableElement.querySelectorAll<HTMLTableRowElement>(
|
|
217
|
+
`:scope > tbody > tr[data-row-id]${selector}`
|
|
218
|
+
);
|
|
195
219
|
}
|
|
196
220
|
const rangeBounds = $derived.by((): [number, number, number, number] | null => {
|
|
197
221
|
if (!cellRange) return null;
|
|
@@ -250,13 +274,13 @@
|
|
|
250
274
|
return activeCell?.row === rowId && activeCell.column === column ? 0 : -1;
|
|
251
275
|
}
|
|
252
276
|
function positionOf(cell: HTMLTableCellElement): CellPosition | null {
|
|
253
|
-
const row = nodes.get(cell.
|
|
277
|
+
const row = nodes.get(cell.parentElement?.getAttribute('data-row-id') ?? '')?.id;
|
|
254
278
|
const column = cell.dataset.column;
|
|
255
279
|
return row !== undefined && column ? { row, column } : null;
|
|
256
280
|
}
|
|
257
281
|
function cellElement(position: CellPosition): HTMLTableCellElement | null {
|
|
258
|
-
return
|
|
259
|
-
`:scope >
|
|
282
|
+
return tableElement.querySelector<HTMLTableCellElement>(
|
|
283
|
+
`:scope > tbody > tr[data-row-id="${CSS.escape(String(position.row))}"] > td[data-column="${CSS.escape(position.column)}"]`
|
|
260
284
|
);
|
|
261
285
|
}
|
|
262
286
|
/**
|
|
@@ -294,70 +318,473 @@
|
|
|
294
318
|
return text == null || text === '' ? null : text;
|
|
295
319
|
}
|
|
296
320
|
/**
|
|
297
|
-
* Progressive mounting (`progressive`).
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
* the
|
|
301
|
-
*
|
|
321
|
+
* Progressive mounting (`progressive`). A change that brings at least a first batch of rows the
|
|
322
|
+
* table has not rendered before (the first render in the browser, a fetch, a page change)
|
|
323
|
+
* renders that batch in the same update and the rest in slices, one macrotask each, sized from
|
|
324
|
+
* the measured cost of a row so a slice takes about `SLICE_MS` of work. Sorting and filtering
|
|
325
|
+
* rows already on screen renders at once. `shown` is the rendered prefix. A server render is
|
|
326
|
+
* complete, and so is the first client render of a table the server rendered (it hydrates, and
|
|
327
|
+
* must match): the server marks its root `data-ssr`, which hydration removes.
|
|
302
328
|
*/
|
|
303
329
|
const SLICE_MS = 30;
|
|
304
|
-
|
|
305
|
-
let
|
|
306
|
-
|
|
330
|
+
/** The height of a body row, read off the first section once drawn; skipped sections assume it. */
|
|
331
|
+
let rowEstimate = $state(0);
|
|
332
|
+
const batch = $derived(
|
|
333
|
+
progressive === true
|
|
334
|
+
? Math.min(60, Math.ceil((BROWSER ? window.innerHeight : 800) / (rowEstimate || 40)) + 4)
|
|
335
|
+
: progressive || 0
|
|
336
|
+
);
|
|
337
|
+
const SERVER = !BROWSER;
|
|
338
|
+
const hydrating = BROWSER && !!document.querySelector('.slean-table[data-ssr]');
|
|
339
|
+
let limit = $state(
|
|
340
|
+
untrack(() =>
|
|
341
|
+
!SERVER && !hydrating && batch && model.rows.length > batch ? batch : Number.POSITIVE_INFINITY
|
|
342
|
+
)
|
|
343
|
+
);
|
|
344
|
+
/** Where each rendered row stood: its index, and so, in grid layout, its body section. */
|
|
345
|
+
let renderedAt: Map<RowId, number> | null = null;
|
|
307
346
|
let job = 0;
|
|
347
|
+
let rowsPerSlice = 10;
|
|
308
348
|
const shown = $derived(limit >= model.rows.length ? model.rows : model.rows.slice(0, limit));
|
|
349
|
+
/**
|
|
350
|
+
* Grid layout groups the body rows in sections of `CHUNK` (`<tbody>` each); the section, not the
|
|
351
|
+
* row, is what the browser skips off screen, so a scroll checks a few dozen boxes, not every row.
|
|
352
|
+
* A row that moves to another section is drawn anew there, progressively like new rows. Every
|
|
353
|
+
* section of the page stands from the start: one whose rows are not drawn yet holds their
|
|
354
|
+
* expected height, so a progressive mount does not grow the page, and move what is below it,
|
|
355
|
+
* with every slice.
|
|
356
|
+
*/
|
|
357
|
+
const CHUNK = 50;
|
|
358
|
+
let previousChunks: RowNode<T>[][] = [];
|
|
359
|
+
const chunks = $derived.by(() => {
|
|
360
|
+
if (!grid) return (previousChunks = []);
|
|
361
|
+
const result: RowNode<T>[][] = [];
|
|
362
|
+
for (let i = 0, c = 0; i < model.rows.length; i += CHUNK, c++) {
|
|
363
|
+
const next = shown.slice(i, i + CHUNK);
|
|
364
|
+
const before = previousChunks[c];
|
|
365
|
+
// A section whose rows are the same keeps its array, so its rows are not diffed again
|
|
366
|
+
// when a progressive slice or a change elsewhere touches another section.
|
|
367
|
+
result.push(
|
|
368
|
+
before && before.length === next.length && before.every((n, j) => n === next[j])
|
|
369
|
+
? before
|
|
370
|
+
: next
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return (previousChunks = result);
|
|
374
|
+
});
|
|
309
375
|
$effect.pre(() => {
|
|
310
376
|
const rows = model.rows;
|
|
311
|
-
const size = batch;
|
|
312
377
|
untrack(() => {
|
|
313
|
-
const
|
|
314
|
-
|
|
378
|
+
const size = batch;
|
|
379
|
+
const previous = renderedAt;
|
|
380
|
+
renderedAt = new Map(rows.map((n, i) => [n.id, i]));
|
|
315
381
|
job++;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
382
|
+
if (!previous) {
|
|
383
|
+
if (limit < rows.length) continueMount(job);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// Rows to draw: new ones, and in grid layout rows that changed section (a section's rows
|
|
387
|
+
// are drawn anew when they move to another one). Rows before the first of them stay.
|
|
388
|
+
let drawn = 0;
|
|
389
|
+
let first = -1;
|
|
390
|
+
if (size)
|
|
391
|
+
rows.forEach((n, i) => {
|
|
392
|
+
const was = previous.get(n.id);
|
|
393
|
+
if (was !== undefined && (!grid || Math.floor(was / CHUNK) === Math.floor(i / CHUNK)))
|
|
394
|
+
return;
|
|
395
|
+
drawn++;
|
|
396
|
+
if (first < 0) first = i;
|
|
397
|
+
});
|
|
398
|
+
if (!size || drawn < size || rows.length <= size) {
|
|
319
399
|
limit = Number.POSITIVE_INFINITY;
|
|
320
400
|
return;
|
|
321
401
|
}
|
|
322
|
-
limit = size;
|
|
323
|
-
continueMount(job
|
|
402
|
+
limit = Math.max(size, first);
|
|
403
|
+
continueMount(job);
|
|
324
404
|
});
|
|
325
405
|
});
|
|
326
406
|
$effect(() => () => {
|
|
327
407
|
job++;
|
|
328
408
|
});
|
|
329
|
-
function continueMount(id: number
|
|
409
|
+
function continueMount(id: number) {
|
|
330
410
|
const channel = new MessageChannel();
|
|
331
411
|
channel.port1.onmessage = () => {
|
|
332
412
|
if (id !== job) return channel.port1.close();
|
|
333
413
|
const start = performance.now();
|
|
334
|
-
|
|
414
|
+
const count = rowsPerSlice;
|
|
415
|
+
limit += count;
|
|
335
416
|
flushSync();
|
|
417
|
+
const cost = (performance.now() - start) / count;
|
|
418
|
+
rowsPerSlice = Math.max(5, Math.min(400, Math.floor(SLICE_MS / Math.max(cost, 0.05))));
|
|
336
419
|
if (id !== job || limit >= model.rows.length) {
|
|
337
420
|
if (id === job) limit = Number.POSITIVE_INFINITY;
|
|
338
421
|
return channel.port1.close();
|
|
339
422
|
}
|
|
340
|
-
const spent = Math.max(1, performance.now() - start);
|
|
341
|
-
step = Math.min(1000, Math.max(10, Math.round((step * SLICE_MS) / spent)));
|
|
342
423
|
channel.port2.postMessage(null);
|
|
343
424
|
};
|
|
344
|
-
|
|
425
|
+
// The first slice waits for the frame that paints the first batch: queued right away, it
|
|
426
|
+
// could run before that frame, and the interaction that brought the rows (a sort, a page)
|
|
427
|
+
// would be answered only after it. A hidden page paints no frames: it starts at once.
|
|
428
|
+
if (document.hidden) channel.port2.postMessage(null);
|
|
429
|
+
else requestAnimationFrame(() => channel.port2.postMessage(null));
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* What a plain body cell's attributes are, per column, computed once per column change: the
|
|
433
|
+
* cell template reads them as plain properties, so a row adds no per-cell computations.
|
|
434
|
+
*/
|
|
435
|
+
const cellMeta = $derived(
|
|
436
|
+
Object.fromEntries(
|
|
437
|
+
cols.map((col) => {
|
|
438
|
+
const pin = pins.get(col.id);
|
|
439
|
+
return [
|
|
440
|
+
col.id,
|
|
441
|
+
{
|
|
442
|
+
align: col.align !== 'start' ? col.align : undefined,
|
|
443
|
+
pin: pin?.pin,
|
|
444
|
+
edge: pin?.edge,
|
|
445
|
+
clip: clipped.has(col.id) || undefined,
|
|
446
|
+
className: typeof col.class === 'string' ? col.class : undefined,
|
|
447
|
+
gridClass: `slean-table-cell${typeof col.class === 'string' ? ` ${col.class}` : ''}`,
|
|
448
|
+
style: pin?.style
|
|
449
|
+
}
|
|
450
|
+
];
|
|
451
|
+
})
|
|
452
|
+
)
|
|
453
|
+
);
|
|
454
|
+
/**
|
|
455
|
+
* The plain cells' contexts of one row, by column id: built once per row (a `{@const}` in the
|
|
456
|
+
* row), again only when the row's node or the columns change. `index` is read through a getter,
|
|
457
|
+
* so a reorder does not rebuild them.
|
|
458
|
+
*/
|
|
459
|
+
function rowContexts(
|
|
460
|
+
node: RowNode<T>,
|
|
461
|
+
row: T,
|
|
462
|
+
index: () => number
|
|
463
|
+
): Record<string, CellContext<T>> {
|
|
464
|
+
const result: Record<string, CellContext<T>> = {};
|
|
465
|
+
for (const column of cols)
|
|
466
|
+
result[column.id] = {
|
|
467
|
+
row,
|
|
468
|
+
rowId: node.id,
|
|
469
|
+
column,
|
|
470
|
+
get value() {
|
|
471
|
+
return valueOf(row, column);
|
|
472
|
+
},
|
|
473
|
+
get index() {
|
|
474
|
+
return index();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
return result;
|
|
345
478
|
}
|
|
479
|
+
/**
|
|
480
|
+
* A cell's context, created once per row and column: `index` is read through a getter, so a
|
|
481
|
+
* reorder that only moves a row does not hand its cells a new context to render again.
|
|
482
|
+
*/
|
|
483
|
+
function cellContext(
|
|
484
|
+
node: RowNode<T>,
|
|
485
|
+
row: T,
|
|
486
|
+
column: Column<T>,
|
|
487
|
+
index: () => number
|
|
488
|
+
): CellContext<T> {
|
|
489
|
+
return {
|
|
490
|
+
row,
|
|
491
|
+
get value() {
|
|
492
|
+
return valueOf(row, column);
|
|
493
|
+
},
|
|
494
|
+
rowId: node.id,
|
|
495
|
+
column,
|
|
496
|
+
get index() {
|
|
497
|
+
return index();
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Grid layout (`layout="grid"`). Rows are CSS grid rows over one track list; a body section off
|
|
503
|
+
* screen is skipped by the browser (`content-visibility: auto`). Column widths come from the
|
|
504
|
+
* content: read off hidden copies of a sample of rows, every cell at its max-content width
|
|
505
|
+
* (`data-measuring`), when a new set of rows first renders and once more, when idle, after a
|
|
506
|
+
* progressive mount completes; then kept. A width only grows while the columns stay, so nothing shifts under the
|
|
507
|
+
* reader. A declared `width` is the minimum; `maxWidth` (default `GRID_MAX`) caps a measured
|
|
508
|
+
* width, and the cells of a capped column clip with an ellipsis.
|
|
509
|
+
*/
|
|
510
|
+
const GRID_MAX = 480;
|
|
511
|
+
const GRID_MIN = 48;
|
|
512
|
+
const MEASURE_ROWS = 30;
|
|
513
|
+
const grid = $derived(layout === 'grid');
|
|
514
|
+
let measured = $state<Record<string, number>>({});
|
|
515
|
+
function gridWidth(col: Column<T>): number {
|
|
516
|
+
const resized = s.widths[col.id];
|
|
517
|
+
if (resized !== undefined) return resized;
|
|
518
|
+
const declared = col.width ?? 0;
|
|
519
|
+
const max = Math.max(col.maxWidth ?? GRID_MAX, declared);
|
|
520
|
+
return Math.max(col.minWidth ?? GRID_MIN, declared, Math.min(measured[col.id] ?? 0, max));
|
|
521
|
+
}
|
|
522
|
+
const clipped = $derived(
|
|
523
|
+
new Set(
|
|
524
|
+
grid
|
|
525
|
+
? cols
|
|
526
|
+
.filter(
|
|
527
|
+
(col) => (measured[col.id] ?? 0) > Math.max(col.maxWidth ?? GRID_MAX, col.width ?? 0)
|
|
528
|
+
)
|
|
529
|
+
.map((col) => col.id)
|
|
530
|
+
: []
|
|
531
|
+
)
|
|
532
|
+
);
|
|
533
|
+
/**
|
|
534
|
+
* When to measure: the first rows of a new column set, before they are painted; a page of rows
|
|
535
|
+
* the table has not drawn before, when idle; the whole page once a progressive mount completes,
|
|
536
|
+
* when idle. A sort, a filter or the same rows arriving again measure nothing.
|
|
537
|
+
*/
|
|
538
|
+
let measuredColumns = '';
|
|
539
|
+
let measuredRows: Set<RowId> = new Set();
|
|
540
|
+
let measuredWhole = false;
|
|
541
|
+
$effect(() => {
|
|
542
|
+
if (!grid) return;
|
|
543
|
+
const columns = cols.map((col) => col.id).join('\u0000');
|
|
544
|
+
const rows = shown;
|
|
545
|
+
const complete = limit === Number.POSITIVE_INFINITY;
|
|
546
|
+
if (!rows.length) return;
|
|
547
|
+
untrack(() => {
|
|
548
|
+
if (columns !== measuredColumns) {
|
|
549
|
+
measuredColumns = columns;
|
|
550
|
+
measured = {};
|
|
551
|
+
measuredRows = new Set(rows.map((n) => n.id));
|
|
552
|
+
measuredWhole = complete;
|
|
553
|
+
measure(complete);
|
|
554
|
+
void tick().then(() => untrack(() => estimateRows()));
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
// A progressive mount is measured once, when it completes.
|
|
558
|
+
if (!complete) return;
|
|
559
|
+
const fresh = rows.some((n) => !measuredRows.has(n.id));
|
|
560
|
+
if (!fresh && measuredWhole) return;
|
|
561
|
+
// The first rows are measured again only on a new page (its first row is new); a page whose
|
|
562
|
+
// first batch was measured as it arrived needs only the spread over the rest.
|
|
563
|
+
const first = !measuredRows.has(rows[0]!.id);
|
|
564
|
+
measuredRows = new Set(rows.map((n) => n.id));
|
|
565
|
+
measuredWhole = true;
|
|
566
|
+
whenIdle(() => measure(true, first));
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
/**
|
|
570
|
+
* What a body row stands, from the first section once its rows are drawn: the sections the
|
|
571
|
+
* browser skips assume it, so the page is about as tall before they are drawn as after.
|
|
572
|
+
*/
|
|
573
|
+
function estimateRows() {
|
|
574
|
+
const section = tableElement?.querySelector<HTMLElement>(':scope > tbody.slean-table-chunk');
|
|
575
|
+
const first = section?.firstElementChild;
|
|
576
|
+
if (!section || !first) return;
|
|
577
|
+
// Before its first frame the browser has not yet decided the section is on screen, and its
|
|
578
|
+
// rows may be skipped: it is laid out here, as it is about to be anyway, and read.
|
|
579
|
+
const skipped = first.checkVisibility?.({ contentVisibilityAuto: true }) === false;
|
|
580
|
+
if (skipped) section.style.contentVisibility = 'visible';
|
|
581
|
+
const height = first.getBoundingClientRect().height;
|
|
582
|
+
if (skipped) section.style.contentVisibility = '';
|
|
583
|
+
if (height > 0) rowEstimate = Math.round(height * 100) / 100;
|
|
584
|
+
}
|
|
585
|
+
function whenIdle(run: () => void) {
|
|
586
|
+
if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 500 });
|
|
587
|
+
else setTimeout(run, 50);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Content widths, read from clones: the header row and a sample of body rows are copied into a
|
|
591
|
+
* hidden table inside the root, laid out there with every cell at its max-content width
|
|
592
|
+
* (`data-measuring`), read, and removed. The live rows are neither restyled nor laid out.
|
|
593
|
+
*/
|
|
594
|
+
function measure(whole: boolean, first = true) {
|
|
595
|
+
const live = viewport?.firstElementChild as HTMLElement | null;
|
|
596
|
+
if (!live || !root) return;
|
|
597
|
+
const leaves = new Set(cols.map((col) => col.id));
|
|
598
|
+
const rows = [...ownRows()];
|
|
599
|
+
// The first rows and the header, and for a whole page twenty rows spread over the rest; a pass
|
|
600
|
+
// over a page whose first rows were measured already (`first` false) takes only the spread.
|
|
601
|
+
const sample = first ? rows.slice(0, MEASURE_ROWS) : [];
|
|
602
|
+
if (whole && rows.length > MEASURE_ROWS) {
|
|
603
|
+
const step = Math.ceil((rows.length - MEASURE_ROWS) / 20);
|
|
604
|
+
for (let i = MEASURE_ROWS; i < rows.length; i += step) sample.push(rows[i]!);
|
|
605
|
+
}
|
|
606
|
+
if (!sample.length) return;
|
|
607
|
+
const region = document.createElement('div');
|
|
608
|
+
region.className = 'slean-table-scroll';
|
|
609
|
+
region.setAttribute('aria-hidden', 'true');
|
|
610
|
+
region.style.cssText =
|
|
611
|
+
'position:absolute;inset-block-start:0;inset-inline-start:0;visibility:hidden;pointer-events:none;contain:layout style;';
|
|
612
|
+
const clone = document.createElement('table');
|
|
613
|
+
clone.setAttribute('data-measuring', '');
|
|
614
|
+
const head = live.querySelector(':scope > thead > tr:last-child');
|
|
615
|
+
const thead = document.createElement('thead');
|
|
616
|
+
if (head && first) thead.append(head.cloneNode(true));
|
|
617
|
+
const tbody = document.createElement('tbody');
|
|
618
|
+
for (const row of sample) tbody.append(row.cloneNode(true));
|
|
619
|
+
clone.append(thead, tbody);
|
|
620
|
+
region.append(clone);
|
|
621
|
+
root.append(region);
|
|
622
|
+
const next = { ...measured };
|
|
623
|
+
let changed = false;
|
|
624
|
+
for (const cell of clone.querySelectorAll(':scope > * > tr > [data-column]')) {
|
|
625
|
+
const id = cell.getAttribute('data-column');
|
|
626
|
+
if (!id || !leaves.has(id)) continue;
|
|
627
|
+
const width = Math.ceil(cell.getBoundingClientRect().width);
|
|
628
|
+
if (width > (next[id] ?? 0)) {
|
|
629
|
+
next[id] = width;
|
|
630
|
+
changed = true;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
region.remove();
|
|
634
|
+
if (changed) measured = next;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Idle warming (grid layout). A section the browser has skipped (`content-visibility: auto`) is
|
|
638
|
+
* styled and laid out in the frame that scrolls it into view; in a fast scroll through a long
|
|
639
|
+
* page that is every frame. Once a page is fully rendered, sections are laid out ahead in idle
|
|
640
|
+
* time, nearest the screen first, as many per idle period as it has room for, so a section that
|
|
641
|
+
* arrives costs only its paint. Nothing is windowed: every row stays in the document; this only
|
|
642
|
+
* does the browser's work early. New rows and new column widths make sections cold again.
|
|
643
|
+
*/
|
|
644
|
+
$effect(() => {
|
|
645
|
+
if (!grid || limit !== Number.POSITIVE_INFINITY || typeof requestIdleCallback !== 'function')
|
|
646
|
+
return;
|
|
647
|
+
void chunks;
|
|
648
|
+
void tracks?.template;
|
|
649
|
+
const warm = new WeakSet<Element>();
|
|
650
|
+
let handle = 0;
|
|
651
|
+
let warmCost = 10;
|
|
652
|
+
const step = (deadline: IdleDeadline) => {
|
|
653
|
+
const view = viewport.getBoundingClientRect();
|
|
654
|
+
const cold = [
|
|
655
|
+
...tableElement.querySelectorAll<HTMLElement>(':scope > tbody.slean-table-chunk')
|
|
656
|
+
]
|
|
657
|
+
.filter((section) => !warm.has(section))
|
|
658
|
+
.map((section) => {
|
|
659
|
+
const box = section.getBoundingClientRect();
|
|
660
|
+
const distance =
|
|
661
|
+
box.bottom < view.top
|
|
662
|
+
? view.top - box.bottom
|
|
663
|
+
: Math.max(0, box.top - Math.min(view.bottom, innerHeight));
|
|
664
|
+
return { section, distance };
|
|
665
|
+
})
|
|
666
|
+
.sort((a, b) => a.distance - b.distance);
|
|
667
|
+
// As many sections as the idle period has room for, judged by what the last one cost (half
|
|
668
|
+
// of it may run past the period): warming a section is a layout of its rows, and a long
|
|
669
|
+
// idle callback is a long task all the same. A callback the browser ran late
|
|
670
|
+
// (`didTimeout`, a busy page) warms one.
|
|
671
|
+
let done = 0;
|
|
672
|
+
for (const { section } of cold) {
|
|
673
|
+
if (deadline.didTimeout ? done > 0 : deadline.timeRemaining() < Math.min(warmCost / 2, 40))
|
|
674
|
+
break;
|
|
675
|
+
warm.add(section);
|
|
676
|
+
// The section itself is never skipped, its rows are: a drawn first row means the
|
|
677
|
+
// browser has laid this section out already.
|
|
678
|
+
if (section.firstElementChild?.checkVisibility?.({ contentVisibilityAuto: true })) continue;
|
|
679
|
+
const start = performance.now();
|
|
680
|
+
section.style.contentVisibility = 'visible';
|
|
681
|
+
void section.offsetHeight;
|
|
682
|
+
section.style.contentVisibility = '';
|
|
683
|
+
warmCost = performance.now() - start;
|
|
684
|
+
done++;
|
|
685
|
+
}
|
|
686
|
+
if (cold.some(({ section }) => !warm.has(section)))
|
|
687
|
+
handle = requestIdleCallback(step, { timeout: 2000 });
|
|
688
|
+
};
|
|
689
|
+
handle = requestIdleCallback(step, { timeout: 2000 });
|
|
690
|
+
return () => cancelIdleCallback(handle);
|
|
691
|
+
});
|
|
692
|
+
/**
|
|
693
|
+
* The grid track list, and the width below which the rows scroll sideways. A column is never
|
|
694
|
+
* narrower than its content (its measured width, a declared `width` or `minWidth`); spare width
|
|
695
|
+
* is shared in proportion to the content (`fr`). Header labels count with their longest word
|
|
696
|
+
* (they wrap), so a long label does not widen a column of short values. When the content does
|
|
697
|
+
* not fit, the rows scroll sideways. Pinned and resized columns keep their exact width, which
|
|
698
|
+
* the sticky offsets are computed from.
|
|
699
|
+
*/
|
|
700
|
+
const tracks = $derived.by(() => {
|
|
701
|
+
if (!grid) return null;
|
|
702
|
+
const lead = [selection && SELECTION_WIDTH, expander && EXPAND_WIDTH].filter(
|
|
703
|
+
(part): part is string => !!part
|
|
704
|
+
);
|
|
705
|
+
const parts = [...lead];
|
|
706
|
+
let min = 0;
|
|
707
|
+
for (const col of cols) {
|
|
708
|
+
const width = gridWidth(col);
|
|
709
|
+
if (s.widths[col.id] !== undefined || pins.has(col.id)) {
|
|
710
|
+
parts.push(`${width}px`);
|
|
711
|
+
min += width;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
const floor = width;
|
|
715
|
+
parts.push(`minmax(${floor}px, ${width}fr)`);
|
|
716
|
+
min += floor;
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
template: parts.join(' '),
|
|
720
|
+
min: lead.length ? `calc(${min}px + ${lead.join(' + ')})` : `${min}px`
|
|
721
|
+
};
|
|
722
|
+
});
|
|
723
|
+
/**
|
|
724
|
+
* Header rows. In grid layout every row is a grid of its own, so a cell spanning rows sits in
|
|
725
|
+
* the lowest row it spans, placed at its first column; the leading control headers sit there
|
|
726
|
+
* too.
|
|
727
|
+
*/
|
|
728
|
+
const leafIndex = $derived(new Map(cols.map((col, i) => [col.id, i])));
|
|
729
|
+
function firstLeaf(column: Column<T>): number {
|
|
730
|
+
if (!column.children?.length) return leafIndex.get(column.id) ?? 0;
|
|
731
|
+
return Math.min(...column.children.map(firstLeaf));
|
|
732
|
+
}
|
|
733
|
+
const headerLevels = $derived.by((): { cell: HeaderCell<T>; place?: string }[][] => {
|
|
734
|
+
const levels = table.headers;
|
|
735
|
+
if (!grid) return levels.map((row) => row.map((cell) => ({ cell })));
|
|
736
|
+
const lead = (selection ? 1 : 0) + (expander ? 1 : 0);
|
|
737
|
+
const result: { cell: HeaderCell<T>; place?: string }[][] = levels.map(() => []);
|
|
738
|
+
levels.forEach((row, level) =>
|
|
739
|
+
row.forEach((cell) => {
|
|
740
|
+
const target = Math.min(levels.length - 1, level + cell.rowSpan - 1);
|
|
741
|
+
const start = lead + firstLeaf(cell.column) + 1;
|
|
742
|
+
result[target]!.push({ cell, place: `grid-column:${start} / span ${cell.colSpan};` });
|
|
743
|
+
})
|
|
744
|
+
);
|
|
745
|
+
return result;
|
|
746
|
+
});
|
|
747
|
+
const leadLevel = $derived(grid ? table.headers.length - 1 : 0);
|
|
748
|
+
/**
|
|
749
|
+
* The last body row carries `data-last`, where the stylesheet drops its bottom line (the region's
|
|
750
|
+
* border closes the table). Set here, on one element after each update, instead of a
|
|
751
|
+
* `:last-child` selector, which would restyle every cell each time a row is appended.
|
|
752
|
+
*/
|
|
753
|
+
let lastRow: Element | null = null;
|
|
754
|
+
$effect(() => {
|
|
755
|
+
void shown;
|
|
756
|
+
void skeleton;
|
|
757
|
+
void table.state.expanded;
|
|
758
|
+
const sections = tableElement.tBodies;
|
|
759
|
+
const last = sections[sections.length - 1]?.lastElementChild ?? null;
|
|
760
|
+
if (last === lastRow) return;
|
|
761
|
+
lastRow?.removeAttribute('data-last');
|
|
762
|
+
last?.setAttribute('data-last', '');
|
|
763
|
+
lastRow = last;
|
|
764
|
+
});
|
|
346
765
|
/**
|
|
347
766
|
* Page-sticky header. Without `height` the scroll region only exists to scroll sideways, yet as
|
|
348
767
|
* a scroll container it would also capture the sticky header. While the table fits its width,
|
|
349
768
|
* the region stops being a scroll container (`data-page-sticky`) and the header sticks to the
|
|
350
769
|
* page. One ResizeObserver per table; widths are read only when a size changes.
|
|
770
|
+
*
|
|
771
|
+
* A region that scrolls sideways but not down (no `height`, too wide to fit) captures the sticky
|
|
772
|
+
* header without ever letting it stick; the header is then not sticky at all (`scrollsY`), since
|
|
773
|
+
* a sticky box in a scroller is composited, and every row painted under it is weighed against it
|
|
774
|
+
* (at 1000 rows about a tenth of a fast scroll's main-thread work).
|
|
351
775
|
*/
|
|
352
776
|
let fits = $state(false);
|
|
777
|
+
let scrollsY = $state(true);
|
|
353
778
|
$effect(() => {
|
|
354
779
|
if (!stickyHeader || height || typeof ResizeObserver === 'undefined') {
|
|
355
780
|
fits = false;
|
|
781
|
+
scrollsY = true;
|
|
356
782
|
return;
|
|
357
783
|
}
|
|
358
784
|
const table = viewport.firstElementChild as HTMLElement;
|
|
359
785
|
const observer = new ResizeObserver(() => {
|
|
360
786
|
fits = table.offsetWidth <= viewport.clientWidth + 1;
|
|
787
|
+
scrollsY = viewport.scrollHeight > viewport.clientHeight + 1;
|
|
361
788
|
});
|
|
362
789
|
observer.observe(viewport);
|
|
363
790
|
observer.observe(table);
|
|
@@ -402,7 +829,7 @@
|
|
|
402
829
|
: `${offset}px`;
|
|
403
830
|
const property = side === 'left' ? 'inset-inline-start' : 'inset-inline-end';
|
|
404
831
|
result.set(col.id, { pin: side, style: `position:sticky;${property}:${inset};z-index:2;` });
|
|
405
|
-
offset += table.columnWidth(col);
|
|
832
|
+
offset += grid ? gridWidth(col) : table.columnWidth(col);
|
|
406
833
|
last = col.id;
|
|
407
834
|
}
|
|
408
835
|
const edge = last === undefined ? undefined : result.get(last);
|
|
@@ -509,9 +936,9 @@
|
|
|
509
936
|
editing = { id, column: col.id, row, value: String(valueOf(row, col) ?? '') };
|
|
510
937
|
editError = '';
|
|
511
938
|
await tick();
|
|
512
|
-
|
|
939
|
+
tableElement
|
|
513
940
|
.querySelector<HTMLInputElement | HTMLSelectElement>(
|
|
514
|
-
':scope > tr > td > .slean-table-editor > :is(input, select)'
|
|
941
|
+
':scope > tbody > tr > td > .slean-table-editor > :is(input, select)'
|
|
515
942
|
)
|
|
516
943
|
?.focus();
|
|
517
944
|
}
|
|
@@ -558,14 +985,12 @@
|
|
|
558
985
|
endEdit();
|
|
559
986
|
}
|
|
560
987
|
}
|
|
561
|
-
function cellKey(e: KeyboardEvent, row: T, col: Column<T>) {
|
|
562
|
-
if (e.target !== e.currentTarget) return;
|
|
988
|
+
function cellKey(e: KeyboardEvent, cell: HTMLTableCellElement, row: T, col: Column<T>) {
|
|
563
989
|
if (e.key === 'Enter' || e.key === 'F2') {
|
|
564
990
|
e.preventDefault();
|
|
565
991
|
startEdit(row, col);
|
|
566
992
|
return;
|
|
567
993
|
}
|
|
568
|
-
const cell = e.currentTarget as HTMLTableCellElement;
|
|
569
994
|
const rtl = isRtl();
|
|
570
995
|
const horizontal =
|
|
571
996
|
e.key === 'ArrowRight' ? (rtl ? -1 : 1) : e.key === 'ArrowLeft' ? (rtl ? 1 : -1) : 0;
|
|
@@ -586,11 +1011,93 @@
|
|
|
586
1011
|
});
|
|
587
1012
|
}
|
|
588
1013
|
}
|
|
1014
|
+
/**
|
|
1015
|
+
* A body cell takes the full template only when something in it can differ from a plain value:
|
|
1016
|
+
* an editor, range selection, row moves, spans, or the first column's toggle and indentation.
|
|
1017
|
+
* Every other cell is a `<td>` with one rendered snippet.
|
|
1018
|
+
*/
|
|
1019
|
+
const fullCells = $derived(
|
|
1020
|
+
!!onEdit ||
|
|
1021
|
+
!!onCellRangeChange ||
|
|
1022
|
+
movable ||
|
|
1023
|
+
cols.some((col) => (!grid && !!col.span) || typeof col.class === 'function' || !!col.ellipsis)
|
|
1024
|
+
);
|
|
1025
|
+
const firstSpecial = $derived(!!getSubRows || (!!rowDetails && !expander));
|
|
1026
|
+
/**
|
|
1027
|
+
* Grid rows with nothing but data cells take the plain row template: no group rows (the default
|
|
1028
|
+
* row model, no tree), no selection column, detail rows only behind an expand column, no cell
|
|
1029
|
+
* that needs the full template. The choice is made once per table, not per row, so a plain row
|
|
1030
|
+
* carries no per-cell branches. Everything else takes the full template.
|
|
1031
|
+
*/
|
|
1032
|
+
const plainRows = $derived(
|
|
1033
|
+
grid &&
|
|
1034
|
+
!fullCells &&
|
|
1035
|
+
!firstSpecial &&
|
|
1036
|
+
!selection &&
|
|
1037
|
+
!getSubRows &&
|
|
1038
|
+
getRowModel === createRowModel
|
|
1039
|
+
);
|
|
1040
|
+
/**
|
|
1041
|
+
* Cell events are delegated to the body: one listener each for click, double click, context
|
|
1042
|
+
* menu and keys, resolving the cell, its row and its column from the target. A cell of a table
|
|
1043
|
+
* nested in a detail row never resolves here (its row's parent is the nested body).
|
|
1044
|
+
*/
|
|
1045
|
+
const colById = $derived(new Map(cols.map((col) => [col.id, col])));
|
|
1046
|
+
function ownCell(
|
|
1047
|
+
target: EventTarget | null
|
|
1048
|
+
): { cell: HTMLTableCellElement; node: RowNode<T>; row: T; col: Column<T> } | null {
|
|
1049
|
+
let element = target as Element | null;
|
|
1050
|
+
while (element && !isOwnRow(element.parentElement)) element = element.parentElement;
|
|
1051
|
+
if (!element || !element.hasAttribute('data-cell')) return null;
|
|
1052
|
+
const node = nodes.get(element.parentElement?.getAttribute('data-row-id') ?? '');
|
|
1053
|
+
const col = colById.get(element.getAttribute('data-column') ?? '');
|
|
1054
|
+
if (!node || node.data === undefined || !col) return null;
|
|
1055
|
+
return { cell: element as HTMLTableCellElement, node, row: node.data, col };
|
|
1056
|
+
}
|
|
1057
|
+
function bodyClick(e: MouseEvent) {
|
|
1058
|
+
const hit = ownCell(e.target);
|
|
1059
|
+
if (!hit) return;
|
|
1060
|
+
const { cell, node, row, col } = hit;
|
|
1061
|
+
const index = model.rows.indexOf(node);
|
|
1062
|
+
if (focusableCells) current = { row: node.id, column: col.id };
|
|
1063
|
+
const target = e.target as HTMLElement;
|
|
1064
|
+
const action = target.closest<HTMLElement>('[data-action]');
|
|
1065
|
+
if (action && cell.contains(action)) {
|
|
1066
|
+
onAction?.(action.dataset.action ?? '', row, action, index);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const control = target.closest(NOT_ROW_CLICK);
|
|
1070
|
+
if (control && cell.contains(control)) return;
|
|
1071
|
+
if (onCellRangeChange) {
|
|
1072
|
+
const position = { row: node.id, column: col.id };
|
|
1073
|
+
onCellRangeChange({
|
|
1074
|
+
start: e.shiftKey && cellRange ? cellRange.start : position,
|
|
1075
|
+
end: position
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
if (expandRowByClick && expandable(node, row)) toggleExpanded(node.id, row);
|
|
1079
|
+
onRowClick?.(row, e, index);
|
|
1080
|
+
}
|
|
1081
|
+
function bodyDoubleClick(e: MouseEvent) {
|
|
1082
|
+
const hit = ownCell(e.target);
|
|
1083
|
+
if (hit) startEdit(hit.row, hit.col);
|
|
1084
|
+
const node = ownRow(e.target);
|
|
1085
|
+
if (node?.data !== undefined) onRowDoubleClick?.(node.data, e);
|
|
1086
|
+
}
|
|
1087
|
+
function bodyContextMenu(e: MouseEvent) {
|
|
1088
|
+
const node = ownRow(e.target);
|
|
1089
|
+
if (node?.data !== undefined) onRowContextMenu?.(node.data, e);
|
|
1090
|
+
}
|
|
1091
|
+
function bodyKey(e: KeyboardEvent) {
|
|
1092
|
+
const hit = ownCell(e.target);
|
|
1093
|
+
if (hit && e.target === hit.cell) cellKey(e, hit.cell, hit.row, hit.col);
|
|
1094
|
+
else rowKey(e);
|
|
1095
|
+
}
|
|
589
1096
|
/** Enter and Space on a focused clickable row, delegated to the body: no listener per row. */
|
|
590
1097
|
function rowKey(e: KeyboardEvent) {
|
|
591
1098
|
if (!focusableRows || (e.key !== 'Enter' && e.key !== ' ')) return;
|
|
592
1099
|
const tr = e.target as HTMLElement;
|
|
593
|
-
if (tr
|
|
1100
|
+
if (!isOwnRow(tr)) return;
|
|
594
1101
|
const node = nodes.get(tr.getAttribute('data-row-id') ?? '');
|
|
595
1102
|
if (node?.data === undefined) return;
|
|
596
1103
|
e.preventDefault();
|
|
@@ -608,8 +1115,343 @@
|
|
|
608
1115
|
focusable="false"><path d={ICONS[name]} /></svg
|
|
609
1116
|
>{/snippet}
|
|
610
1117
|
|
|
1118
|
+
<!-- A cell without a `cell` snippet: its formatted value, or the placeholder. -->
|
|
1119
|
+
{#snippet plainCell(context: CellContext<T>)}{@const text = textOf(
|
|
1120
|
+
context.column,
|
|
1121
|
+
context.value,
|
|
1122
|
+
context.row
|
|
1123
|
+
)}{#if text !== null}{text}{:else if placeholder}<span class="slean-table-placeholder"
|
|
1124
|
+
>{placeholder}</span
|
|
1125
|
+
>{/if}{/snippet}
|
|
1126
|
+
|
|
1127
|
+
<!-- Skeleton rows while loading, or the empty row. -->
|
|
1128
|
+
{#snippet placeholders()}
|
|
1129
|
+
{#each skeleton as index (index)}<tr
|
|
1130
|
+
class="slean-table-skeleton-row"
|
|
1131
|
+
aria-hidden="true"
|
|
1132
|
+
role={grid ? 'row' : undefined}
|
|
1133
|
+
>{@render leading()}{#each cols as col (col.id)}<td role={grid ? 'cell' : undefined}
|
|
1134
|
+
><span class="slean-table-skeleton"></span></td
|
|
1135
|
+
>{/each}</tr
|
|
1136
|
+
>{/each}
|
|
1137
|
+
{#if !model.rows.length && !skeleton.length}<tr role={grid ? 'row' : undefined}
|
|
1138
|
+
><td
|
|
1139
|
+
colspan={Math.max(1, span)}
|
|
1140
|
+
class="slean-table-empty slean-table-full"
|
|
1141
|
+
role={grid ? 'cell' : undefined}
|
|
1142
|
+
>{#if empty}{@render empty(emptyContext)}{:else}{loading ? m.loading : m.empty}{/if}</td
|
|
1143
|
+
></tr
|
|
1144
|
+
>{/if}{/snippet}
|
|
1145
|
+
|
|
1146
|
+
<!-- A body section of grid layout. Its rows are the only content of the <tbody>, so a section
|
|
1147
|
+
emptied at once (a new page, a sort) is cleared in one DOM operation. -->
|
|
1148
|
+
{#snippet section(
|
|
1149
|
+
chunk: RowNode<T>[],
|
|
1150
|
+
c: number,
|
|
1151
|
+
row: Snippet<[RowNode<T>, number]>
|
|
1152
|
+
)}<!-- svelte-ignore a11y_no_redundant_roles --><tbody
|
|
1153
|
+
class="slean-table-chunk"
|
|
1154
|
+
role="rowgroup"
|
|
1155
|
+
style:--slean-table-chunk-rows={Math.min(CHUNK, model.rows.length - c * CHUNK)}
|
|
1156
|
+
style:--slean-table-row-estimate={rowEstimate ? `${rowEstimate}px` : undefined}
|
|
1157
|
+
data-pending={chunk.length < Math.min(CHUNK, model.rows.length - c * CHUNK) || undefined}
|
|
1158
|
+
>{#each chunk as node, i (node.id)}{@render row(node, c * CHUNK + i)}{/each}</tbody
|
|
1159
|
+
>{/snippet}
|
|
1160
|
+
|
|
1161
|
+
<!-- One body row: a group row, or a data row and its detail row. -->
|
|
1162
|
+
<!-- A plain grid row (see `plainRows`): the row and its cells, with no per-row branches. -->
|
|
1163
|
+
{#snippet plainRow(node: RowNode<T>, index: number)}{@const row = node.data!}{@const contexts =
|
|
1164
|
+
rowContexts(node, row, () => index)}<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
1165
|
+
<tr
|
|
1166
|
+
class={rowClass?.(row, index) ?? ''}
|
|
1167
|
+
data-row-id={String(node.id)}
|
|
1168
|
+
role="row"
|
|
1169
|
+
data-even={striped && index % 2 === 1 ? '' : undefined}
|
|
1170
|
+
data-clickable={onRowClick ? true : undefined}
|
|
1171
|
+
aria-rowindex={ariaRowCount === undefined
|
|
1172
|
+
? undefined
|
|
1173
|
+
: headerRowCount + model.pageIndex * s.pageSize + index + 1}
|
|
1174
|
+
tabindex={focusableRows ? 0 : undefined}
|
|
1175
|
+
>{#if expander}{@render plainExpander(node, row)}{/if}{#each cols as col (col.id)}<td
|
|
1176
|
+
data-cell
|
|
1177
|
+
role="cell"
|
|
1178
|
+
data-column={col.id}
|
|
1179
|
+
data-align={cellMeta[col.id]!.align}
|
|
1180
|
+
data-pin={cellMeta[col.id]!.pin}
|
|
1181
|
+
data-pin-edge={cellMeta[col.id]!.edge}
|
|
1182
|
+
data-clip={cellMeta[col.id]!.clip}
|
|
1183
|
+
class={cellMeta[col.id]!.gridClass}
|
|
1184
|
+
style={cellMeta[col.id]!.style}>{@render (col.cell ?? plainCell)(contexts[col.id]!)}</td
|
|
1185
|
+
>{/each}</tr
|
|
1186
|
+
>{#if rowDetails && table.isExpanded(node.id) && rowExpandable(row)}<!-- svelte-ignore a11y_no_redundant_roles --><tr
|
|
1187
|
+
class="slean-table-detail"
|
|
1188
|
+
role="row"
|
|
1189
|
+
><td colspan={span} class="slean-table-full" role="cell"
|
|
1190
|
+
>{@render rowDetails(row, {
|
|
1191
|
+
rowId: node.id,
|
|
1192
|
+
index,
|
|
1193
|
+
collapse: () => toggleExpanded(node.id, row, false)
|
|
1194
|
+
})}</td
|
|
1195
|
+
></tr
|
|
1196
|
+
>{/if}{/snippet}
|
|
1197
|
+
|
|
1198
|
+
<!-- The expand column's cell of a plain row (no selection column precedes it). The chevron is the
|
|
1199
|
+
button's own mask (`slean-table-chevron`), not an SVG element per row. -->
|
|
1200
|
+
{#snippet plainExpander(node: RowNode<T>, row: T)}<td
|
|
1201
|
+
role="cell"
|
|
1202
|
+
class="slean-table-expander"
|
|
1203
|
+
style="inset-inline-start:0px"
|
|
1204
|
+
>{#if rowDetails && rowExpandable(row)}{@const open = table.isExpanded(node.id)}<button
|
|
1205
|
+
type="button"
|
|
1206
|
+
class="slean-table-button slean-table-expand slean-table-chevron"
|
|
1207
|
+
aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
|
|
1208
|
+
aria-expanded={open}
|
|
1209
|
+
onclick={() => toggleExpanded(node.id, row)}
|
|
1210
|
+
></button>{/if}</td
|
|
1211
|
+
>{/snippet}
|
|
1212
|
+
|
|
1213
|
+
{#snippet bodyRow(node: RowNode<T>, index: number)}
|
|
1214
|
+
{#if node.kind === 'group'}<tr class="slean-table-group" role={grid ? 'row' : undefined}
|
|
1215
|
+
><th colspan={span} scope="row" class="slean-table-full" role={grid ? 'rowheader' : undefined}
|
|
1216
|
+
><button
|
|
1217
|
+
type="button"
|
|
1218
|
+
class="slean-table-group-toggle"
|
|
1219
|
+
aria-expanded={table.isExpanded(node.id)}
|
|
1220
|
+
onclick={() => toggleExpanded(node.id)}
|
|
1221
|
+
>{@render icon(table.isExpanded(node.id) ? 'collapse' : 'expand')}
|
|
1222
|
+
{node.label} <span>({node.count})</span></button
|
|
1223
|
+
>{#each cols.filter((c) => c.aggregate) as col (col.id)}<span class="slean-table-aggregate"
|
|
1224
|
+
>{col.header}: {String(node.aggregates?.[col.id] ?? '')}</span
|
|
1225
|
+
>{/each}</th
|
|
1226
|
+
></tr
|
|
1227
|
+
>
|
|
1228
|
+
{:else if node.data !== undefined}{@const row = node.data}{@const open = table.isExpanded(
|
|
1229
|
+
node.id
|
|
1230
|
+
)}{@const contexts = rowContexts(node, row, () => index)}
|
|
1231
|
+
<tr
|
|
1232
|
+
class={rowClass?.(row, index) ?? ''}
|
|
1233
|
+
data-row-id={String(node.id)}
|
|
1234
|
+
data-even={striped && index % 2 === 1 ? '' : undefined}
|
|
1235
|
+
data-last={index === shown.length - 1 && !(rowDetails && open && rowExpandable(row))
|
|
1236
|
+
? ''
|
|
1237
|
+
: undefined}
|
|
1238
|
+
role={grid ? 'row' : undefined}
|
|
1239
|
+
data-selected={table.isSelected(node.id)}
|
|
1240
|
+
data-clickable={onRowClick ? true : undefined}
|
|
1241
|
+
aria-rowindex={ariaRowCount === undefined
|
|
1242
|
+
? undefined
|
|
1243
|
+
: headerRowCount + model.pageIndex * s.pageSize + index + 1}
|
|
1244
|
+
tabindex={focusableRows ? 0 : undefined}
|
|
1245
|
+
draggable={movable}
|
|
1246
|
+
>
|
|
1247
|
+
<!-- Two static branches: `data-slean` stays a static marker for build-time
|
|
1248
|
+
discovery (Tier 0, no runtime); a radio is not the checkbox primitive. -->
|
|
1249
|
+
{#if selection === 'single'}<td class="slean-table-selection" role={grid ? 'cell' : undefined}
|
|
1250
|
+
><input
|
|
1251
|
+
type="radio"
|
|
1252
|
+
class="slean-table-check"
|
|
1253
|
+
name={id ? `${id}-selection` : undefined}
|
|
1254
|
+
aria-label={`${m.selectRow}: ${nameOf(node, row)}`}
|
|
1255
|
+
checked={table.isSelected(node.id)}
|
|
1256
|
+
disabled={!isRowSelectable(row)}
|
|
1257
|
+
onchange={() => table.toggleSelection(node.id)}
|
|
1258
|
+
/></td
|
|
1259
|
+
>{:else if selection}<td class="slean-table-selection" role={grid ? 'cell' : undefined}
|
|
1260
|
+
><input
|
|
1261
|
+
type="checkbox"
|
|
1262
|
+
class="slean-table-check"
|
|
1263
|
+
data-slean="checkbox"
|
|
1264
|
+
aria-label={`${m.selectRow}: ${nameOf(node, row)}`}
|
|
1265
|
+
checked={table.isSelected(node.id)}
|
|
1266
|
+
disabled={!isRowSelectable(row)}
|
|
1267
|
+
onchange={() => table.toggleSelection(node.id)}
|
|
1268
|
+
/></td
|
|
1269
|
+
>{/if}
|
|
1270
|
+
{#if expander}<td
|
|
1271
|
+
role={grid ? 'cell' : undefined}
|
|
1272
|
+
class="slean-table-expander"
|
|
1273
|
+
style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
|
|
1274
|
+
>{#if expandable(node, row)}<button
|
|
1275
|
+
type="button"
|
|
1276
|
+
class="slean-table-button slean-table-expand"
|
|
1277
|
+
aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
|
|
1278
|
+
aria-expanded={open}
|
|
1279
|
+
onclick={() => toggleExpanded(node.id, row)}
|
|
1280
|
+
>{@render icon(open ? 'collapse' : 'expand')}</button
|
|
1281
|
+
>{/if}</td
|
|
1282
|
+
>{/if}
|
|
1283
|
+
{#if fullCells || firstSpecial}{#each cols as col, ci (col.id)}{#if fullCells || ci === 0}{@const value =
|
|
1284
|
+
valueOf(row, col)}{@const context = cellContext(
|
|
1285
|
+
node,
|
|
1286
|
+
row,
|
|
1287
|
+
col,
|
|
1288
|
+
() => index
|
|
1289
|
+
)}{@const span = grid ? undefined : col.span?.(context)}{@const pin = pins.get(col.id)}
|
|
1290
|
+
{#if span?.rows !== 0 && span?.columns !== 0}<td
|
|
1291
|
+
data-range={rangeBounds &&
|
|
1292
|
+
index >= rangeBounds[0] &&
|
|
1293
|
+
index <= rangeBounds[1] &&
|
|
1294
|
+
ci >= rangeBounds[2] &&
|
|
1295
|
+
ci <= rangeBounds[3]
|
|
1296
|
+
? true
|
|
1297
|
+
: undefined}
|
|
1298
|
+
data-cell
|
|
1299
|
+
role={grid ? 'cell' : undefined}
|
|
1300
|
+
data-clip={clipped.has(col.id) || undefined}
|
|
1301
|
+
data-column={col.id}
|
|
1302
|
+
data-align={col.align !== 'start' ? col.align : undefined}
|
|
1303
|
+
data-pin={pin?.pin}
|
|
1304
|
+
data-pin-edge={pin?.edge}
|
|
1305
|
+
class={typeof col.class === 'function' ? col.class(row) : col.class}
|
|
1306
|
+
class:slean-table-ellipsis={!!col.ellipsis}
|
|
1307
|
+
title={col.ellipsis &&
|
|
1308
|
+
(typeof col.ellipsis === 'boolean' || col.ellipsis.showTitle !== false)
|
|
1309
|
+
? String(value ?? '')
|
|
1310
|
+
: undefined}
|
|
1311
|
+
style={pin?.style}
|
|
1312
|
+
rowspan={span?.rows}
|
|
1313
|
+
colspan={span?.columns}
|
|
1314
|
+
tabindex={cellTabindex(node.id, col.id)}
|
|
1315
|
+
>{#if editing?.id === node.id && editing.column === col.id}<div
|
|
1316
|
+
class="slean-table-editor"
|
|
1317
|
+
>
|
|
1318
|
+
{#if col.editor === 'select'}<select
|
|
1319
|
+
class="slean-table-input"
|
|
1320
|
+
aria-label={col.header}
|
|
1321
|
+
bind:value={editing.value}
|
|
1322
|
+
disabled={saving}
|
|
1323
|
+
onkeydown={(e) => editorKey(e, col)}
|
|
1324
|
+
>{#each col.options ?? [] as option (option.value)}<option
|
|
1325
|
+
value={option.value}>{option.label}</option
|
|
1326
|
+
>{/each}</select
|
|
1327
|
+
>{:else}<input
|
|
1328
|
+
class="slean-table-input"
|
|
1329
|
+
aria-label={col.header}
|
|
1330
|
+
type={col.editor === 'number'
|
|
1331
|
+
? 'number'
|
|
1332
|
+
: col.editor === 'date'
|
|
1333
|
+
? 'date'
|
|
1334
|
+
: 'text'}
|
|
1335
|
+
value={editing.value}
|
|
1336
|
+
disabled={saving}
|
|
1337
|
+
oninput={(e) => {
|
|
1338
|
+
if (editing) editing.value = e.currentTarget.value;
|
|
1339
|
+
}}
|
|
1340
|
+
onkeydown={(e) => editorKey(e, col)}
|
|
1341
|
+
/>{/if}
|
|
1342
|
+
<button
|
|
1343
|
+
type="button"
|
|
1344
|
+
class="slean-table-button"
|
|
1345
|
+
disabled={saving}
|
|
1346
|
+
onclick={() => save(col)}>{m.save}</button
|
|
1347
|
+
><button
|
|
1348
|
+
type="button"
|
|
1349
|
+
class="slean-table-button"
|
|
1350
|
+
disabled={saving}
|
|
1351
|
+
onclick={endEdit}>{m.cancel}</button
|
|
1352
|
+
>{#if editError}<span role="alert">{editError}</span>{/if}
|
|
1353
|
+
</div>{:else}<div
|
|
1354
|
+
class="slean-table-cell"
|
|
1355
|
+
style:padding-inline-start={ci === 0 && node.depth
|
|
1356
|
+
? `${node.depth * indentSize}px`
|
|
1357
|
+
: undefined}
|
|
1358
|
+
>
|
|
1359
|
+
{#if ci === 0 && movable}<span class="slean-table-row-move"
|
|
1360
|
+
><button
|
|
1361
|
+
type="button"
|
|
1362
|
+
class="slean-table-button"
|
|
1363
|
+
aria-label={`${m.moveUp}: ${nameOf(node, row)}`}
|
|
1364
|
+
disabled={index === 0}
|
|
1365
|
+
onclick={() => {
|
|
1366
|
+
const target = model.rows[index - 1];
|
|
1367
|
+
if (target) onRowMove?.(node.id, target.id);
|
|
1368
|
+
}}>{@render icon('asc')}</button
|
|
1369
|
+
><button
|
|
1370
|
+
type="button"
|
|
1371
|
+
class="slean-table-button"
|
|
1372
|
+
aria-label={`${m.moveDown}: ${nameOf(node, row)}`}
|
|
1373
|
+
disabled={index === model.rows.length - 1}
|
|
1374
|
+
onclick={() => {
|
|
1375
|
+
const target = model.rows[index + 1];
|
|
1376
|
+
if (target) onRowMove?.(node.id, target.id);
|
|
1377
|
+
}}>{@render icon('desc')}</button
|
|
1378
|
+
></span
|
|
1379
|
+
>{/if}{#if ci === 0 && !expander && expandable(node, row)}<button
|
|
1380
|
+
type="button"
|
|
1381
|
+
class="slean-table-button slean-table-expand"
|
|
1382
|
+
aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
|
|
1383
|
+
aria-expanded={open}
|
|
1384
|
+
onclick={() => toggleExpanded(node.id, row)}
|
|
1385
|
+
>{@render icon(open ? 'collapse' : 'expand')}</button
|
|
1386
|
+
>{:else if ci === 0 && !expander && (rowDetails || getSubRows)}<span
|
|
1387
|
+
class="slean-table-expand-spacer"
|
|
1388
|
+
></span>{/if}{#if col.cell}{@render col.cell(context)}{:else}{@const text =
|
|
1389
|
+
textOf(col, value, row)}{#if text !== null}{text}{:else if placeholder}<span
|
|
1390
|
+
class="slean-table-placeholder">{placeholder}</span
|
|
1391
|
+
>{/if}{/if}{#if editable(row, col)}<button
|
|
1392
|
+
type="button"
|
|
1393
|
+
class="slean-table-button slean-table-edit"
|
|
1394
|
+
tabindex="-1"
|
|
1395
|
+
aria-label={`${m.edit}: ${col.header}`}
|
|
1396
|
+
onclick={() => startEdit(row, col)}>{@render icon('edit')}</button
|
|
1397
|
+
>{/if}
|
|
1398
|
+
</div>{/if}</td
|
|
1399
|
+
>{/if}
|
|
1400
|
+
{:else}<td
|
|
1401
|
+
data-cell
|
|
1402
|
+
role={grid ? 'cell' : undefined}
|
|
1403
|
+
data-clip={cellMeta[col.id]!.clip}
|
|
1404
|
+
data-column={col.id}
|
|
1405
|
+
data-align={cellMeta[col.id]!.align}
|
|
1406
|
+
data-pin={cellMeta[col.id]!.pin}
|
|
1407
|
+
data-pin-edge={cellMeta[col.id]!.edge}
|
|
1408
|
+
class={cellMeta[col.id]!.className}
|
|
1409
|
+
style={cellMeta[col.id]!.style}
|
|
1410
|
+
><div class="slean-table-cell">
|
|
1411
|
+
{@render (col.cell ?? plainCell)(contexts[col.id]!)}
|
|
1412
|
+
</div></td
|
|
1413
|
+
>{/if}{/each}{:else if grid}{#each cols as col (col.id)}<td
|
|
1414
|
+
data-cell
|
|
1415
|
+
role="cell"
|
|
1416
|
+
data-column={col.id}
|
|
1417
|
+
data-align={cellMeta[col.id]!.align}
|
|
1418
|
+
data-pin={cellMeta[col.id]!.pin}
|
|
1419
|
+
data-pin-edge={cellMeta[col.id]!.edge}
|
|
1420
|
+
data-clip={cellMeta[col.id]!.clip}
|
|
1421
|
+
class={cellMeta[col.id]!.gridClass}
|
|
1422
|
+
style={cellMeta[col.id]!.style}>{@render (col.cell ?? plainCell)(contexts[col.id]!)}</td
|
|
1423
|
+
>{/each}{:else}{#each cols as col (col.id)}<td
|
|
1424
|
+
data-cell
|
|
1425
|
+
data-column={col.id}
|
|
1426
|
+
data-align={cellMeta[col.id]!.align}
|
|
1427
|
+
data-pin={cellMeta[col.id]!.pin}
|
|
1428
|
+
data-pin-edge={cellMeta[col.id]!.edge}
|
|
1429
|
+
class={cellMeta[col.id]!.className}
|
|
1430
|
+
style={cellMeta[col.id]!.style}
|
|
1431
|
+
><div class="slean-table-cell">
|
|
1432
|
+
{@render (col.cell ?? plainCell)(contexts[col.id]!)}
|
|
1433
|
+
</div></td
|
|
1434
|
+
>{/each}{/if}
|
|
1435
|
+
</tr>
|
|
1436
|
+
{#if rowDetails && open && rowExpandable(row)}<tr
|
|
1437
|
+
class="slean-table-detail"
|
|
1438
|
+
role={grid ? 'row' : undefined}
|
|
1439
|
+
><td colspan={span} class="slean-table-full" role={grid ? 'cell' : undefined}
|
|
1440
|
+
>{@render rowDetails(row, {
|
|
1441
|
+
rowId: node.id,
|
|
1442
|
+
index,
|
|
1443
|
+
collapse: () => toggleExpanded(node.id, row, false)
|
|
1444
|
+
})}</td
|
|
1445
|
+
></tr
|
|
1446
|
+
>{/if}
|
|
1447
|
+
{/if}{/snippet}
|
|
1448
|
+
|
|
611
1449
|
<!-- Empty cells of the leading control columns, for rows that hold no controls. -->
|
|
612
|
-
{#snippet leading()}{#if selection}<td
|
|
1450
|
+
{#snippet leading()}{#if selection}<td
|
|
1451
|
+
class="slean-table-selection"
|
|
1452
|
+
role={grid ? 'cell' : undefined}
|
|
1453
|
+
></td>{/if}{#if expander}<td
|
|
1454
|
+
role={grid ? 'cell' : undefined}
|
|
613
1455
|
class="slean-table-expander"
|
|
614
1456
|
style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
|
|
615
1457
|
></td>{/if}{/snippet}
|
|
@@ -620,6 +1462,8 @@
|
|
|
620
1462
|
bind:this={root}
|
|
621
1463
|
{id}
|
|
622
1464
|
class="slean-table {className}"
|
|
1465
|
+
data-layout={layout}
|
|
1466
|
+
data-ssr={SERVER ? '' : undefined}
|
|
623
1467
|
data-density={density}
|
|
624
1468
|
data-striped={striped}
|
|
625
1469
|
data-bordered={bordered}
|
|
@@ -714,29 +1558,55 @@
|
|
|
714
1558
|
aria-label={label}
|
|
715
1559
|
onscroll={onScroll}
|
|
716
1560
|
>
|
|
1561
|
+
<!-- Row keys, clicks and drags are delegated to the table; rows and cells are the targets. -->
|
|
1562
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
717
1563
|
<table
|
|
1564
|
+
bind:this={tableElement}
|
|
1565
|
+
onkeydown={bodyKey}
|
|
1566
|
+
onclick={bodyClick}
|
|
1567
|
+
ondblclick={bodyDoubleClick}
|
|
1568
|
+
oncontextmenu={bodyContextMenu}
|
|
1569
|
+
ondragstart={(e) => {
|
|
1570
|
+
const id = ownRow(e.target)?.id;
|
|
1571
|
+
if (movable && id !== undefined) dragging = id;
|
|
1572
|
+
}}
|
|
1573
|
+
ondragover={(e) => {
|
|
1574
|
+
if (movable) e.preventDefault();
|
|
1575
|
+
}}
|
|
1576
|
+
ondrop={(e) => {
|
|
1577
|
+
if (!movable) return;
|
|
1578
|
+
e.preventDefault();
|
|
1579
|
+
const id = ownRow(e.target)?.id;
|
|
1580
|
+
if (dragging !== null && id !== undefined) onRowMove?.(dragging, id);
|
|
1581
|
+
dragging = null;
|
|
1582
|
+
}}
|
|
1583
|
+
role={grid ? 'table' : undefined}
|
|
718
1584
|
aria-label={label}
|
|
719
1585
|
aria-busy={loading || fetching}
|
|
720
1586
|
aria-rowcount={ariaRowCount}
|
|
721
|
-
class:slean-table-sticky={stickyHeader}
|
|
722
|
-
style:table-layout={tableLayout}
|
|
1587
|
+
class:slean-table-sticky={stickyHeader && (fits || scrollsY)}
|
|
1588
|
+
style:table-layout={grid ? undefined : tableLayout}
|
|
1589
|
+
style:--slean-table-tracks={tracks?.template}
|
|
1590
|
+
style:--slean-table-min-width={tracks?.min}
|
|
723
1591
|
>
|
|
724
1592
|
{#if caption}<caption>{caption}</caption>{/if}
|
|
725
|
-
<colgroup
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
{#if showHeader}<thead>
|
|
734
|
-
{#each
|
|
1593
|
+
{#if !grid}<colgroup
|
|
1594
|
+
>{#if selection}<col style="width:{SELECTION_WIDTH}" />{/if}{#if expander}<col
|
|
1595
|
+
style="width:{EXPAND_WIDTH}"
|
|
1596
|
+
/>{/if}{#each cols as col (col.id)}<col
|
|
1597
|
+
data-column={col.id}
|
|
1598
|
+
style:width={colWidth(col)}
|
|
1599
|
+
/>{/each}</colgroup
|
|
1600
|
+
>{/if}
|
|
1601
|
+
{#if showHeader}<thead role={grid ? 'rowgroup' : undefined}>
|
|
1602
|
+
{#each headerLevels as row, level}<tr
|
|
1603
|
+
role={grid ? 'row' : undefined}
|
|
735
1604
|
aria-rowindex={ariaRowCount === undefined ? undefined : level + 1}
|
|
736
1605
|
>
|
|
737
|
-
{#if selection && level ===
|
|
1606
|
+
{#if selection && level === leadLevel}<th
|
|
738
1607
|
scope="col"
|
|
739
|
-
|
|
1608
|
+
role={grid ? 'columnheader' : undefined}
|
|
1609
|
+
rowspan={grid ? undefined : table.headers.length}
|
|
740
1610
|
class="slean-table-selection"
|
|
741
1611
|
>{#if selection === 'multiple' && !hideSelectAll}<input
|
|
742
1612
|
type="checkbox"
|
|
@@ -750,24 +1620,26 @@
|
|
|
750
1620
|
/>{:else}<span class="slean-table-visually-hidden">{m.selectionHeader}</span
|
|
751
1621
|
>{/if}</th
|
|
752
1622
|
>{/if}
|
|
753
|
-
{#if expander && level ===
|
|
1623
|
+
{#if expander && level === leadLevel}<th
|
|
754
1624
|
scope="col"
|
|
755
|
-
|
|
1625
|
+
role={grid ? 'columnheader' : undefined}
|
|
1626
|
+
rowspan={grid ? undefined : table.headers.length}
|
|
756
1627
|
class="slean-table-expander"
|
|
757
1628
|
style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
|
|
758
1629
|
><span class="slean-table-visually-hidden">{m.expandHeader}</span></th
|
|
759
1630
|
>{/if}
|
|
760
|
-
{#each row as h (h.key)}{@const col = h.column}{@const sort =
|
|
1631
|
+
{#each row as { cell: h, place } (h.key)}{@const col = h.column}{@const sort =
|
|
761
1632
|
table.sortOf(col)}{@const pin = h.colSpan === 1 ? pins.get(col.id) : undefined}<th
|
|
762
1633
|
scope={h.colSpan > 1 ? 'colgroup' : 'col'}
|
|
1634
|
+
role={grid ? 'columnheader' : undefined}
|
|
763
1635
|
colspan={h.colSpan}
|
|
764
|
-
rowspan={h.rowSpan}
|
|
1636
|
+
rowspan={grid ? undefined : h.rowSpan}
|
|
765
1637
|
class={col.headerClass}
|
|
766
1638
|
data-column={col.id}
|
|
767
1639
|
data-align={h.colSpan === 1 && col.align !== 'start' ? col.align : undefined}
|
|
768
1640
|
data-pin={pin?.pin}
|
|
769
1641
|
data-pin-edge={pin?.edge}
|
|
770
|
-
style={pin?.style ?? ''}
|
|
1642
|
+
style={`${pin?.style ?? ''}${place ?? ''}`}
|
|
771
1643
|
aria-sort={sort ? (sort.desc ? 'descending' : 'ascending') : undefined}
|
|
772
1644
|
>
|
|
773
1645
|
{#if col.children?.length}{col.header}{:else}<div class="slean-table-heading">
|
|
@@ -808,9 +1680,12 @@
|
|
|
808
1680
|
<!-- Filter cells are not headers: <td> in <thead>, so a column without a filter is
|
|
809
1681
|
not an empty header cell (axe: empty-table-header). -->
|
|
810
1682
|
{#if filters}<tr
|
|
1683
|
+
role={grid ? 'row' : undefined}
|
|
811
1684
|
class="slean-table-filters"
|
|
812
1685
|
aria-rowindex={ariaRowCount === undefined ? undefined : table.headers.length + 1}
|
|
813
|
-
>{@render leading()}{#each cols as col (col.id)}<td
|
|
1686
|
+
>{@render leading()}{#each cols as col (col.id)}<td
|
|
1687
|
+
{...pinned(col)}
|
|
1688
|
+
role={grid ? 'cell' : undefined}
|
|
814
1689
|
>{#if col.filterable !== false}<input
|
|
815
1690
|
class="slean-table-filter slean-table-input"
|
|
816
1691
|
aria-label={m.filter(col.header)}
|
|
@@ -824,266 +1699,26 @@
|
|
|
824
1699
|
>{/each}</tr
|
|
825
1700
|
>{/if}
|
|
826
1701
|
</thead>{/if}
|
|
827
|
-
<!--
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
dragging = null;
|
|
845
|
-
}}
|
|
846
|
-
>
|
|
847
|
-
{#each skeleton as index (index)}<tr class="slean-table-skeleton-row" aria-hidden="true"
|
|
1702
|
+
<!-- In grid layout the sections are `display: block`; the roles keep the table semantics
|
|
1703
|
+
that some engines drop with a changed display. -->
|
|
1704
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
1705
|
+
{#if grid}{#if skeleton.length || !model.rows.length}<tbody role="rowgroup"
|
|
1706
|
+
>{@render placeholders()}</tbody
|
|
1707
|
+
>{/if}{#each chunks as chunk, c (c)}{@render section(
|
|
1708
|
+
chunk,
|
|
1709
|
+
c,
|
|
1710
|
+
plainRows ? plainRow : bodyRow
|
|
1711
|
+
)}{/each}{:else}<tbody
|
|
1712
|
+
>{@render placeholders()}{#each shown as node, index (node.id)}{@render bodyRow(
|
|
1713
|
+
node,
|
|
1714
|
+
index
|
|
1715
|
+
)}{/each}</tbody
|
|
1716
|
+
>{/if}
|
|
1717
|
+
{#if summary}<tfoot role={grid ? 'rowgroup' : undefined}
|
|
1718
|
+
><tr role={grid ? 'row' : undefined}
|
|
848
1719
|
>{@render leading()}{#each cols as col (col.id)}<td
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
>{#if editing?.id === node.id && editing.column === col.id}<div
|
|
980
|
-
class="slean-table-editor"
|
|
981
|
-
>
|
|
982
|
-
{#if col.editor === 'select'}<select
|
|
983
|
-
class="slean-table-input"
|
|
984
|
-
aria-label={col.header}
|
|
985
|
-
bind:value={editing.value}
|
|
986
|
-
disabled={saving}
|
|
987
|
-
onkeydown={(e) => editorKey(e, col)}
|
|
988
|
-
>{#each col.options ?? [] as option (option.value)}<option
|
|
989
|
-
value={option.value}>{option.label}</option
|
|
990
|
-
>{/each}</select
|
|
991
|
-
>{:else}<input
|
|
992
|
-
class="slean-table-input"
|
|
993
|
-
aria-label={col.header}
|
|
994
|
-
type={col.editor === 'number'
|
|
995
|
-
? 'number'
|
|
996
|
-
: col.editor === 'date'
|
|
997
|
-
? 'date'
|
|
998
|
-
: 'text'}
|
|
999
|
-
value={editing.value}
|
|
1000
|
-
disabled={saving}
|
|
1001
|
-
oninput={(e) => {
|
|
1002
|
-
if (editing) editing.value = e.currentTarget.value;
|
|
1003
|
-
}}
|
|
1004
|
-
onkeydown={(e) => editorKey(e, col)}
|
|
1005
|
-
/>{/if}
|
|
1006
|
-
<button
|
|
1007
|
-
type="button"
|
|
1008
|
-
class="slean-table-button"
|
|
1009
|
-
disabled={saving}
|
|
1010
|
-
onclick={() => save(col)}>{m.save}</button
|
|
1011
|
-
><button
|
|
1012
|
-
type="button"
|
|
1013
|
-
class="slean-table-button"
|
|
1014
|
-
disabled={saving}
|
|
1015
|
-
onclick={endEdit}>{m.cancel}</button
|
|
1016
|
-
>{#if editError}<span role="alert">{editError}</span>{/if}
|
|
1017
|
-
</div>{:else}<div
|
|
1018
|
-
class="slean-table-cell"
|
|
1019
|
-
style:padding-inline-start={ci === 0 && node.depth
|
|
1020
|
-
? `${node.depth * indentSize}px`
|
|
1021
|
-
: undefined}
|
|
1022
|
-
>
|
|
1023
|
-
{#if ci === 0 && movable}<span class="slean-table-row-move"
|
|
1024
|
-
><button
|
|
1025
|
-
type="button"
|
|
1026
|
-
class="slean-table-button"
|
|
1027
|
-
aria-label={`${m.moveUp}: ${nameOf(node, row)}`}
|
|
1028
|
-
disabled={index === 0}
|
|
1029
|
-
onclick={() => {
|
|
1030
|
-
const target = model.rows[index - 1];
|
|
1031
|
-
if (target) onRowMove?.(node.id, target.id);
|
|
1032
|
-
}}>{@render icon('asc')}</button
|
|
1033
|
-
><button
|
|
1034
|
-
type="button"
|
|
1035
|
-
class="slean-table-button"
|
|
1036
|
-
aria-label={`${m.moveDown}: ${nameOf(node, row)}`}
|
|
1037
|
-
disabled={index === model.rows.length - 1}
|
|
1038
|
-
onclick={() => {
|
|
1039
|
-
const target = model.rows[index + 1];
|
|
1040
|
-
if (target) onRowMove?.(node.id, target.id);
|
|
1041
|
-
}}>{@render icon('desc')}</button
|
|
1042
|
-
></span
|
|
1043
|
-
>{/if}{#if ci === 0 && !expander && expandable(node, row)}<button
|
|
1044
|
-
type="button"
|
|
1045
|
-
class="slean-table-button slean-table-expand"
|
|
1046
|
-
aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
|
|
1047
|
-
aria-expanded={open}
|
|
1048
|
-
onclick={() => toggleExpanded(node.id, row)}
|
|
1049
|
-
>{@render icon(open ? 'collapse' : 'expand')}</button
|
|
1050
|
-
>{:else if ci === 0 && !expander && (rowDetails || getSubRows)}<span
|
|
1051
|
-
class="slean-table-expand-spacer"
|
|
1052
|
-
></span>{/if}{#if col.cell}{@render col.cell(
|
|
1053
|
-
context
|
|
1054
|
-
)}{:else}{@const text = textOf(
|
|
1055
|
-
col,
|
|
1056
|
-
value,
|
|
1057
|
-
row
|
|
1058
|
-
)}{#if text !== null}{text}{:else if placeholder}<span
|
|
1059
|
-
class="slean-table-placeholder">{placeholder}</span
|
|
1060
|
-
>{/if}{/if}{#if editable(row, col)}<button
|
|
1061
|
-
type="button"
|
|
1062
|
-
class="slean-table-button slean-table-edit"
|
|
1063
|
-
tabindex="-1"
|
|
1064
|
-
aria-label={`${m.edit}: ${col.header}`}
|
|
1065
|
-
onclick={() => startEdit(row, col)}>{@render icon('edit')}</button
|
|
1066
|
-
>{/if}
|
|
1067
|
-
</div>{/if}</td
|
|
1068
|
-
>{/if}
|
|
1069
|
-
{/each}
|
|
1070
|
-
</tr>
|
|
1071
|
-
{#if rowDetails && open && rowExpandable(row)}<tr class="slean-table-detail"
|
|
1072
|
-
><td colspan={span}
|
|
1073
|
-
>{@render rowDetails(row, {
|
|
1074
|
-
rowId: node.id,
|
|
1075
|
-
index,
|
|
1076
|
-
collapse: () => toggleExpanded(node.id, row, false)
|
|
1077
|
-
})}</td
|
|
1078
|
-
></tr
|
|
1079
|
-
>{/if}
|
|
1080
|
-
{/if}
|
|
1081
|
-
{/each}
|
|
1082
|
-
</tbody>
|
|
1083
|
-
{#if summary}<tfoot
|
|
1084
|
-
><tr
|
|
1085
|
-
>{@render leading()}{#each cols as col (col.id)}<td {...pinned(col)}
|
|
1086
|
-
>{summary[col.id] ?? ''}</td
|
|
1720
|
+
{...pinned(col)}
|
|
1721
|
+
role={grid ? 'cell' : undefined}>{summary[col.id] ?? ''}</td
|
|
1087
1722
|
>{/each}</tr
|
|
1088
1723
|
></tfoot
|
|
1089
1724
|
>{/if}
|
|
@@ -1092,16 +1727,42 @@
|
|
|
1092
1727
|
{#if pagination}<div class="slean-table-pagination">
|
|
1093
1728
|
<span class="slean-table-page-status" aria-live="polite"
|
|
1094
1729
|
>{m.page(model.pageIndex + 1, model.pageCount, rowsKnown ? model.total : undefined)}</span
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
class="slean-table-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1730
|
+
>{#if pageSizeSelect === 'custom'}<span class="slean-table-page-size"
|
|
1731
|
+
><span id="{uid}-page-size">{m.pageSize}</span>
|
|
1732
|
+
<div class="slean-table-page-select" {...SELECT_ROOT} data-size="sm">
|
|
1733
|
+
<select
|
|
1734
|
+
data-slean-part="native"
|
|
1735
|
+
aria-labelledby="{uid}-page-size"
|
|
1736
|
+
value={s.pageSize}
|
|
1737
|
+
onchange={(e) => table.setPageSize(Number(e.currentTarget.value))}
|
|
1738
|
+
>{#each pageSizes as size (size)}<option value={size}
|
|
1739
|
+
>{size === 0 ? m.allRows : size}</option
|
|
1740
|
+
>{/each}</select
|
|
1741
|
+
><button
|
|
1742
|
+
type="button"
|
|
1743
|
+
role="combobox"
|
|
1744
|
+
aria-haspopup="listbox"
|
|
1745
|
+
aria-expanded="false"
|
|
1746
|
+
aria-controls="{uid}-page-sizes"
|
|
1747
|
+
aria-labelledby="{uid}-page-size"
|
|
1748
|
+
data-slean-part="trigger"
|
|
1749
|
+
><span data-slean-part="value">{s.pageSize === 0 ? m.allRows : s.pageSize}</span
|
|
1750
|
+
></button
|
|
1751
|
+
><span data-slean-part="toggle" aria-hidden="true"></span>
|
|
1752
|
+
<div popover="manual" data-slean-part="popup">
|
|
1753
|
+
<ul id="{uid}-page-sizes" role="listbox" aria-labelledby="{uid}-page-size"></ul>
|
|
1754
|
+
</div>
|
|
1755
|
+
</div></span
|
|
1756
|
+
>{:else}<label class="slean-table-page-size"
|
|
1757
|
+
>{m.pageSize}<select
|
|
1758
|
+
class="slean-table-input"
|
|
1759
|
+
value={s.pageSize}
|
|
1760
|
+
onchange={(e) => table.setPageSize(Number(e.currentTarget.value))}
|
|
1761
|
+
>{#each pageSizes as size (size)}<option value={size}
|
|
1762
|
+
>{size === 0 ? m.allRows : size}</option
|
|
1763
|
+
>{/each}</select
|
|
1764
|
+
></label
|
|
1765
|
+
>{/if}<span class="slean-table-pager"
|
|
1105
1766
|
><button
|
|
1106
1767
|
type="button"
|
|
1107
1768
|
class="slean-table-button"
|