@svelte-lean/table 0.12.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @svelte-lean/table
2
2
 
3
+ ## 0.13.1
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.13.0
8
+
9
+ ### Minor Changes
10
+
11
+ - cc23cc5: `DataTable` reorders rows live. With `onRowMove` the table has a leading grip column (`slean-table-mover`, sticky at the start edge, `--slean-table-move-width`, 36px). The leading control columns now come in one order: expand, grip, selection (the expand column used to follow the selection column). Dragging a grip lifts its row after 3px: the row (with its open details row) follows the pointer, the rows it passes slide out of its way so the drop position shows before release, and the scrolling container scrolls while the row is held near its top or bottom edge. Release calls `onRowMove(source, target)` and the row glides into the place the application gave it; Escape, a cancelled pointer or a move the application does not make slides every row back. On a focused grip, ArrowUp and ArrowDown move the row one place and Home and End to the first or last place on the page, and focus stays on the grip. The grips are hidden while the rows are sorted, filtered or searched (the column stays); a tree or a custom row model has no grip column. Pointer moves write transforms on the row elements only; the listeners of a drag exist only while it lasts, and a press reaches one delegated listener for the whole table.
12
+
13
+ The Move up and Move down buttons in the first cell and native HTML drag and drop on the rows (`draggable`, `dragstart`/`dragover`/`drop`) are removed, with their `.slean-table-row-move` class. New: the `moveRow` message (`'Drag to reorder'`, tr `'Sürükleyerek sırala'`) names the grips and the grip column; `moveUp` and `moveDown` stay in `TableMessages` but are deprecated and unused. New tokens: `--slean-table-row-lifted-shadow` and `--slean-table-row-lifted` (the dragged row's shadow and background); the root carries `data-row-dragging` during a drag and the dragged rows `data-lifted`. `@svelte-lean/styles` adds the `--slean-icon-grip` token, which the grip reads when present.
14
+
15
+ Size budgets raised for this feature: the renderer from 22000 B to 22700 B gzip (22614 B measured, was 20710 B), the table stylesheet from 7300 B to 7700 B gzip (7613 B measured, was 7021 B).
16
+
17
+ ## 0.12.1
18
+
19
+ No changes in this release.
20
+
3
21
  ## 0.12.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -41,7 +41,8 @@ Peer dependency: `svelte ^5.0.0`.
41
41
  ```
42
42
 
43
43
  Sorting, search, column filters, pagination, selection, expansion, tree rows, sticky headers, column
44
- pinning, resizing, visibility and ordering, inline editing, cell ranges, row reordering and loading,
44
+ pinning, resizing, visibility and ordering, inline editing, cell ranges, row reordering (a grip column
45
+ dragged live or moved with the keyboard) and loading,
45
46
  empty and error states are props of `<DataTable />`. State is a serializable `TableState` object:
46
47
  pass `bind:state` or `onStateChange` to own it.
47
48
 
package/dist/messages.js CHANGED
@@ -19,6 +19,7 @@ export const en = {
19
19
  resize: 'Resize',
20
20
  moveLeft: 'Move earlier',
21
21
  moveRight: 'Move later',
22
+ moveRow: 'Drag to reorder',
22
23
  moveUp: 'Move up',
23
24
  moveDown: 'Move down',
24
25
  pin: 'Pin',
@@ -55,6 +56,7 @@ export const tr = {
55
56
  resize: 'Boyutlandır',
56
57
  moveLeft: 'Öne taşı',
57
58
  moveRight: 'Arkaya taşı',
59
+ moveRow: 'Sürükleyerek sırala',
58
60
  moveUp: 'Yukarı taşı',
59
61
  moveDown: 'Aşağı taşı',
60
62
  pin: 'Sabitle',
package/dist/style.css CHANGED
@@ -77,6 +77,11 @@
77
77
  );
78
78
  --slean-table-row-selected: var(--slean-table-selected);
79
79
  --slean-table-pin-shadow: light-dark(oklch(0% 0 0 / 0.1), oklch(0% 0 0 / 0.45));
80
+ --slean-table-row-lifted-shadow: var(
81
+ --slean-shadow-md,
82
+ 0 4px 12px light-dark(oklch(0% 0 0 / 0.1), oklch(0% 0 0 / 0.5)),
83
+ 0 1px 3px light-dark(oklch(0% 0 0 / 0.08), oklch(0% 0 0 / 0.4))
84
+ );
80
85
  --slean-table-fetching-opacity: 0.55;
81
86
  /* Set per row by hover, stripes, selection or `rowClass`; reset here so it never leaks into a
82
87
  table nested in a detail row. */
@@ -470,7 +475,7 @@
470
475
  }
471
476
 
472
477
  /* Row controls: 24px ghost icon buttons. */
473
- :where(.slean-table-expand, .slean-table-edit, .slean-table-row-move > .slean-table-button) {
478
+ :where(.slean-table-expand, .slean-table-edit, .slean-table-grip) {
474
479
  min-inline-size: 1.5rem;
475
480
  min-block-size: 1.5rem;
476
481
  padding: 0;
@@ -482,10 +487,27 @@
482
487
  :where(
483
488
  .slean-table-expand:hover,
484
489
  .slean-table-edit:hover,
485
- .slean-table-row-move > .slean-table-button:hover
490
+ .slean-table-grip:hover,
491
+ .slean-table-grip:focus-visible
486
492
  ) {
487
493
  color: var(--slean-table-fg);
488
494
  }
495
+ /* The row grip (onRowMove): the dots are masked on a pseudo-element, so the focus ring stays. */
496
+ :where(.slean-table-grip) {
497
+ cursor: grab;
498
+ touch-action: none;
499
+ }
500
+ :where(.slean-table-grip)::before {
501
+ content: '';
502
+ inline-size: 1rem;
503
+ block-size: 1rem;
504
+ background: currentColor;
505
+ mask: var(
506
+ --slean-icon-grip,
507
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4h.01M10 4h.01M6 8h.01M10 8h.01M6 12h.01M10 12h.01' stroke='black' stroke-width='2.5' stroke-linecap='round'/%3E%3C/svg%3E")
508
+ )
509
+ center / 1rem no-repeat;
510
+ }
489
511
  /* The expand toggle of a plain grid row: the chevron is drawn by the button itself (two borders
490
512
  of a turned square), one element per row instead of an SVG. It points down when open, and the
491
513
  other way in right-to-left tables. */
@@ -520,10 +542,6 @@
520
542
  :where(td:hover > .slean-table-cell > .slean-table-edit, .slean-table-edit:focus-visible) {
521
543
  opacity: 1;
522
544
  }
523
- :where(.slean-table-row-move) {
524
- display: flex;
525
- gap: 0.125rem;
526
- }
527
545
  :where(.slean-table-ellipsis > .slean-table-cell) {
528
546
  line-height: 1.5rem;
529
547
  min-width: 0;
@@ -570,13 +588,17 @@
570
588
  z-index: 4;
571
589
  }
572
590
 
573
- /* Leading control columns and pinned columns. */
574
- :where(.slean-table-selection, .slean-table-expander) {
591
+ /* Leading control columns (expand, grip, selection) and pinned columns. */
592
+ :where(.slean-table-mover, .slean-table-selection, .slean-table-expander) {
575
593
  position: sticky;
576
594
  inset-inline-start: 0;
577
595
  z-index: 3;
578
596
  text-align: center;
579
597
  }
598
+ :where(.slean-table-mover) {
599
+ width: var(--slean-table-move-width, 36px);
600
+ padding-inline: 0;
601
+ }
580
602
  :where(.slean-table-selection) {
581
603
  width: var(--slean-table-selection-width, 44px);
582
604
  padding-inline: 0;
@@ -585,7 +607,7 @@
585
607
  width: var(--slean-table-expand-width, 40px);
586
608
  padding-inline: 0;
587
609
  }
588
- :where(thead .slean-table-selection, thead .slean-table-expander) {
610
+ :where(thead .slean-table-mover, thead .slean-table-selection, thead .slean-table-expander) {
589
611
  z-index: 4;
590
612
  }
591
613
  :where(.slean-table-scroll > table > * > tr > [data-pin='left'][data-pin-edge]) {
@@ -607,6 +629,41 @@
607
629
  box-shadow: inset 0 0 0 1px var(--slean-table-accent);
608
630
  }
609
631
 
632
+ /* A dragged row (row-drag.ts): opaque and raised over the body, below the sticky header (5);
633
+ the other rows' sticky cells step back while it passes. */
634
+ :where(.slean-table-scroll > table > tbody > tr[data-lifted]) {
635
+ position: relative;
636
+ z-index: 4;
637
+ --slean-table-row-bg: var(--slean-table-row-lifted, var(--slean-table-bg));
638
+ box-shadow: var(--slean-table-row-lifted-shadow);
639
+ }
640
+ /* A skipped grid section clips and stacks on its own; a dragged row crosses sections. */
641
+ :where(
642
+ .slean-table[data-layout='grid'][data-row-dragging]
643
+ > .slean-table-scroll
644
+ > table
645
+ > tbody.slean-table-chunk
646
+ ) {
647
+ content-visibility: visible;
648
+ }
649
+ :where(.slean-table[data-row-dragging]) {
650
+ user-select: none;
651
+ -webkit-user-select: none;
652
+ }
653
+ :where(.slean-table[data-row-dragging], .slean-table[data-row-dragging] *) {
654
+ cursor: grabbing;
655
+ }
656
+ :where(
657
+ .slean-table[data-row-dragging]
658
+ > .slean-table-scroll
659
+ > table
660
+ > tbody
661
+ > tr:not([data-lifted])
662
+ > :is([data-pin], .slean-table-mover, .slean-table-selection, .slean-table-expander)
663
+ ) {
664
+ z-index: 1;
665
+ }
666
+
610
667
  /* Empty, loading, fetching, error. */
611
668
  :where(.slean-table-scroll > table > tbody > tr > .slean-table-empty) {
612
669
  padding: 3rem 1rem;
@@ -763,6 +820,13 @@
763
820
  :where(.slean-table-scroll > table > tbody) {
764
821
  transition: opacity var(--slean-table-duration);
765
822
  }
823
+ /* Rows slide aside and into place; the lifted row follows the pointer (inline none). */
824
+ :where(.slean-table[data-row-dragging] > .slean-table-scroll > table > tbody > tr) {
825
+ transition: transform 150ms ease;
826
+ }
827
+ :where(.slean-table[data-row-settling] > .slean-table-scroll > table > tbody > tr) {
828
+ transition: none;
829
+ }
766
830
  :where(.slean-table-skeleton) {
767
831
  background: linear-gradient(
768
832
  90deg,
@@ -896,7 +960,7 @@
896
960
  > table
897
961
  > *
898
962
  > tr
899
- > :is(.slean-table-selection, .slean-table-expander)
963
+ > :is(.slean-table-mover, .slean-table-selection, .slean-table-expander)
900
964
  ) {
901
965
  justify-content: center;
902
966
  }
@@ -999,7 +1063,7 @@
999
1063
  line-height: 1.5rem;
1000
1064
  }
1001
1065
  /* A grid body row paints its own ground once; its cells are transparent, except the sticky
1002
- ones (pinned, selection, expand), which must stay opaque over what scrolls beneath them. */
1066
+ ones (pinned, expand, grip, selection), which must stay opaque over what scrolls beneath them. */
1003
1067
  :where(.slean-table[data-layout='grid'] > .slean-table-scroll > table > tbody > tr) {
1004
1068
  background: var(--slean-table-row-bg, var(--slean-table-bg));
1005
1069
  }
@@ -1012,7 +1076,7 @@
1012
1076
  > table
1013
1077
  > tbody
1014
1078
  > tr
1015
- > :is([data-pin], .slean-table-selection, .slean-table-expander)
1079
+ > :is([data-pin], .slean-table-mover, .slean-table-selection, .slean-table-expander)
1016
1080
  ) {
1017
1081
  background: var(--slean-table-row-bg, var(--slean-table-bg));
1018
1082
  }
@@ -1027,7 +1091,7 @@
1027
1091
  .slean-table-pagination,
1028
1092
  .slean-table-resize,
1029
1093
  .slean-table-edit,
1030
- .slean-table-row-move
1094
+ .slean-table-grip
1031
1095
  ) {
1032
1096
  display: none !important;
1033
1097
  }
@@ -20,15 +20,21 @@
20
20
  } as const;
21
21
  type IconName = keyof typeof ICONS;
22
22
  /**
23
- * Widths of the leading control columns. Applications override `--slean-table-selection-width`
24
- * and `--slean-table-expand-width` in CSS; the fallbacks keep the table correct without
25
- * `style.css`. Read here, by the `<col>`s and by the pinned-column offsets only.
23
+ * Widths of the leading control columns. Applications override `--slean-table-move-width`,
24
+ * `--slean-table-selection-width` and `--slean-table-expand-width` in CSS; the fallbacks keep the
25
+ * table correct without `style.css`. Read here, by the `<col>`s and by the sticky offsets only.
26
26
  */
27
+ const MOVE_WIDTH = 'var(--slean-table-move-width, 36px)';
27
28
  const SELECTION_WIDTH = 'var(--slean-table-selection-width, 44px)';
28
29
  const EXPAND_WIDTH = 'var(--slean-table-expand-width, 40px)';
29
30
  /** A click on one of these inside a cell belongs to the control, not to the row. */
30
31
  const NOT_ROW_CLICK =
31
32
  'button,a,input,select,textarea,label,summary,[contenteditable],[role="menuitem"],[data-action],[data-no-row-click]';
33
+ /** The inline-start inset of a sticky column: the widths of the columns before it, summed. */
34
+ function inset(...widths: (string | false)[]): string {
35
+ const parts = widths.filter(Boolean);
36
+ return parts.length > 1 ? `calc(${parts.join(' + ')})` : parts[0] || '0px';
37
+ }
32
38
  /**
33
39
  * Numbered pages around the current one (0-based): the first, the last and the current ±1.
34
40
  * `null` marks a gap; a gap of one page shows that page instead.
@@ -67,6 +73,7 @@
67
73
  import { en } from '../messages.js';
68
74
  import { parseEdit } from '../editing.js';
69
75
  import { horizontalCell, verticalCell } from './navigation.js';
76
+ import { dragRow } from './row-drag.js';
70
77
  let {
71
78
  data,
72
79
  columns,
@@ -196,12 +203,28 @@
196
203
  });
197
204
  /** The expand toggle has a column of its own when asked for and when anything can expand. */
198
205
  const expander = $derived(expandColumn && (!!rowDetails || !!getSubRows));
206
+ /**
207
+ * The grip column (`onRowMove` on a flat row model). It stands whether or not a move is possible
208
+ * right now (`movable`), so sorting or filtering does not add or remove a column.
209
+ */
210
+ const mover = $derived(!!onRowMove && !getSubRows && getRowModel === createRowModel);
211
+ /** The leading control columns' widths, in order: expand, grip, selection. */
212
+ const leads = $derived(
213
+ [expander && EXPAND_WIDTH, mover && MOVE_WIDTH, selection && SELECTION_WIDTH].filter(
214
+ (part): part is string => !!part
215
+ )
216
+ );
217
+ /** Sticky insets of the grip and selection columns: the leading columns before each. */
218
+ const moverStart = $derived(expander ? EXPAND_WIDTH : undefined);
219
+ const selectionStart = $derived(
220
+ expander || mover ? inset(expander && EXPAND_WIDTH, mover && MOVE_WIDTH) : undefined
221
+ );
222
+ const expanderStart = '0px';
199
223
  /** Every column of a full-width row: the leading control columns and the data columns. */
200
- const span = $derived(cols.length + (selection ? 1 : 0) + (expander ? 1 : 0));
224
+ const span = $derived(cols.length + leads.length);
201
225
  let editing = $state<{ id: RowId; column: string; row: T; value: string } | null>(null);
202
226
  let editError = $state('');
203
227
  let saving = $state(false);
204
- let dragging = $state<RowId | null>(null);
205
228
  let root: HTMLDivElement;
206
229
  let viewport: HTMLDivElement;
207
230
  let tableElement: HTMLTableElement;
@@ -731,9 +754,7 @@
731
754
  */
732
755
  const tracks = $derived.by(() => {
733
756
  if (!grid) return null;
734
- const lead = [selection && SELECTION_WIDTH, expander && EXPAND_WIDTH].filter(
735
- (part): part is string => !!part
736
- );
757
+ const lead = leads;
737
758
  const parts = [...lead];
738
759
  let min = 0;
739
760
  for (const col of cols) {
@@ -765,7 +786,7 @@
765
786
  const headerLevels = $derived.by((): { cell: HeaderCell<T>; place?: string }[][] => {
766
787
  const levels = table.headers;
767
788
  if (!grid) return levels.map((row) => row.map((cell) => ({ cell })));
768
- const lead = (selection ? 1 : 0) + (expander ? 1 : 0);
789
+ const lead = leads.length;
769
790
  const result: { cell: HeaderCell<T>; place?: string }[][] = levels.map(() => []);
770
791
  levels.forEach((row, level) =>
771
792
  row.forEach((cell) => {
@@ -848,7 +869,7 @@
848
869
  */
849
870
  const pins = $derived.by(() => {
850
871
  const result = new Map<string, { pin: Exclude<Pin, false>; style: string; edge?: true }>();
851
- const lead = [selection && SELECTION_WIDTH, expander && EXPAND_WIDTH].filter(Boolean);
872
+ const lead = leads;
852
873
  for (const side of ['left', 'right'] as const) {
853
874
  const ordered = side === 'left' ? cols : [...cols].reverse();
854
875
  let offset = 0;
@@ -1055,19 +1076,18 @@
1055
1076
  }
1056
1077
  /**
1057
1078
  * A body cell takes the full template only when something in it can differ from a plain value:
1058
- * an editor, range selection, row moves, spans, or the first column's toggle and indentation.
1079
+ * an editor, range selection, spans, or the first column's toggle and indentation.
1059
1080
  * Every other cell is a `<td>` with one rendered snippet.
1060
1081
  */
1061
1082
  const fullCells = $derived(
1062
1083
  !!onEdit ||
1063
1084
  !!onCellRangeChange ||
1064
- movable ||
1065
1085
  cols.some((col) => (!grid && !!col.span) || typeof col.class === 'function' || !!col.ellipsis)
1066
1086
  );
1067
1087
  const firstSpecial = $derived(!!getSubRows || (!!rowDetails && !expander));
1068
1088
  /**
1069
1089
  * Grid rows with nothing but data cells take the plain row template: no group rows (the default
1070
- * row model, no tree), no selection column, detail rows only behind an expand column, no cell
1090
+ * row model, no tree), no grip or selection column, detail rows only behind an expand column, no cell
1071
1091
  * that needs the full template. The choice is made once per table, not per row, so a plain row
1072
1092
  * carries no per-cell branches. Everything else takes the full template.
1073
1093
  */
@@ -1076,6 +1096,7 @@
1076
1096
  !fullCells &&
1077
1097
  !firstSpecial &&
1078
1098
  !selection &&
1099
+ !mover &&
1079
1100
  !getSubRows &&
1080
1101
  getRowModel === createRowModel
1081
1102
  );
@@ -1134,10 +1155,70 @@
1134
1155
  if (node?.data !== undefined) onRowContextMenu?.(node.data, e);
1135
1156
  }
1136
1157
  function bodyKey(e: KeyboardEvent) {
1158
+ if (gripKey(e)) return;
1137
1159
  const hit = ownCell(e.target);
1138
1160
  if (hit && e.target === hit.cell) cellKey(e, hit.cell, hit.row, hit.col);
1139
1161
  else rowKey(e);
1140
1162
  }
1163
+ /** The data row of a grip of this table, or null when `target` is not one. */
1164
+ function gripRow(target: EventTarget | null): HTMLTableRowElement | null {
1165
+ const element = target as Element | null;
1166
+ const row = element?.classList?.contains('slean-table-grip')
1167
+ ? (element.parentElement?.parentElement ?? null)
1168
+ : null;
1169
+ return isOwnRow(row) ? row : null;
1170
+ }
1171
+ /**
1172
+ * Arrow keys on a grip move its row one place on the page, Home and End to the first or last
1173
+ * place; focus stays on the grip of the moved row. Returns whether the key was the grip's.
1174
+ */
1175
+ function gripKey(e: KeyboardEvent): boolean {
1176
+ const tr = gripRow(e.target);
1177
+ const node = tr && nodes.get(tr.getAttribute('data-row-id') ?? '');
1178
+ if (!movable || !node) return false;
1179
+ const rows = model.rows.filter((n) => n.data !== undefined);
1180
+ const index = rows.indexOf(node);
1181
+ const target =
1182
+ e.key === 'ArrowUp'
1183
+ ? rows[index - 1]
1184
+ : e.key === 'ArrowDown'
1185
+ ? rows[index + 1]
1186
+ : e.key === 'Home'
1187
+ ? rows[0]
1188
+ : e.key === 'End'
1189
+ ? rows[rows.length - 1]
1190
+ : null;
1191
+ if (target === null) return false;
1192
+ e.preventDefault();
1193
+ e.stopPropagation();
1194
+ if (!target || target === node) return true;
1195
+ onRowMove?.(node.id, target.id);
1196
+ void tick().then(() =>
1197
+ ownRows(`[data-row-id="${CSS.escape(String(node.id))}"]`)[0]
1198
+ ?.querySelector<HTMLElement>(':scope > .slean-table-mover > .slean-table-grip')
1199
+ ?.focus()
1200
+ );
1201
+ return true;
1202
+ }
1203
+ /** A press on a grip starts a live drag (`row-drag.ts`); one delegated listener for the table. */
1204
+ function bodyPointerDown(e: PointerEvent) {
1205
+ const row = gripRow(e.target);
1206
+ if (!movable || !row) return;
1207
+ dragRow({
1208
+ event: e,
1209
+ grip: e.target as HTMLElement,
1210
+ row,
1211
+ root,
1212
+ viewport,
1213
+ rows: () => [...ownRows()],
1214
+ move: (source, target) => {
1215
+ const a = nodes.get(source)?.id;
1216
+ const b = nodes.get(target)?.id;
1217
+ if (a !== undefined && b !== undefined) onRowMove?.(a, b);
1218
+ },
1219
+ update: tick
1220
+ });
1221
+ }
1141
1222
  /** Enter and Space on a focused clickable row, delegated to the body: no listener per row. */
1142
1223
  function rowKey(e: KeyboardEvent) {
1143
1224
  if (!focusableRows || (e.key !== 'Enter' && e.key !== ' ')) return;
@@ -1287,11 +1368,37 @@
1287
1368
  ? undefined
1288
1369
  : headerRowCount + model.pageIndex * s.pageSize + index + 1}
1289
1370
  tabindex={focusableRows ? 0 : undefined}
1290
- draggable={movable}
1291
1371
  >
1372
+ {#if expander}<td
1373
+ role={grid ? 'cell' : undefined}
1374
+ class="slean-table-expander"
1375
+ style:inset-inline-start={expanderStart}
1376
+ >{#if expandable(node, row)}<button
1377
+ type="button"
1378
+ class="slean-table-button slean-table-expand"
1379
+ aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
1380
+ aria-expanded={open}
1381
+ onclick={() => toggleExpanded(node.id, row)}
1382
+ >{@render icon(open ? 'collapse' : 'expand')}</button
1383
+ >{/if}</td
1384
+ >{/if}
1385
+ {#if mover}<td
1386
+ class="slean-table-mover"
1387
+ role={grid ? 'cell' : undefined}
1388
+ style:inset-inline-start={moverStart}
1389
+ >{#if movable}<button
1390
+ type="button"
1391
+ class="slean-table-button slean-table-grip"
1392
+ aria-label={`${m.moveRow}: ${nameOf(node, row)}`}
1393
+ aria-keyshortcuts="ArrowUp ArrowDown"
1394
+ ></button>{/if}</td
1395
+ >{/if}
1292
1396
  <!-- Two static branches: `data-slean` stays a static marker for build-time
1293
1397
  discovery (Tier 0, no runtime); a radio is not the checkbox primitive. -->
1294
- {#if selection === 'single'}<td class="slean-table-selection" role={grid ? 'cell' : undefined}
1398
+ {#if selection === 'single'}<td
1399
+ class="slean-table-selection"
1400
+ role={grid ? 'cell' : undefined}
1401
+ style:inset-inline-start={selectionStart}
1295
1402
  ><input
1296
1403
  type="radio"
1297
1404
  class="slean-table-check"
@@ -1301,7 +1408,10 @@
1301
1408
  disabled={!isRowSelectable(row)}
1302
1409
  onchange={() => table.toggleSelection(node.id)}
1303
1410
  /></td
1304
- >{:else if selection}<td class="slean-table-selection" role={grid ? 'cell' : undefined}
1411
+ >{:else if selection}<td
1412
+ class="slean-table-selection"
1413
+ role={grid ? 'cell' : undefined}
1414
+ style:inset-inline-start={selectionStart}
1305
1415
  ><input
1306
1416
  type="checkbox"
1307
1417
  class="slean-table-check"
@@ -1312,19 +1422,6 @@
1312
1422
  onchange={() => table.toggleSelection(node.id)}
1313
1423
  /></td
1314
1424
  >{/if}
1315
- {#if expander}<td
1316
- role={grid ? 'cell' : undefined}
1317
- class="slean-table-expander"
1318
- style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
1319
- >{#if expandable(node, row)}<button
1320
- type="button"
1321
- class="slean-table-button slean-table-expand"
1322
- aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
1323
- aria-expanded={open}
1324
- onclick={() => toggleExpanded(node.id, row)}
1325
- >{@render icon(open ? 'collapse' : 'expand')}</button
1326
- >{/if}</td
1327
- >{/if}
1328
1425
  {#if fullCells || firstSpecial}{#each cols as col, ci (col.id)}{#if fullCells || ci === 0}{@const value =
1329
1426
  valueOf(row, col)}{@const context = cellContext(
1330
1427
  node,
@@ -1401,27 +1498,7 @@
1401
1498
  ? `${node.depth * indentSize}px`
1402
1499
  : undefined}
1403
1500
  >
1404
- {#if ci === 0 && movable}<span class="slean-table-row-move"
1405
- ><button
1406
- type="button"
1407
- class="slean-table-button"
1408
- aria-label={`${m.moveUp}: ${nameOf(node, row)}`}
1409
- disabled={index === 0}
1410
- onclick={() => {
1411
- const target = model.rows[index - 1];
1412
- if (target) onRowMove?.(node.id, target.id);
1413
- }}>{@render icon('asc')}</button
1414
- ><button
1415
- type="button"
1416
- class="slean-table-button"
1417
- aria-label={`${m.moveDown}: ${nameOf(node, row)}`}
1418
- disabled={index === model.rows.length - 1}
1419
- onclick={() => {
1420
- const target = model.rows[index + 1];
1421
- if (target) onRowMove?.(node.id, target.id);
1422
- }}>{@render icon('desc')}</button
1423
- ></span
1424
- >{/if}{#if ci === 0 && !expander && expandable(node, row)}<button
1501
+ {#if ci === 0 && !expander && expandable(node, row)}<button
1425
1502
  type="button"
1426
1503
  class="slean-table-button slean-table-expand"
1427
1504
  aria-label={`${open ? m.collapse : m.expand}: ${nameOf(node, row)}`}
@@ -1492,13 +1569,18 @@
1492
1569
  {/if}{/snippet}
1493
1570
 
1494
1571
  <!-- Empty cells of the leading control columns, for rows that hold no controls. -->
1495
- {#snippet leading()}{#if selection}<td
1496
- class="slean-table-selection"
1497
- role={grid ? 'cell' : undefined}
1498
- ></td>{/if}{#if expander}<td
1572
+ {#snippet leading()}{#if expander}<td
1499
1573
  role={grid ? 'cell' : undefined}
1500
1574
  class="slean-table-expander"
1501
- style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
1575
+ style:inset-inline-start={expanderStart}
1576
+ ></td>{/if}{#if mover}<td
1577
+ class="slean-table-mover"
1578
+ role={grid ? 'cell' : undefined}
1579
+ style:inset-inline-start={moverStart}
1580
+ ></td>{/if}{#if selection}<td
1581
+ class="slean-table-selection"
1582
+ role={grid ? 'cell' : undefined}
1583
+ style:inset-inline-start={selectionStart}
1502
1584
  ></td>{/if}{/snippet}
1503
1585
 
1504
1586
  <!-- `dir` is spread so an absent prop leaves no attribute: for a literal dir={…} Svelte re-assigns
@@ -1607,7 +1689,8 @@
1607
1689
  aria-label={label}
1608
1690
  onscroll={onScroll}
1609
1691
  >
1610
- <!-- Row keys, clicks and drags are delegated to the table; rows and cells are the targets. -->
1692
+ <!-- Row keys, clicks and grip presses are delegated to the table; rows, cells and grips are
1693
+ the targets. -->
1611
1694
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1612
1695
  <table
1613
1696
  bind:this={tableElement}
@@ -1615,20 +1698,7 @@
1615
1698
  onclick={bodyClick}
1616
1699
  ondblclick={bodyDoubleClick}
1617
1700
  oncontextmenu={bodyContextMenu}
1618
- ondragstart={(e) => {
1619
- const id = ownRow(e.target)?.id;
1620
- if (movable && id !== undefined) dragging = id;
1621
- }}
1622
- ondragover={(e) => {
1623
- if (movable) e.preventDefault();
1624
- }}
1625
- ondrop={(e) => {
1626
- if (!movable) return;
1627
- e.preventDefault();
1628
- const id = ownRow(e.target)?.id;
1629
- if (dragging !== null && id !== undefined) onRowMove?.(dragging, id);
1630
- dragging = null;
1631
- }}
1701
+ onpointerdown={bodyPointerDown}
1632
1702
  role={grid ? 'table' : undefined}
1633
1703
  aria-label={label}
1634
1704
  aria-busy={loading || fetching}
@@ -1640,8 +1710,10 @@
1640
1710
  >
1641
1711
  {#if caption}<caption>{caption}</caption>{/if}
1642
1712
  {#if !grid}<colgroup
1643
- >{#if selection}<col style="width:{SELECTION_WIDTH}" />{/if}{#if expander}<col
1644
- style="width:{EXPAND_WIDTH}"
1713
+ >{#if expander}<col style="width:{EXPAND_WIDTH}" />{/if}{#if mover}<col
1714
+ style="width:{MOVE_WIDTH}"
1715
+ />{/if}{#if selection}<col
1716
+ style="width:{SELECTION_WIDTH}"
1645
1717
  />{/if}{#each cols as col (col.id)}<col
1646
1718
  data-column={col.id}
1647
1719
  style:width={colWidth(col)}
@@ -1652,11 +1724,28 @@
1652
1724
  role={grid ? 'row' : undefined}
1653
1725
  aria-rowindex={ariaRowCount === undefined ? undefined : level + 1}
1654
1726
  >
1727
+ {#if expander && level === leadLevel}<th
1728
+ scope="col"
1729
+ role={grid ? 'columnheader' : undefined}
1730
+ rowspan={grid ? undefined : table.headers.length}
1731
+ class="slean-table-expander"
1732
+ style:inset-inline-start={expanderStart}
1733
+ ><span class="slean-table-visually-hidden">{m.expandHeader}</span></th
1734
+ >{/if}
1735
+ {#if mover && level === leadLevel}<th
1736
+ scope="col"
1737
+ role={grid ? 'columnheader' : undefined}
1738
+ rowspan={grid ? undefined : table.headers.length}
1739
+ class="slean-table-mover"
1740
+ style:inset-inline-start={moverStart}
1741
+ ><span class="slean-table-visually-hidden">{m.moveRow}</span></th
1742
+ >{/if}
1655
1743
  {#if selection && level === leadLevel}<th
1656
1744
  scope="col"
1657
1745
  role={grid ? 'columnheader' : undefined}
1658
1746
  rowspan={grid ? undefined : table.headers.length}
1659
1747
  class="slean-table-selection"
1748
+ style:inset-inline-start={selectionStart}
1660
1749
  >{#if selection === 'multiple' && !hideSelectAll}<input
1661
1750
  type="checkbox"
1662
1751
  class="slean-table-check"
@@ -1669,14 +1758,6 @@
1669
1758
  />{:else}<span class="slean-table-visually-hidden">{m.selectionHeader}</span
1670
1759
  >{/if}</th
1671
1760
  >{/if}
1672
- {#if expander && level === leadLevel}<th
1673
- scope="col"
1674
- role={grid ? 'columnheader' : undefined}
1675
- rowspan={grid ? undefined : table.headers.length}
1676
- class="slean-table-expander"
1677
- style:inset-inline-start={selection ? SELECTION_WIDTH : '0px'}
1678
- ><span class="slean-table-visually-hidden">{m.expandHeader}</span></th
1679
- >{/if}
1680
1761
  {#each row as { cell: h, place } (h.key)}{@const col = h.column}{@const sort =
1681
1762
  table.sortOf(col)}{@const pin = h.colSpan === 1 ? pins.get(col.id) : undefined}<th
1682
1763
  scope={h.colSpan > 1 ? 'colgroup' : 'col'}
@@ -0,0 +1,18 @@
1
+ export interface RowDragOptions {
2
+ /** The `pointerdown` on a grip. */
3
+ event: PointerEvent;
4
+ grip: HTMLElement;
5
+ /** The data row of the grip. */
6
+ row: HTMLTableRowElement;
7
+ /** The `.slean-table` root: `data-row-dragging`, `data-row-settling`. */
8
+ root: HTMLElement;
9
+ /** The `.slean-table-scroll` region. */
10
+ viewport: HTMLElement;
11
+ /** The table's own data rows, in document order (never the rows of a nested table). */
12
+ rows: () => HTMLTableRowElement[];
13
+ /** Reports a drop on another place, by `data-row-id`. */
14
+ move: (source: string, target: string) => void;
15
+ /** Resolves once the application's answer to `move` is in the document (Svelte's `tick`). */
16
+ update: () => Promise<void>;
17
+ }
18
+ export declare function dragRow(options: RowDragOptions): void;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Live row dragging for `<DataTable />` (`onRowMove`). DOM only: no Svelte state changes while the
3
+ * pointer moves; transforms are written straight to the row elements, and only when they change.
4
+ *
5
+ * A press on a grip lifts its row after `ACTIVATION` pixels. The lifted row (and its detail rows)
6
+ * follows the pointer; the rows between its place and the place under it slide out of its way, so
7
+ * the drop position is visible before release. Release reports the move (`move`), waits for the
8
+ * application's update (`update`) and lets the row glide from where it was dropped into the place
9
+ * it now has. A cancel (Escape, `pointercancel`, a lost capture), or a move the application did not
10
+ * make, slides every row back. Near the visible edge of the scrolling container the container
11
+ * scrolls, on a timer (a hidden tab runs no animation frames).
12
+ *
13
+ * Every listener exists only for the length of one press and is removed through one
14
+ * `AbortController`. Nothing here runs at module evaluation.
15
+ */
16
+ const ACTIVATION = 3;
17
+ const EDGE = 48;
18
+ const SPEED = 20;
19
+ const INTERVAL = 16;
20
+ /** The slide of the rows (`style.css`), and a little more, before the end state is cleaned up. */
21
+ const SETTLE_MS = 200;
22
+ /** The cleanup of a drop still sliding into place, per table root: a new press runs it first. */
23
+ const finishing = new WeakMap();
24
+ function blockOf(row) {
25
+ const rows = [row];
26
+ for (let next = row.nextElementSibling; next?.matches('tr.slean-table-detail'); next = next.nextElementSibling)
27
+ rows.push(next);
28
+ return rows;
29
+ }
30
+ function keyOf(row) {
31
+ return row.getAttribute('data-row-id') ?? '';
32
+ }
33
+ function translate(rows, y) {
34
+ for (const row of rows)
35
+ row.style.transform = y ? `translateY(${y}px)` : '';
36
+ }
37
+ /** The nearest container above the table that scrolls vertically; the document otherwise. */
38
+ function scrollerOf(viewport) {
39
+ const doc = viewport.ownerDocument;
40
+ for (let element = viewport; element && element !== doc.body && element !== doc.documentElement; element = element.parentElement) {
41
+ if (element.scrollHeight > element.clientHeight + 1 &&
42
+ /auto|scroll|overlay/.test(getComputedStyle(element).overflowY))
43
+ return element;
44
+ }
45
+ return doc.scrollingElement ?? doc.documentElement;
46
+ }
47
+ export function dragRow(options) {
48
+ const { event, grip, root } = options;
49
+ if (event.button !== 0 || !event.isPrimary)
50
+ return;
51
+ finishing.get(root)?.();
52
+ const doc = grip.ownerDocument;
53
+ const startX = event.clientX;
54
+ const startY = event.clientY;
55
+ let y = startY;
56
+ const controller = new AbortController();
57
+ const { signal } = controller;
58
+ let blocks = [];
59
+ let dragged;
60
+ let from = -1;
61
+ let target = -1;
62
+ let scroller = doc.documentElement;
63
+ let page = false;
64
+ let scrollStart = 0;
65
+ let written = 0;
66
+ let timer;
67
+ try {
68
+ grip.setPointerCapture(event.pointerId);
69
+ }
70
+ catch {
71
+ // No capture (an engine or a test environment without it): the document listeners below
72
+ // still receive the moves while the pointer stays over the page.
73
+ }
74
+ function lift() {
75
+ blocks = options.rows().map((row) => {
76
+ const rows = blockOf(row);
77
+ const first = rows[0].getBoundingClientRect();
78
+ const last = rows[rows.length - 1].getBoundingClientRect();
79
+ return { key: keyOf(row), rows, top: first.top, height: last.bottom - first.top, shift: 0 };
80
+ });
81
+ from = target = blocks.findIndex((block) => block.rows[0] === options.row);
82
+ dragged = blocks[from];
83
+ if (!dragged)
84
+ return false;
85
+ scroller = scrollerOf(options.viewport);
86
+ page = scroller === doc.scrollingElement || scroller === doc.documentElement;
87
+ scrollStart = scroller.scrollTop;
88
+ root.setAttribute('data-row-dragging', '');
89
+ for (const row of dragged.rows) {
90
+ row.setAttribute('data-lifted', '');
91
+ // The lifted row follows the pointer exactly; the others slide (`style.css`).
92
+ row.style.transition = 'none';
93
+ }
94
+ doc.addEventListener('keydown', escape, { signal, capture: true });
95
+ (page ? doc : scroller).addEventListener('scroll', frame, { signal, passive: true });
96
+ timer = setInterval(autoScroll, INTERVAL);
97
+ return true;
98
+ }
99
+ function frame() {
100
+ if (!dragged)
101
+ return;
102
+ const dy = y - startY + scroller.scrollTop - scrollStart;
103
+ if (dy !== written) {
104
+ written = dy;
105
+ translate(dragged.rows, dy);
106
+ }
107
+ const centre = dragged.top + dy + dragged.height / 2;
108
+ const middle = (block) => block.top + block.height / 2;
109
+ let next = from;
110
+ while (next + 1 < blocks.length && centre > middle(blocks[next + 1]))
111
+ next++;
112
+ if (next === from)
113
+ while (next > 0 && centre < middle(blocks[next - 1]))
114
+ next--;
115
+ target = next;
116
+ blocks.forEach((block, i) => {
117
+ if (i === from)
118
+ return;
119
+ const shift = i > from && i <= next ? -dragged.height : i < from && i >= next ? dragged.height : 0;
120
+ if (shift === block.shift)
121
+ return;
122
+ block.shift = shift;
123
+ translate(block.rows, shift);
124
+ });
125
+ }
126
+ /** Scrolls while the pointer is within `EDGE` of the container's visible top or bottom. */
127
+ function autoScroll() {
128
+ const height = doc.documentElement.clientHeight;
129
+ const box = page ? { top: 0, bottom: height } : scroller.getBoundingClientRect();
130
+ const top = Math.max(box.top, 0);
131
+ const bottom = Math.min(box.bottom, height);
132
+ const speed = (distance) => Math.ceil(SPEED * (1 - Math.max(0, distance) / EDGE));
133
+ const step = y - top < EDGE ? -speed(y - top) : bottom - y < EDGE ? speed(bottom - y) : 0;
134
+ if (!step)
135
+ return;
136
+ const before = scroller.scrollTop;
137
+ scroller.scrollTop = before + step;
138
+ if (scroller.scrollTop !== before)
139
+ frame();
140
+ }
141
+ function stop() {
142
+ controller.abort();
143
+ clearInterval(timer);
144
+ }
145
+ /** Leaves the rows as they are now, after the slide back or into place. */
146
+ function later(rows) {
147
+ const done = () => {
148
+ clearTimeout(handle);
149
+ finishing.delete(root);
150
+ for (const row of rows) {
151
+ row.removeAttribute('data-lifted');
152
+ row.style.transform = '';
153
+ row.style.transition = '';
154
+ }
155
+ root.removeAttribute('data-row-settling');
156
+ root.removeAttribute('data-row-dragging');
157
+ };
158
+ const handle = setTimeout(done, SETTLE_MS);
159
+ finishing.set(root, done);
160
+ }
161
+ /** Every row slides back to its place. */
162
+ function back() {
163
+ for (const row of dragged.rows)
164
+ row.style.transition = '';
165
+ for (const block of blocks) {
166
+ block.shift = 0;
167
+ translate(block.rows, 0);
168
+ }
169
+ later(blocks.flatMap((block) => block.rows));
170
+ }
171
+ /**
172
+ * The application has answered the move. Positions are reset with transitions off; the dragged
173
+ * row (perhaps a new element: it is found again by its id) starts where it was dropped and
174
+ * slides into its new place.
175
+ */
176
+ function settle(dropped) {
177
+ const key = dragged.key;
178
+ const now = options.rows();
179
+ const row = now.find((element) => keyOf(element) === key);
180
+ if (row && now.indexOf(row) === from)
181
+ return back();
182
+ root.setAttribute('data-row-settling', '');
183
+ for (const block of blocks)
184
+ for (const element of block.rows) {
185
+ element.removeAttribute('data-lifted');
186
+ element.style.transform = '';
187
+ element.style.transition = '';
188
+ }
189
+ if (!row)
190
+ return later([]);
191
+ const rows = blockOf(row);
192
+ const offset = dropped - row.getBoundingClientRect().top;
193
+ for (const element of rows)
194
+ element.setAttribute('data-lifted', '');
195
+ translate(rows, offset);
196
+ void row.offsetHeight;
197
+ root.removeAttribute('data-row-settling');
198
+ translate(rows, 0);
199
+ later(rows);
200
+ }
201
+ function drop() {
202
+ stop();
203
+ if (target === from)
204
+ return back();
205
+ const dropped = dragged.rows[0].getBoundingClientRect().top;
206
+ options.move(dragged.key, blocks[target].key);
207
+ void options.update().then(() => settle(dropped));
208
+ }
209
+ function cancel() {
210
+ stop();
211
+ if (dragged)
212
+ back();
213
+ }
214
+ function escape(e) {
215
+ if (e.key !== 'Escape')
216
+ return;
217
+ e.preventDefault();
218
+ e.stopPropagation();
219
+ cancel();
220
+ }
221
+ doc.addEventListener('pointermove', (e) => {
222
+ if (e.pointerId !== event.pointerId)
223
+ return;
224
+ y = e.clientY;
225
+ if (!dragged) {
226
+ if (Math.hypot(e.clientX - startX, e.clientY - startY) < ACTIVATION)
227
+ return;
228
+ if (!lift())
229
+ return stop();
230
+ }
231
+ e.preventDefault();
232
+ frame();
233
+ }, { signal });
234
+ doc.addEventListener('pointerup', (e) => {
235
+ if (e.pointerId !== event.pointerId)
236
+ return;
237
+ if (dragged)
238
+ drop();
239
+ else
240
+ stop();
241
+ }, { signal });
242
+ doc.addEventListener('pointercancel', (e) => {
243
+ if (e.pointerId === event.pointerId)
244
+ cancel();
245
+ }, { signal });
246
+ grip.addEventListener('lostpointercapture', cancel, { signal });
247
+ }
package/dist/types.d.ts CHANGED
@@ -186,8 +186,14 @@ export interface TableMessages {
186
186
  /** Column controls: move a column earlier/later in the order. */
187
187
  moveLeft: string;
188
188
  moveRight: string;
189
- /** Row reordering buttons rendered when `onRowMove` is set. */
189
+ /**
190
+ * The row grip (`onRowMove`): the hidden header text of the grip column and, followed by the
191
+ * row's name, each grip's accessible name (`"Drag to reorder: <row>"`).
192
+ */
193
+ moveRow: string;
194
+ /** @deprecated Unused since the grip column (`moveRow`) replaced the move buttons. */
190
195
  moveUp: string;
196
+ /** @deprecated Unused since the grip column (`moveRow`) replaced the move buttons. */
191
197
  moveDown: string;
192
198
  /** Accessible name of the column-controls pin select (`"Pin: <column>"`). */
193
199
  pin: string;
@@ -338,7 +344,15 @@ export interface TableProps<T> {
338
344
  onScroll?: (event: Event) => void;
339
345
  onAction?: (action: string, row: T, trigger: HTMLElement, index: number) => void;
340
346
  rowClass?: (row: T, index: number) => string;
341
- /** Controlled drag/reorder; disabled while sorting, filtering or using tree/group models. */
347
+ /**
348
+ * Controlled row reordering: `source` is to take the place of `target` on the page (the
349
+ * application moves the row; the table never changes `data`). Adds a leading grip column
350
+ * (`slean-table-mover`). A grip is dragged live: the row follows the pointer, the other rows
351
+ * slide out of its way, and the container scrolls near its edges; Escape cancels. On a focused
352
+ * grip, ArrowUp and ArrowDown move the row one place, Home and End to the first or last place
353
+ * on the page. The grips are hidden while the rows are sorted, filtered or searched; the column
354
+ * is absent with a tree (`getSubRows`) or a custom row model (grouping, pivot).
355
+ */
342
356
  onRowMove?: (source: RowId, target: RowId) => void;
343
357
  cellRange?: CellRange | null;
344
358
  onCellRangeChange?: (range: CellRange | null) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelte-lean/table",
3
- "version": "0.12.0",
3
+ "version": "0.13.1",
4
4
  "description": "A Svelte-native table and data-grid engine that ships only the features you use.",
5
5
  "license": "MIT",
6
6
  "author": "Ömer Say",