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