@svelte-lean/table 0.1.1 → 0.3.0

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