@svelte-lean/table 0.1.0 → 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.
@@ -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 body: HTMLTableSectionElement;
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
- * The row of this table that contains `target`: the `<tr>` whose parent is this `<tbody>`, so
185
- * a table nested in a detail row never resolves to a row of the outer table or the reverse.
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.parentElement !== body) element = element.parentElement;
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 and cells, never those of a table nested in a detail row. */
204
+ /** This table's own rows, in document order across its body sections. */
193
205
  function ownRows(selector = ''): NodeListOf<HTMLTableRowElement> {
194
- return body.querySelectorAll<HTMLTableRowElement>(`:scope > tr[data-row-id]${selector}`);
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.dataset.row ?? '')?.id;
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 body.querySelector<HTMLTableCellElement>(
259
- `:scope > tr > td[data-row="${CSS.escape(String(position.row))}"][data-column="${CSS.escape(position.column)}"]`
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`). The first render is always complete, so server output
298
- * and hydration agree. Afterwards, a change that brings at least `batch` rows the table has not
299
- * rendered before (a fetch, a page change) renders the first `batch` rows in the same update and
300
- * the rest in slices, one macrotask each, sized so a slice takes about `SLICE_MS`. Sorting and
301
- * filtering rows already on screen renders at once. `shown` is the rendered prefix.
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
- const batch = $derived(progressive === true ? 100 : progressive || 0);
305
- let limit = $state(Number.POSITIVE_INFINITY);
306
- let renderedIds: Set<RowId> | null = null;
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 previous = renderedIds;
314
- renderedIds = new Set(rows.map((n) => n.id));
368
+ const size = batch;
369
+ const previous = renderedAt;
370
+ renderedAt = new Map(rows.map((n, i) => [n.id, i]));
315
371
  job++;
316
- let fresh = 0;
317
- if (previous && size) for (const n of rows) if (!previous.has(n.id)) fresh++;
318
- if (!previous || !size || fresh < size) {
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, size);
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, step: 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
- limit += step;
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
- channel.port2.postMessage(null);
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
- body
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.parentElement !== e.currentTarget) return;
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 class="slean-table-selection"></td>{/if}{#if expander}<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
- >{#if selection}<col style="width:{SELECTION_WIDTH}" />{/if}{#if expander}<col
727
- style="width:{EXPAND_WIDTH}"
728
- />{/if}{#each cols as col (col.id)}<col
729
- data-column={col.id}
730
- style:width={colWidth(col)}
731
- />{/each}</colgroup
732
- >
733
- {#if showHeader}<thead>
734
- {#each table.headers as row, level}<tr
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 === 0}<th
1596
+ {#if selection && level === leadLevel}<th
738
1597
  scope="col"
739
- rowspan={table.headers.length}
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 === 0}<th
1613
+ {#if expander && level === leadLevel}<th
754
1614
  scope="col"
755
- rowspan={table.headers.length}
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 {...pinned(col)}
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,268 +1689,26 @@
824
1689
  >{/each}</tr
825
1690
  >{/if}
826
1691
  </thead>{/if}
827
- <!-- Row keys and drags are delegated to the body; the focusable rows are the targets. -->
828
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
829
- <tbody
830
- bind:this={body}
831
- onkeydown={rowKey}
832
- ondragstart={(e) => {
833
- const id = ownRow(e.target)?.id;
834
- if (movable && id !== undefined) dragging = id;
835
- }}
836
- ondragover={(e) => {
837
- if (movable) e.preventDefault();
838
- }}
839
- ondrop={(e) => {
840
- if (!movable) return;
841
- e.preventDefault();
842
- const id = ownRow(e.target)?.id;
843
- if (dragging !== null && id !== undefined) onRowMove?.(dragging, id);
844
- dragging = null;
845
- }}
846
- >
847
- {#each skeleton as index (index)}<tr class="slean-table-skeleton-row" aria-hidden="true"
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
- ><span class="slean-table-skeleton"></span></td
850
- >{/each}</tr
851
- >{/each}
852
- {#if !model.rows.length && !skeleton.length}<tr
853
- ><td colspan={Math.max(1, span)} class="slean-table-empty"
854
- >{#if empty}{@render empty(emptyContext)}{:else}{loading
855
- ? m.loading
856
- : m.empty}{/if}</td
857
- ></tr
858
- >{/if}
859
- {#each shown as node, index (node.id)}
860
- {#if node.kind === 'group'}<tr class="slean-table-group"
861
- ><th colspan={span} scope="row"
862
- ><button
863
- type="button"
864
- class="slean-table-group-toggle"
865
- aria-expanded={table.isExpanded(node.id)}
866
- onclick={() => toggleExpanded(node.id)}
867
- >{@render icon(table.isExpanded(node.id) ? 'collapse' : 'expand')}
868
- {node.label} <span>({node.count})</span></button
869
- >{#each cols.filter((c) => c.aggregate) as col (col.id)}<span
870
- class="slean-table-aggregate"
871
- >{col.header}: {String(node.aggregates?.[col.id] ?? '')}</span
872
- >{/each}</th
873
- ></tr
874
- >
875
- {:else if node.data !== undefined}{@const row = node.data}{@const open = table.isExpanded(
876
- node.id
877
- )}
878
- <tr
879
- class={rowClass?.(row, index) ?? ''}
880
- data-row-id={String(node.id)}
881
- data-selected={table.isSelected(node.id)}
882
- data-clickable={onRowClick ? true : undefined}
883
- aria-rowindex={ariaRowCount === undefined
884
- ? undefined
885
- : headerRowCount + model.pageIndex * s.pageSize + index + 1}
886
- tabindex={focusableRows ? 0 : undefined}
887
- draggable={movable}
888
- ondblclick={(event) => onRowDoubleClick?.(row, event)}
889
- oncontextmenu={(event) => onRowContextMenu?.(row, event)}
890
- >
891
- <!-- Two static branches: `data-slean` stays a static marker for build-time
892
- discovery (Tier 0, no runtime); a radio is not the checkbox primitive. -->
893
- {#if selection === 'single'}<td class="slean-table-selection"
894
- ><input
895
- type="radio"
896
- class="slean-table-check"
897
- name={id ? `${id}-selection` : undefined}
898
- aria-label={`${m.selectRow}: ${nameOf(node, row)}`}
899
- checked={table.isSelected(node.id)}
900
- disabled={!isRowSelectable(row)}
901
- onchange={() => table.toggleSelection(node.id)}
902
- /></td
903
- >{:else if selection}<td class="slean-table-selection"
904
- ><input
905
- type="checkbox"
906
- class="slean-table-check"
907
- data-slean="checkbox"
908
- aria-label={`${m.selectRow}: ${nameOf(node, row)}`}
909
- checked={table.isSelected(node.id)}
910
- disabled={!isRowSelectable(row)}
911
- onchange={() => table.toggleSelection(node.id)}
912
- /></td
913
- >{/if}
914
- {#if expander}<td
915
- class="slean-table-expander"
916
- style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
917
- >{#if expandable(node, row)}<button
918
- type="button"
919
- class="slean-table-button slean-table-expand"
920
- aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
921
- aria-expanded={open}
922
- onclick={() => toggleExpanded(node.id, row)}
923
- >{@render icon(open ? 'collapse' : 'expand')}</button
924
- >{/if}</td
925
- >{/if}
926
- {#each cols as col, ci (col.id)}{@const value = valueOf(row, col)}{@const context = {
927
- row,
928
- value,
929
- rowId: node.id,
930
- index,
931
- column: col
932
- }}{@const span = col.span?.(context)}{@const pin = pins.get(col.id)}
933
- {#if span?.rows !== 0 && span?.columns !== 0}<td
934
- data-range={rangeBounds &&
935
- index >= rangeBounds[0] &&
936
- index <= rangeBounds[1] &&
937
- ci >= rangeBounds[2] &&
938
- ci <= rangeBounds[3]
939
- ? true
940
- : undefined}
941
- data-cell
942
- data-row={String(node.id)}
943
- data-column={col.id}
944
- data-align={col.align !== 'start' ? col.align : undefined}
945
- data-pin={pin?.pin}
946
- data-pin-edge={pin?.edge}
947
- class={typeof col.class === 'function' ? col.class(row) : col.class}
948
- class:slean-table-ellipsis={!!col.ellipsis}
949
- title={col.ellipsis &&
950
- (typeof col.ellipsis === 'boolean' || col.ellipsis.showTitle !== false)
951
- ? String(value ?? '')
952
- : undefined}
953
- style={`${pin?.style ?? ''}text-align:${col.align ?? 'start'};`}
954
- rowspan={span?.rows}
955
- colspan={span?.columns}
956
- tabindex={cellTabindex(node.id, col.id)}
957
- onkeydown={(e) => cellKey(e, row, col)}
958
- ondblclick={() => startEdit(row, col)}
959
- onclick={(e) => {
960
- if (focusableCells) current = { row: node.id, column: col.id };
961
- const target = e.target as HTMLElement;
962
- const action = target.closest<HTMLElement>('[data-action]');
963
- if (action && e.currentTarget.contains(action)) {
964
- onAction?.(action.dataset.action ?? '', row, action, index);
965
- return;
966
- }
967
- const control = target.closest(NOT_ROW_CLICK);
968
- if (control && e.currentTarget.contains(control)) return;
969
- if (onCellRangeChange) {
970
- const position = { row: node.id, column: col.id };
971
- onCellRangeChange({
972
- start: e.shiftKey && cellRange ? cellRange.start : position,
973
- end: position
974
- });
975
- }
976
- if (expandRowByClick && expandable(node, row)) toggleExpanded(node.id, row);
977
- onRowClick?.(row, e, index);
978
- }}
979
- >
980
- {#if editing?.id === node.id && editing.column === col.id}<div
981
- class="slean-table-editor"
982
- >
983
- {#if col.editor === 'select'}<select
984
- class="slean-table-input"
985
- aria-label={col.header}
986
- bind:value={editing.value}
987
- disabled={saving}
988
- onkeydown={(e) => editorKey(e, col)}
989
- >{#each col.options ?? [] as option (option.value)}<option
990
- value={option.value}>{option.label}</option
991
- >{/each}</select
992
- >{:else}<input
993
- class="slean-table-input"
994
- aria-label={col.header}
995
- type={col.editor === 'number'
996
- ? 'number'
997
- : col.editor === 'date'
998
- ? 'date'
999
- : 'text'}
1000
- value={editing.value}
1001
- disabled={saving}
1002
- oninput={(e) => {
1003
- if (editing) editing.value = e.currentTarget.value;
1004
- }}
1005
- onkeydown={(e) => editorKey(e, col)}
1006
- />{/if}
1007
- <button
1008
- type="button"
1009
- class="slean-table-button"
1010
- disabled={saving}
1011
- onclick={() => save(col)}>{m.save}</button
1012
- ><button
1013
- type="button"
1014
- class="slean-table-button"
1015
- disabled={saving}
1016
- onclick={endEdit}>{m.cancel}</button
1017
- >{#if editError}<span role="alert">{editError}</span>{/if}
1018
- </div>{:else}<div
1019
- class="slean-table-cell"
1020
- style:padding-inline-start={ci === 0 && node.depth
1021
- ? `${node.depth * indentSize}px`
1022
- : undefined}
1023
- >
1024
- {#if ci === 0 && movable}<span class="slean-table-row-move"
1025
- ><button
1026
- type="button"
1027
- class="slean-table-button"
1028
- aria-label={`${m.moveUp}: ${nameOf(node, row)}`}
1029
- disabled={index === 0}
1030
- onclick={() => {
1031
- const target = model.rows[index - 1];
1032
- if (target) onRowMove?.(node.id, target.id);
1033
- }}>{@render icon('asc')}</button
1034
- ><button
1035
- type="button"
1036
- class="slean-table-button"
1037
- aria-label={`${m.moveDown}: ${nameOf(node, row)}`}
1038
- disabled={index === model.rows.length - 1}
1039
- onclick={() => {
1040
- const target = model.rows[index + 1];
1041
- if (target) onRowMove?.(node.id, target.id);
1042
- }}>{@render icon('desc')}</button
1043
- ></span
1044
- >{/if}
1045
- {#if ci === 0 && !expander && expandable(node, row)}<button
1046
- type="button"
1047
- class="slean-table-button slean-table-expand"
1048
- aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
1049
- aria-expanded={open}
1050
- onclick={() => toggleExpanded(node.id, row)}
1051
- >{@render icon(open ? 'collapse' : 'expand')}</button
1052
- >{:else if ci === 0 && !expander && (rowDetails || getSubRows)}<span
1053
- class="slean-table-expand-spacer"
1054
- ></span>{/if}
1055
- {#if col.cell}{@render col.cell(context)}{:else}{@const text = textOf(
1056
- col,
1057
- value,
1058
- row
1059
- )}{#if text !== null}{text}{:else if placeholder}<span
1060
- class="slean-table-placeholder">{placeholder}</span
1061
- >{/if}{/if}
1062
- {#if editable(row, col)}<button
1063
- type="button"
1064
- class="slean-table-button slean-table-edit"
1065
- tabindex="-1"
1066
- aria-label={`${m.edit}: ${col.header}`}
1067
- onclick={() => startEdit(row, col)}>{@render icon('edit')}</button
1068
- >{/if}
1069
- </div>{/if}
1070
- </td>{/if}
1071
- {/each}
1072
- </tr>
1073
- {#if rowDetails && open && rowExpandable(row)}<tr class="slean-table-detail"
1074
- ><td colspan={span}
1075
- >{@render rowDetails(row, {
1076
- rowId: node.id,
1077
- index,
1078
- collapse: () => toggleExpanded(node.id, row, false)
1079
- })}</td
1080
- ></tr
1081
- >{/if}
1082
- {/if}
1083
- {/each}
1084
- </tbody>
1085
- {#if summary}<tfoot
1086
- ><tr
1087
- >{@render leading()}{#each cols as col (col.id)}<td {...pinned(col)}
1088
- >{summary[col.id] ?? ''}</td
1710
+ {...pinned(col)}
1711
+ role={grid ? 'cell' : undefined}>{summary[col.id] ?? ''}</td
1089
1712
  >{/each}</tr
1090
1713
  ></tfoot
1091
1714
  >{/if}