@svelte-lean/table 0.12.1 → 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 +14 -0
- package/README.md +2 -1
- package/dist/messages.js +2 -0
- package/dist/style.css +77 -13
- package/dist/table/data-table.svelte +161 -80
- package/dist/table/row-drag.d.ts +18 -0
- package/dist/table/row-drag.js +247 -0
- package/dist/types.d.ts +16 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.12.1
|
|
4
18
|
|
|
5
19
|
No changes in this release.
|
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
|
|
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-
|
|
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-
|
|
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,
|
|
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-
|
|
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-
|
|
24
|
-
* and `--slean-table-expand-width` in CSS; the fallbacks keep the
|
|
25
|
-
* `style.css`. Read here, by the `<col>`s and by the
|
|
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 +
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
|
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
|
|
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 &&
|
|
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
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
|
1644
|
-
style="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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|