@delightstack/components 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/SKILL.md +149 -0
- package/bin/agents.js +63 -0
- package/dist/actions/Alert.svelte +202 -0
- package/dist/actions/Alert.svelte.d.ts +36 -0
- package/dist/actions/Alert.svelte.d.ts.map +1 -0
- package/dist/actions/Button.svelte +1450 -0
- package/dist/actions/Button.svelte.d.ts +56 -0
- package/dist/actions/Button.svelte.d.ts.map +1 -0
- package/dist/actions/ButtonGroup.svelte +111 -0
- package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
- package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
- package/dist/actions/CommandPalette.svelte +939 -0
- package/dist/actions/CommandPalette.svelte.d.ts +37 -0
- package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
- package/dist/actions/ContextMenu.svelte +138 -0
- package/dist/actions/ContextMenu.svelte.d.ts +54 -0
- package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
- package/dist/actions/Modal.svelte +474 -0
- package/dist/actions/Modal.svelte.d.ts +28 -0
- package/dist/actions/Modal.svelte.d.ts.map +1 -0
- package/dist/actions/Popover.svelte +1214 -0
- package/dist/actions/Popover.svelte.d.ts +31 -0
- package/dist/actions/Popover.svelte.d.ts.map +1 -0
- package/dist/actions/Portal.svelte +80 -0
- package/dist/actions/Portal.svelte.d.ts +17 -0
- package/dist/actions/Portal.svelte.d.ts.map +1 -0
- package/dist/actions/ThemeToggle.svelte +345 -0
- package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
- package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
- package/dist/actions/index.d.ts +13 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +10 -0
- package/dist/actions/scrollbar.d.ts +48 -0
- package/dist/actions/scrollbar.d.ts.map +1 -0
- package/dist/actions/scrollbar.js +404 -0
- package/dist/display/Accordion.svelte +586 -0
- package/dist/display/Accordion.svelte.d.ts +41 -0
- package/dist/display/Accordion.svelte.d.ts.map +1 -0
- package/dist/display/Avatar.svelte +527 -0
- package/dist/display/Avatar.svelte.d.ts +22 -0
- package/dist/display/Avatar.svelte.d.ts.map +1 -0
- package/dist/display/AvatarGroup.svelte +298 -0
- package/dist/display/AvatarGroup.svelte.d.ts +31 -0
- package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
- package/dist/display/Calendar.svelte +1366 -0
- package/dist/display/Calendar.svelte.d.ts +58 -0
- package/dist/display/Calendar.svelte.d.ts.map +1 -0
- package/dist/display/Chart.svelte +1426 -0
- package/dist/display/Chart.svelte.d.ts +35 -0
- package/dist/display/Chart.svelte.d.ts.map +1 -0
- package/dist/display/Code.svelte +780 -0
- package/dist/display/Code.svelte.d.ts +19 -0
- package/dist/display/Code.svelte.d.ts.map +1 -0
- package/dist/display/Comparison.svelte +686 -0
- package/dist/display/Comparison.svelte.d.ts +22 -0
- package/dist/display/Comparison.svelte.d.ts.map +1 -0
- package/dist/display/Counter.svelte +285 -0
- package/dist/display/Counter.svelte.d.ts +21 -0
- package/dist/display/Counter.svelte.d.ts.map +1 -0
- package/dist/display/Expand.svelte +48 -0
- package/dist/display/Expand.svelte.d.ts +9 -0
- package/dist/display/Expand.svelte.d.ts.map +1 -0
- package/dist/display/List.svelte +294 -0
- package/dist/display/List.svelte.d.ts +40 -0
- package/dist/display/List.svelte.d.ts.map +1 -0
- package/dist/display/ListContextReset.svelte +19 -0
- package/dist/display/ListContextReset.svelte.d.ts +7 -0
- package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
- package/dist/display/ListItem.svelte +834 -0
- package/dist/display/ListItem.svelte.d.ts +22 -0
- package/dist/display/ListItem.svelte.d.ts.map +1 -0
- package/dist/display/QR.svelte +1193 -0
- package/dist/display/QR.svelte.d.ts +23 -0
- package/dist/display/QR.svelte.d.ts.map +1 -0
- package/dist/display/SplitPane.svelte +744 -0
- package/dist/display/SplitPane.svelte.d.ts +25 -0
- package/dist/display/SplitPane.svelte.d.ts.map +1 -0
- package/dist/display/Stat.svelte +439 -0
- package/dist/display/Stat.svelte.d.ts +24 -0
- package/dist/display/Stat.svelte.d.ts.map +1 -0
- package/dist/display/Table.svelte +4654 -0
- package/dist/display/Table.svelte.d.ts +249 -0
- package/dist/display/Table.svelte.d.ts.map +1 -0
- package/dist/display/TableCellEditor.svelte +935 -0
- package/dist/display/TableCellEditor.svelte.d.ts +58 -0
- package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
- package/dist/display/Timeline.svelte +1258 -0
- package/dist/display/Timeline.svelte.d.ts +43 -0
- package/dist/display/Timeline.svelte.d.ts.map +1 -0
- package/dist/display/Tree.svelte +1740 -0
- package/dist/display/Tree.svelte.d.ts +74 -0
- package/dist/display/Tree.svelte.d.ts.map +1 -0
- package/dist/display/Typewriter.svelte +338 -0
- package/dist/display/Typewriter.svelte.d.ts +22 -0
- package/dist/display/Typewriter.svelte.d.ts.map +1 -0
- package/dist/display/index.d.ts +24 -0
- package/dist/display/index.d.ts.map +1 -0
- package/dist/display/index.js +18 -0
- package/dist/feedback/Callout.svelte +529 -0
- package/dist/feedback/Callout.svelte.d.ts +24 -0
- package/dist/feedback/Callout.svelte.d.ts.map +1 -0
- package/dist/feedback/Confetti.svelte +631 -0
- package/dist/feedback/Confetti.svelte.d.ts +90 -0
- package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
- package/dist/feedback/Progress.svelte +382 -0
- package/dist/feedback/Progress.svelte.d.ts +25 -0
- package/dist/feedback/Progress.svelte.d.ts.map +1 -0
- package/dist/feedback/Toast.svelte +967 -0
- package/dist/feedback/Toast.svelte.d.ts +54 -0
- package/dist/feedback/Toast.svelte.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/index.js +4 -0
- package/dist/form/Checkbox.svelte +449 -0
- package/dist/form/Checkbox.svelte.d.ts +27 -0
- package/dist/form/Checkbox.svelte.d.ts.map +1 -0
- package/dist/form/Fieldset.svelte +410 -0
- package/dist/form/Fieldset.svelte.d.ts +22 -0
- package/dist/form/Fieldset.svelte.d.ts.map +1 -0
- package/dist/form/FileUpload.svelte +934 -0
- package/dist/form/FileUpload.svelte.d.ts +41 -0
- package/dist/form/FileUpload.svelte.d.ts.map +1 -0
- package/dist/form/Form.svelte +530 -0
- package/dist/form/Form.svelte.d.ts +120 -0
- package/dist/form/Form.svelte.d.ts.map +1 -0
- package/dist/form/Input.svelte +2858 -0
- package/dist/form/Input.svelte.d.ts +66 -0
- package/dist/form/Input.svelte.d.ts.map +1 -0
- package/dist/form/Radio.svelte +507 -0
- package/dist/form/Radio.svelte.d.ts +39 -0
- package/dist/form/Radio.svelte.d.ts.map +1 -0
- package/dist/form/Range.svelte +912 -0
- package/dist/form/Range.svelte.d.ts +33 -0
- package/dist/form/Range.svelte.d.ts.map +1 -0
- package/dist/form/Rating.svelte +429 -0
- package/dist/form/Rating.svelte.d.ts +28 -0
- package/dist/form/Rating.svelte.d.ts.map +1 -0
- package/dist/form/Select.svelte +1933 -0
- package/dist/form/Select.svelte.d.ts +54 -0
- package/dist/form/Select.svelte.d.ts.map +1 -0
- package/dist/form/Toggle.svelte +645 -0
- package/dist/form/Toggle.svelte.d.ts +50 -0
- package/dist/form/Toggle.svelte.d.ts.map +1 -0
- package/dist/form/index.d.ts +15 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/layout/README.md +172 -0
- package/dist/media/Carousel.svelte +2424 -0
- package/dist/media/Carousel.svelte.d.ts +47 -0
- package/dist/media/Carousel.svelte.d.ts.map +1 -0
- package/dist/media/Gallery.svelte +2881 -0
- package/dist/media/Gallery.svelte.d.ts +82 -0
- package/dist/media/Gallery.svelte.d.ts.map +1 -0
- package/dist/media/Image.svelte +389 -0
- package/dist/media/Image.svelte.d.ts +33 -0
- package/dist/media/Image.svelte.d.ts.map +1 -0
- package/dist/media/PDF.svelte +1793 -0
- package/dist/media/PDF.svelte.d.ts +44 -0
- package/dist/media/PDF.svelte.d.ts.map +1 -0
- package/dist/media/Panorama.svelte +1391 -0
- package/dist/media/Panorama.svelte.d.ts +47 -0
- package/dist/media/Panorama.svelte.d.ts.map +1 -0
- package/dist/media/Video.svelte +2501 -0
- package/dist/media/Video.svelte.d.ts +58 -0
- package/dist/media/Video.svelte.d.ts.map +1 -0
- package/dist/media/carousel.d.ts +211 -0
- package/dist/media/carousel.d.ts.map +1 -0
- package/dist/media/carousel.js +408 -0
- package/dist/media/index.d.ts +11 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +5 -0
- package/dist/navigation/BottomSheet.svelte +636 -0
- package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
- package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
- package/dist/navigation/Breadcrumbs.svelte +611 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
- package/dist/navigation/Pagination.svelte +641 -0
- package/dist/navigation/Pagination.svelte.d.ts +27 -0
- package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
- package/dist/navigation/Steps.svelte +965 -0
- package/dist/navigation/Steps.svelte.d.ts +43 -0
- package/dist/navigation/Steps.svelte.d.ts.map +1 -0
- package/dist/navigation/Tabs.svelte +698 -0
- package/dist/navigation/Tabs.svelte.d.ts +41 -0
- package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
- package/dist/navigation/index.d.ts +8 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +5 -0
- package/package.json +139 -0
|
@@ -0,0 +1,4654 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface Column<T> {
|
|
3
|
+
/** The row property to read this column's value from (also used as the column id) */
|
|
4
|
+
key: string;
|
|
5
|
+
/** The header text for this column */
|
|
6
|
+
label: string;
|
|
7
|
+
/** Whether clicking the header sorts the table by this column */
|
|
8
|
+
sortable?: boolean;
|
|
9
|
+
/** CSS width of the column (e.g. `'200px'`, `'2fr'`) */
|
|
10
|
+
width?: string;
|
|
11
|
+
/** CSS minimum width of the column */
|
|
12
|
+
minWidth?: string;
|
|
13
|
+
/** Horizontal alignment of cell content */
|
|
14
|
+
align?: 'left' | 'center' | 'right';
|
|
15
|
+
/** Custom snippet to render the cell content (instead of the plain value) */
|
|
16
|
+
cell?: import('svelte').Snippet<[{ value: unknown; row: T; index: number }]>;
|
|
17
|
+
/** Custom snippet to render the header content (instead of `label`) */
|
|
18
|
+
header?: import('svelte').Snippet<[{ column: Column<T> }]>;
|
|
19
|
+
|
|
20
|
+
// ---- Inline editing (only active when the Table's `editable` prop is on) ----
|
|
21
|
+
/** Per-column override of the Table's `editable`. A predicate makes editability
|
|
22
|
+
* per-cell. Defaults to inheriting the Table's `editable` flag. */
|
|
23
|
+
editable?: boolean | ((row: T) => boolean);
|
|
24
|
+
/** Which editor control to use for this column. A Snippet is a custom editor
|
|
25
|
+
* (it receives a `CellEditorContext`). Defaults to `'text'`. */
|
|
26
|
+
editor?: CellEditorType | import('svelte').Snippet<[CellEditorContext<T>]>;
|
|
27
|
+
/** Static autocomplete / select options, filtered client-side by the current
|
|
28
|
+
* value. A `string[]` is shorthand for `{ value }` options. Required (or
|
|
29
|
+
* `onautocomplete`) for the `'select'` editor. */
|
|
30
|
+
options?: CellOption[] | string[];
|
|
31
|
+
/** Dynamic autocomplete options. Called on focus (with the current value) and
|
|
32
|
+
* on input (debounced 300ms). Return a list or a Promise of one. */
|
|
33
|
+
onautocomplete?: (
|
|
34
|
+
ctx: CellAutocompleteContext<T>,
|
|
35
|
+
) => CellOption[] | Promise<CellOption[]>;
|
|
36
|
+
/** Fires on every keystroke while editing (not a commit). */
|
|
37
|
+
oninput?: (ctx: CellEditContext<T>) => void;
|
|
38
|
+
/** Commit handler — fires on Enter or blur, only when the value changed. If it
|
|
39
|
+
* returns a Promise the cell shows a loading spinner; on rejection the cell
|
|
40
|
+
* keeps the value and shows an error ring so the user can retry. */
|
|
41
|
+
onedit?: (ctx: CellEditContext<T>) => void | Promise<void>;
|
|
42
|
+
/** Inline validation. Return an error message (blocks the commit and shows an
|
|
43
|
+
* error ring) or `null`/`''` to pass. May be async. */
|
|
44
|
+
validate?: (
|
|
45
|
+
value: unknown,
|
|
46
|
+
row: T,
|
|
47
|
+
index: number,
|
|
48
|
+
) => string | null | Promise<string | null>;
|
|
49
|
+
/** Raw editor string → stored value, applied before the value is committed
|
|
50
|
+
* (e.g. `Number(raw)`). Defaults to the raw string. */
|
|
51
|
+
parse?: (raw: string, row: T) => unknown;
|
|
52
|
+
/** Stored value → display string. Used for the resting cell text and the
|
|
53
|
+
* editor's initial text. Defaults to `String(value ?? '')`. */
|
|
54
|
+
format?: (value: unknown, row: T) => string;
|
|
55
|
+
/** Editor placeholder text. */
|
|
56
|
+
placeholder?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Built-in editor controls for an editable column. */
|
|
60
|
+
export type CellEditorType = 'text' | 'number' | 'select' | 'boolean' | 'date';
|
|
61
|
+
|
|
62
|
+
/** An autocomplete / select option (mirrors the Input component's options). */
|
|
63
|
+
export interface CellOption {
|
|
64
|
+
/** The value committed when this option is chosen */
|
|
65
|
+
value: string;
|
|
66
|
+
/** Display text for the option (defaults to `value`) */
|
|
67
|
+
label?: string;
|
|
68
|
+
/** Secondary descriptive text shown under the label */
|
|
69
|
+
description?: string;
|
|
70
|
+
/** Whether this option cannot be selected */
|
|
71
|
+
disabled?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Passed to `oninput` / `onedit` (and the Table-level `oncellinput` / `oncelledit`). */
|
|
75
|
+
export interface CellEditContext<T> {
|
|
76
|
+
/** The parsed value being committed (post-`parse`), or the live value for `oninput`. */
|
|
77
|
+
value: unknown;
|
|
78
|
+
/** The previous stored value. */
|
|
79
|
+
previous: unknown;
|
|
80
|
+
/** The row object being edited. */
|
|
81
|
+
row: T;
|
|
82
|
+
/** The row index within the table data. */
|
|
83
|
+
index: number;
|
|
84
|
+
/** The column definition of the edited cell. */
|
|
85
|
+
column: Column<T>;
|
|
86
|
+
/** The edited column's `key`. */
|
|
87
|
+
key: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Passed to a custom editor Snippet (`column.editor`). */
|
|
91
|
+
export interface CellEditorContext<T> {
|
|
92
|
+
/** The current draft value of the editor. */
|
|
93
|
+
value: unknown;
|
|
94
|
+
/** The row object being edited. */
|
|
95
|
+
row: T;
|
|
96
|
+
/** The row index within the table data. */
|
|
97
|
+
index: number;
|
|
98
|
+
/** The column definition of the edited cell. */
|
|
99
|
+
column: Column<T>;
|
|
100
|
+
/** Update the in-progress draft value. */
|
|
101
|
+
setValue: (value: unknown) => void;
|
|
102
|
+
/** Commit the current draft (runs `parse`/`validate`/`onedit`) and move down. */
|
|
103
|
+
commit: () => void;
|
|
104
|
+
/** Discard the draft and exit editing. */
|
|
105
|
+
cancel: () => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Passed to `column.onautocomplete`. */
|
|
109
|
+
export interface CellAutocompleteContext<T> {
|
|
110
|
+
/** The current editor text to filter options by. */
|
|
111
|
+
query: string;
|
|
112
|
+
/** The stored cell value. */
|
|
113
|
+
value: unknown;
|
|
114
|
+
/** The row object being edited. */
|
|
115
|
+
row: T;
|
|
116
|
+
/** The row index within the table data. */
|
|
117
|
+
index: number;
|
|
118
|
+
/** The column definition of the edited cell. */
|
|
119
|
+
column: Column<T>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Which element provides the scrollbar that drives virtual scrolling.
|
|
123
|
+
* - `'container'` (default): the Table's own scroll frame
|
|
124
|
+
* - `'parent'`: the Table's direct parent element
|
|
125
|
+
* - `'window'`: the page / document
|
|
126
|
+
* - a CSS selector string or an `HTMLElement`: any scrollable ancestor */
|
|
127
|
+
export type VirtualScroller =
|
|
128
|
+
| 'container'
|
|
129
|
+
| 'parent'
|
|
130
|
+
| 'window'
|
|
131
|
+
| (string & {})
|
|
132
|
+
| HTMLElement;
|
|
133
|
+
|
|
134
|
+
export interface VirtualScrollOptions {
|
|
135
|
+
/** Fixed row height in px. Auto-measured from the first row when omitted. */
|
|
136
|
+
row_height?: number;
|
|
137
|
+
/** Extra rows rendered above and below the viewport (default 8). */
|
|
138
|
+
overscan?: number;
|
|
139
|
+
/** Which element scrolls (default `'container'`). */
|
|
140
|
+
scroller?: VirtualScroller;
|
|
141
|
+
/** Bounds the scroll viewport height (e.g. 400 or '60vh'). Only applies to
|
|
142
|
+
* the `'container'` scroller (defaults to 420px there); ignored for other
|
|
143
|
+
* scrollers, whose height is owned by the chosen element. */
|
|
144
|
+
max_height?: string | number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `true` for sensible defaults, `false` to disable, or an options object. */
|
|
148
|
+
export type VirtualScroll = boolean | VirtualScrollOptions;
|
|
149
|
+
|
|
150
|
+
/** Pagination appearance + behavior. `true` enables it with sensible defaults
|
|
151
|
+
* (10 rows/page, a numbered pager with a "Showing X–Y of Z" summary beneath the
|
|
152
|
+
* table); pass an object to tune it. */
|
|
153
|
+
export interface PaginationConfig {
|
|
154
|
+
/** Pager style: a full numbered pager (default), `'simple'`
|
|
155
|
+
* (Prev · Page X of Y · Next), or `'compact'` (‹ X / Y ›). */
|
|
156
|
+
variant?: 'default' | 'simple' | 'compact';
|
|
157
|
+
/** Where the pager sits relative to the table (default `'bottom'`). */
|
|
158
|
+
position?: 'top' | 'bottom' | 'both';
|
|
159
|
+
/** Pager alignment. `'between'` (the default) puts the info/summary on the
|
|
160
|
+
* left and the controls on the right; the others align the whole pager. */
|
|
161
|
+
align?: 'start' | 'center' | 'end' | 'between';
|
|
162
|
+
/** Show the "Showing X–Y of Z" summary (default `true`). */
|
|
163
|
+
show_info?: boolean;
|
|
164
|
+
/** Offer a rows-per-page selector with these options. Providing the list
|
|
165
|
+
* turns the selector on; omit it to hide it. */
|
|
166
|
+
page_size_options?: number[];
|
|
167
|
+
/** Total row count for SERVER-side pagination. When set, the Table treats
|
|
168
|
+
* `data` as ALREADY being the current page (it does not slice) and derives
|
|
169
|
+
* the page count from this — bind `page`/`page_size` or use `onpagechange`
|
|
170
|
+
* to fetch each page yourself. */
|
|
171
|
+
total_items?: number;
|
|
172
|
+
/** Sibling pages shown either side of the current page (default `1`). */
|
|
173
|
+
sibling_count?: number;
|
|
174
|
+
/** Pages always shown at the start and end (default `1`). */
|
|
175
|
+
boundary_count?: number;
|
|
176
|
+
/** Pager button size (default `'1'`). */
|
|
177
|
+
size?: '0' | '1' | '2' | '3';
|
|
178
|
+
}
|
|
179
|
+
</script>
|
|
180
|
+
|
|
181
|
+
<script lang="ts" generics="T extends Record<string, unknown>">
|
|
182
|
+
import type { Snippet } from 'svelte';
|
|
183
|
+
import { tick, flushSync } from 'svelte';
|
|
184
|
+
import { ripple } from '@delightstack/utilities';
|
|
185
|
+
import { scrollbar } from '../actions/scrollbar';
|
|
186
|
+
import { slide } from 'svelte/transition';
|
|
187
|
+
import { quintOut } from 'svelte/easing';
|
|
188
|
+
import Progress from '../feedback/Progress.svelte';
|
|
189
|
+
import Pagination from '../navigation/Pagination.svelte';
|
|
190
|
+
import TableCellEditor from './TableCellEditor.svelte';
|
|
191
|
+
|
|
192
|
+
const propId = $props.id();
|
|
193
|
+
|
|
194
|
+
let {
|
|
195
|
+
/** Array of row data */
|
|
196
|
+
data = [] as T[],
|
|
197
|
+
|
|
198
|
+
/** Column definitions */
|
|
199
|
+
columns = [] as Column<T>[],
|
|
200
|
+
|
|
201
|
+
/** Stable per-row identity used as the keyed-`{#each}` key. Pass a field
|
|
202
|
+
* name (`'id'`) or a function (`(row) => row.id`). When set, reordering and
|
|
203
|
+
* re-sorting MOVE each row's existing DOM node to follow its data instead of
|
|
204
|
+
* re-keying rows by position — so selection checkmarks (and any per-row DOM
|
|
205
|
+
* state) ride along with the row rather than redrawing when the parent
|
|
206
|
+
* commits a new order. Defaults to the row's position, which is fine for
|
|
207
|
+
* static tables but makes checkmarks flash on `reorderable` commits. Strongly
|
|
208
|
+
* recommended whenever `reorderable` and `selectable` are combined. */
|
|
209
|
+
row_key = undefined as string | ((row: T) => string | number) | undefined,
|
|
210
|
+
|
|
211
|
+
/** Current sort column key */
|
|
212
|
+
sort_by = $bindable(undefined) as string | undefined,
|
|
213
|
+
|
|
214
|
+
/** Sort direction */
|
|
215
|
+
sort_direction = $bindable('asc') as 'asc' | 'desc',
|
|
216
|
+
|
|
217
|
+
/** Enable row selection */
|
|
218
|
+
selectable = false,
|
|
219
|
+
|
|
220
|
+
/** Selected rows */
|
|
221
|
+
selected = $bindable([]) as T[],
|
|
222
|
+
|
|
223
|
+
/** Alternating row backgrounds */
|
|
224
|
+
striped = false,
|
|
225
|
+
|
|
226
|
+
/** Compact padding */
|
|
227
|
+
dense = false,
|
|
228
|
+
|
|
229
|
+
/** Relaxed padding */
|
|
230
|
+
comfortable = false,
|
|
231
|
+
|
|
232
|
+
/** Sticky header */
|
|
233
|
+
sticky_header = true,
|
|
234
|
+
|
|
235
|
+
/** Enable column resizing — drag any column border (in the header or the
|
|
236
|
+
* body cells) to resize; double-click a border to auto-fit. */
|
|
237
|
+
resizable = false,
|
|
238
|
+
|
|
239
|
+
/** Row expansion */
|
|
240
|
+
expandable = false,
|
|
241
|
+
|
|
242
|
+
/** Group rows by column key */
|
|
243
|
+
group_by = undefined as string | undefined,
|
|
244
|
+
|
|
245
|
+
/** Enable CSV/JSON export */
|
|
246
|
+
exportable = false,
|
|
247
|
+
|
|
248
|
+
/** Loading skeleton */
|
|
249
|
+
skeleton = false,
|
|
250
|
+
|
|
251
|
+
/** Skeleton rows */
|
|
252
|
+
skeleton_count = 5,
|
|
253
|
+
|
|
254
|
+
/** Virtual scrolling. `true` enables it with sensible defaults, `false`
|
|
255
|
+
* disables it, or pass an options object — `{ row_height, overscan, scroller,
|
|
256
|
+
* max_height }`. Windows the rows so only those near the viewport render,
|
|
257
|
+
* keeping tables with thousands of rows fast. Applies to the flat
|
|
258
|
+
* (non-grouped) data path. The `scroller` option chooses what scrolls:
|
|
259
|
+
* the Table's own frame (default), its parent, the window, or any element. */
|
|
260
|
+
virtual_scroll = false as VirtualScroll,
|
|
261
|
+
|
|
262
|
+
/** Pagination. `true` turns it on with sensible defaults — 10 rows per page
|
|
263
|
+
* and a numbered pager with a "Showing X–Y of Z" summary beneath the table.
|
|
264
|
+
* Pass a config object to tune the look/behavior (`{ variant, position,
|
|
265
|
+
* align, show_info, page_size_options, total_items, sibling_count,
|
|
266
|
+
* boundary_count, size }`). By default the Table slices `data` to the current
|
|
267
|
+
* page for you (client-side). For SERVER-side paging, set `total_items` in
|
|
268
|
+
* the config and feed the Table only the current page, then bind `page` /
|
|
269
|
+
* `page_size` (or use `onpagechange`) to fetch. While active, pagination
|
|
270
|
+
* disables `virtual_scroll` and `reorderable`. */
|
|
271
|
+
pagination = false as boolean | PaginationConfig,
|
|
272
|
+
|
|
273
|
+
/** Current page, 1-based (bindable). */
|
|
274
|
+
page = $bindable(1),
|
|
275
|
+
|
|
276
|
+
/** Rows per page (bindable). */
|
|
277
|
+
page_size = $bindable(10),
|
|
278
|
+
|
|
279
|
+
/** Element ID */
|
|
280
|
+
id = propId,
|
|
281
|
+
|
|
282
|
+
/** Additional CSS classes */
|
|
283
|
+
class: class_name = '',
|
|
284
|
+
|
|
285
|
+
/** Custom empty state */
|
|
286
|
+
empty = undefined as Snippet | undefined,
|
|
287
|
+
|
|
288
|
+
/** Expanded row content */
|
|
289
|
+
expanded_row = undefined as Snippet<[T]> | undefined,
|
|
290
|
+
|
|
291
|
+
/** Sort changed */
|
|
292
|
+
onsort = undefined as
|
|
293
|
+
| ((payload: { column: string; direction: 'asc' | 'desc' }) => void)
|
|
294
|
+
| undefined,
|
|
295
|
+
|
|
296
|
+
/** Selection changed */
|
|
297
|
+
onselect = undefined as ((payload: { selected: T[] }) => void) | undefined,
|
|
298
|
+
|
|
299
|
+
/** Row clicked */
|
|
300
|
+
onrowclick = undefined as ((payload: { row: T; index: number }) => void) | undefined,
|
|
301
|
+
|
|
302
|
+
/** Column resized */
|
|
303
|
+
oncolumnresize = undefined as
|
|
304
|
+
| ((payload: { column: string; width: number }) => void)
|
|
305
|
+
| undefined,
|
|
306
|
+
|
|
307
|
+
/** Enable drag-to-reorder rows. On desktop a press-and-drag reorders
|
|
308
|
+
* immediately (a plain click still selects); on touch the row must be
|
|
309
|
+
* held briefly (long-press) before it lifts, so normal scrolling is
|
|
310
|
+
* preserved. Works alongside `selectable` (drag the whole selection at
|
|
311
|
+
* once) and `virtual_scroll`. Disabled while `group_by` is set. */
|
|
312
|
+
reorderable = false,
|
|
313
|
+
|
|
314
|
+
/** Reorder committed — fires AFTER the drop animation has finished, so the
|
|
315
|
+
* parent can swap in the new order without interrupting the animation.
|
|
316
|
+
* Assign `payload.newData` to your `data`. `from` is the moved rows' data
|
|
317
|
+
* indices (in visual order); `to` is the index in the new array where the
|
|
318
|
+
* block was inserted. */
|
|
319
|
+
onreorder = undefined as
|
|
320
|
+
| ((payload: { from: number[]; to: number; oldData: T[]; newData: T[] }) => void)
|
|
321
|
+
| undefined,
|
|
322
|
+
|
|
323
|
+
/** A reorder drag began (the row(s) lifted). */
|
|
324
|
+
onreorderstart = undefined as ((payload: { from: number[] }) => void) | undefined,
|
|
325
|
+
|
|
326
|
+
/** The row(s) were released — fires when the drop animation BEGINS, before
|
|
327
|
+
* `onreorder` commits. Useful for haptics/analytics. */
|
|
328
|
+
ondrop = undefined as ((payload: { from: number[]; to: number }) => void) | undefined,
|
|
329
|
+
|
|
330
|
+
/** Enable inline cell editing. Turns on the spreadsheet-style edit UX:
|
|
331
|
+
* focusing a cell (click or Tab) opens an inline editor, arrow keys / Tab move
|
|
332
|
+
* between cells, and hover highlights the cell (not the whole row). Each column
|
|
333
|
+
* opts in/out via `column.editable` and configures its editor + `onedit`
|
|
334
|
+
* callback. The Table stays controlled: editing fires `onedit`; the parent
|
|
335
|
+
* updates `data` (an optimistic value is shown while an async `onedit` runs). */
|
|
336
|
+
editable = false,
|
|
337
|
+
|
|
338
|
+
/** Table-wide commit handler — fires when a cell is edited and its column has no
|
|
339
|
+
* own `onedit`. May return a Promise (→ in-cell spinner). */
|
|
340
|
+
oncelledit = undefined as
|
|
341
|
+
| ((ctx: CellEditContext<T>) => void | Promise<void>)
|
|
342
|
+
| undefined,
|
|
343
|
+
|
|
344
|
+
/** Table-wide per-keystroke handler — fallback when a column has no own `oninput`. */
|
|
345
|
+
oncellinput = undefined as ((ctx: CellEditContext<T>) => void) | undefined,
|
|
346
|
+
|
|
347
|
+
/** The page or page size changed. Fires for both client- and server-side
|
|
348
|
+
* paging — use it to fetch the next page when you manage `data` yourself. */
|
|
349
|
+
onpagechange = undefined as
|
|
350
|
+
| ((payload: { page: number; page_size: number }) => void)
|
|
351
|
+
| undefined,
|
|
352
|
+
} = $props();
|
|
353
|
+
|
|
354
|
+
// ---- Internal state ----
|
|
355
|
+
let columnWidths = $state<Record<string, number>>({});
|
|
356
|
+
let resizing = $state<{
|
|
357
|
+
column_key: string;
|
|
358
|
+
start_x: number;
|
|
359
|
+
start_width: number;
|
|
360
|
+
} | null>(null);
|
|
361
|
+
// Column whose border the mouse is hovering (via the boundary hit zones, in the
|
|
362
|
+
// header or any body cell). Drives the full-height boundary preview — set only
|
|
363
|
+
// while the pointer is inside a resize zone, so the accent never shows when the
|
|
364
|
+
// mouse is merely somewhere over the column.
|
|
365
|
+
let hoveredResizeKey = $state<string | null>(null);
|
|
366
|
+
let expandedRows = $state(new Set<number>());
|
|
367
|
+
let collapsedGroups = $state(new Set<string>());
|
|
368
|
+
// Anchor + hovered row tracked as VISUAL positions so shift-range follows the
|
|
369
|
+
// rows as displayed (after sorting/grouping), not their order in `data`.
|
|
370
|
+
let lastSelectedVisual = $state<number | null>(null);
|
|
371
|
+
let showExportMenu = $state(false);
|
|
372
|
+
|
|
373
|
+
// ---- Inline editing state ----
|
|
374
|
+
// The active cell + the roving (tab-entry) cell are tracked by STABLE identity
|
|
375
|
+
// (`row_id` from `keyOf`, plus column key), so they survive sort/reorder and a
|
|
376
|
+
// virtual-scroll remount. Per-cell async state is keyed by `${row_id}:${col_key}`
|
|
377
|
+
// (never row object identity — selection proxies, see `shallowEqual`).
|
|
378
|
+
interface CellRef {
|
|
379
|
+
row_id: string | number;
|
|
380
|
+
col_key: string;
|
|
381
|
+
}
|
|
382
|
+
interface CellEdit {
|
|
383
|
+
row_id: string | number;
|
|
384
|
+
col_key: string;
|
|
385
|
+
prev: unknown;
|
|
386
|
+
next: unknown;
|
|
387
|
+
}
|
|
388
|
+
let active_cell = $state<CellRef | null>(null);
|
|
389
|
+
let roving_cell = $state<CellRef | null>(null);
|
|
390
|
+
let cellOptimistic = $state(new Map<string, unknown>()); // draft shown while saving
|
|
391
|
+
let cellPending = $state(new Set<string>()); // async save in flight → spinner
|
|
392
|
+
let cellError = $state(new Map<string, string>()); // failed save → error ring + msg
|
|
393
|
+
let cellSaved = $state(new Set<string>()); // brief success flash
|
|
394
|
+
let undoStack: CellEdit[] = [];
|
|
395
|
+
let redoStack: CellEdit[] = [];
|
|
396
|
+
|
|
397
|
+
// ---- Virtual scrolling state ----
|
|
398
|
+
let wrapperEl = $state<HTMLDivElement | null>(null);
|
|
399
|
+
let scrollEl = $state<HTMLDivElement | null>(null);
|
|
400
|
+
let tableEl = $state<HTMLTableElement | null>(null);
|
|
401
|
+
let resolvedScroller = $state<HTMLElement | Window | null>(null);
|
|
402
|
+
// Body offset (px the row list has scrolled past the viewport top) and the
|
|
403
|
+
// scroller's visible height — recomputed from geometry on scroll/resize, so
|
|
404
|
+
// the same windowing math works whether the container frame, the page, or a
|
|
405
|
+
// custom ancestor is the thing scrolling.
|
|
406
|
+
let virtualOffset = $state(0);
|
|
407
|
+
let virtualViewport = $state(0);
|
|
408
|
+
let measuredRowHeight = $state<number | null>(null);
|
|
409
|
+
let measuredHeaderHeight = $state(0);
|
|
410
|
+
// Measured heights of expanded detail blocks (keyed by data_index) so the
|
|
411
|
+
// windowing math can fold their extra height into the scroll offsets.
|
|
412
|
+
let expandedHeights = $state(new Map<number, number>());
|
|
413
|
+
|
|
414
|
+
// Shift-range preview state
|
|
415
|
+
let shiftHeld = $state(false);
|
|
416
|
+
let hoverIndex = $state<number | null>(null);
|
|
417
|
+
|
|
418
|
+
// ---- Reorder (drag-to-reorder) state ----
|
|
419
|
+
// Reactive bits the template reads:
|
|
420
|
+
let reorderDragging = $state(false); // a row is actively being dragged
|
|
421
|
+
let reorderDropping = $state(false); // the drop/settle animation is running
|
|
422
|
+
let armedDataIndex = $state<number | null>(null); // touch long-press armed this row
|
|
423
|
+
// data_index → translateY (px) applied to non-dragged rows to open the gap
|
|
424
|
+
let rowTransforms = $state(new Map<number, number>());
|
|
425
|
+
// data_index set of the rows currently lifted out (rendered hidden as placeholders)
|
|
426
|
+
let draggedDataSet = $state(new Set<number>());
|
|
427
|
+
// The lifted row(s) cloned into the floating overlay (in visual order). When
|
|
428
|
+
// dragging many rows the overlay collapses to a single card; `overlayMore`
|
|
429
|
+
// then holds the total count for the "N" badge (0 = show every dragged row).
|
|
430
|
+
let overlayRows = $state<{ row: T; data_index: number }[]>([]);
|
|
431
|
+
let overlayMore = $state(0);
|
|
432
|
+
let overlayEl = $state<HTMLDivElement | null>(null);
|
|
433
|
+
let suppressNextClick = false; // swallow the click that follows a drag
|
|
434
|
+
|
|
435
|
+
// Above this many dragged rows the overlay collapses to one card + a badge,
|
|
436
|
+
// so the floating element stays compact (the gap still reserves every row).
|
|
437
|
+
const REORDER_COLLAPSE_AT = 4;
|
|
438
|
+
|
|
439
|
+
// Non-reactive per-gesture context (mutated at ~60fps; kept off $state to
|
|
440
|
+
// avoid reactivity churn — the template only reads the $state above).
|
|
441
|
+
interface DragContext {
|
|
442
|
+
pointer_id: number;
|
|
443
|
+
pointer_type: string;
|
|
444
|
+
start_client_x: number;
|
|
445
|
+
start_client_y: number;
|
|
446
|
+
last_client_x: number;
|
|
447
|
+
last_client_y: number;
|
|
448
|
+
grab_vi: number; // visual index of the grabbed row
|
|
449
|
+
grab_row_top: number; // grabbed row's client top at pointer-down
|
|
450
|
+
grab_within_block: number; // px from block top to the grab point
|
|
451
|
+
grab_row_offset_in_block: number; // px from block top to the grabbed row's top
|
|
452
|
+
// How the overlay tracks the finger / settles, so a collapsed (single-card)
|
|
453
|
+
// overlay lands on the grabbed row's slot rather than the whole block's top.
|
|
454
|
+
overlay_grab_offset: number; // px from overlay top to the grab point
|
|
455
|
+
overlay_top_content_offset: number; // px from block top to overlay top
|
|
456
|
+
collapsed: boolean; // overlay shows one card + a count badge
|
|
457
|
+
dragged_vis: number[]; // visual indices being dragged (ascending)
|
|
458
|
+
dragged_set: Set<number>; // same, as a Set for O(1) lookups
|
|
459
|
+
dragged_rows: T[]; // the row objects, in visual order
|
|
460
|
+
dragged_row_set: Set<T>;
|
|
461
|
+
virtual: boolean;
|
|
462
|
+
rh: number; // uniform row height used in virtual mode
|
|
463
|
+
total: number; // total row count
|
|
464
|
+
block_height: number;
|
|
465
|
+
// Non-virtual measured layout, indexed by visual index (content-space,
|
|
466
|
+
// body-top = 0). The "delta" model preserves each row's measured position
|
|
467
|
+
// and only shifts it by the dragged height removed above it plus the block
|
|
468
|
+
// height inserted above it — so interleaved expanded rows stay correct.
|
|
469
|
+
top_by_vi: number[]; // vi → measured top
|
|
470
|
+
h_by_vi: number[]; // vi → measured height
|
|
471
|
+
removed_above: number[]; // vi → total dragged height with a smaller vi
|
|
472
|
+
r_rank: number[]; // vi → rank among non-dragged rows
|
|
473
|
+
r_vis: number[]; // non-dragged visual indices, in order
|
|
474
|
+
insert_at: number; // current insertion index (R-space)
|
|
475
|
+
last_insert_at: number;
|
|
476
|
+
block_top_content: number; // content-Y where the block will land
|
|
477
|
+
// Drag direction (with hysteresis) so the insert threshold is the row's top
|
|
478
|
+
// when moving down and its bottom when moving up — symmetric 50%-overlap.
|
|
479
|
+
prev_center: number | null;
|
|
480
|
+
move_dir: 1 | -1; // 1 = down, -1 = up
|
|
481
|
+
armed: boolean;
|
|
482
|
+
hold_timer: number | null;
|
|
483
|
+
raf: number | null;
|
|
484
|
+
settling: boolean;
|
|
485
|
+
settle_timeout: number | null;
|
|
486
|
+
}
|
|
487
|
+
let drag: DragContext | null = null;
|
|
488
|
+
|
|
489
|
+
const HOLD_DELAY = 240; // ms long-press before a touch drag arms
|
|
490
|
+
const SCROLL_TOLERANCE = 10; // px of pre-arm movement that means "scrolling"
|
|
491
|
+
const DRAG_THRESHOLD = 5; // px of movement (mouse) before a drag starts
|
|
492
|
+
const EDGE_SIZE = 56; // px edge band that triggers auto-scroll
|
|
493
|
+
const MAX_SCROLL_SPEED = 18; // px per frame at the very edge
|
|
494
|
+
const SETTLE_MS = 300; // drop animation duration
|
|
495
|
+
const SETTLE_EASE = 'cubic-bezier(0.2, 0.9, 0.25, 1)';
|
|
496
|
+
const LIFT_SCALE = 1.025; // "popped above" scale while dragging (centred)
|
|
497
|
+
const LIFT_IN_MS = 150; // ms to ease from the press into the lifted overlay
|
|
498
|
+
const LIFT_IN_EASE = 'cubic-bezier(0.2, 0.9, 0.25, 1)';
|
|
499
|
+
// The overlay starts its lift from the grabbed row's pressed scale (mouse) so
|
|
500
|
+
// the float grows out of the :active push instead of snapping in. This must
|
|
501
|
+
// match the `.row.clickable:active` scale.
|
|
502
|
+
const GRAB_PRESS_SCALE = 0.909;
|
|
503
|
+
|
|
504
|
+
function clamp(n: number, min: number, max: number): number {
|
|
505
|
+
return n < min ? min : n > max ? max : n;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ---- Reduced motion ----
|
|
509
|
+
function prefersReducedMotion(): boolean {
|
|
510
|
+
return (
|
|
511
|
+
typeof window !== 'undefined' &&
|
|
512
|
+
!!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---- Track Shift for range preview (only while selectable) ----
|
|
517
|
+
$effect(() => {
|
|
518
|
+
if (!selectable) return;
|
|
519
|
+
function onKey(e: KeyboardEvent) {
|
|
520
|
+
if (e.key === 'Shift') shiftHeld = e.type === 'keydown';
|
|
521
|
+
}
|
|
522
|
+
function onBlur() {
|
|
523
|
+
shiftHeld = false;
|
|
524
|
+
}
|
|
525
|
+
window.addEventListener('keydown', onKey);
|
|
526
|
+
window.addEventListener('keyup', onKey);
|
|
527
|
+
window.addEventListener('blur', onBlur);
|
|
528
|
+
return () => {
|
|
529
|
+
window.removeEventListener('keydown', onKey);
|
|
530
|
+
window.removeEventListener('keyup', onKey);
|
|
531
|
+
window.removeEventListener('blur', onBlur);
|
|
532
|
+
};
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ---- Measure header + row height for virtual scrolling ----
|
|
536
|
+
// The window math assumes a uniform row height; measuring the rendered header
|
|
537
|
+
// and first body row keeps it accurate across density modes without the
|
|
538
|
+
// consumer having to supply `row_height`.
|
|
539
|
+
$effect(() => {
|
|
540
|
+
if (!virtualActive) return;
|
|
541
|
+
// Re-measure when density changes.
|
|
542
|
+
void dense;
|
|
543
|
+
void comfortable;
|
|
544
|
+
const head = tableEl?.querySelector('thead tr') as HTMLElement | null;
|
|
545
|
+
if (head) {
|
|
546
|
+
const hh = head.getBoundingClientRect().height;
|
|
547
|
+
if (hh && hh !== measuredHeaderHeight) measuredHeaderHeight = hh;
|
|
548
|
+
}
|
|
549
|
+
if (vOpts.row_height == null) {
|
|
550
|
+
const el = tableEl?.querySelector('tbody tr.row') as HTMLElement | null;
|
|
551
|
+
if (el) {
|
|
552
|
+
const h = el.getBoundingClientRect().height;
|
|
553
|
+
if (h && h !== measuredRowHeight) measuredRowHeight = h;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
measureViewport();
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// ---- Resolve which element scrolls ----
|
|
560
|
+
$effect(() => {
|
|
561
|
+
if (!virtualActive) {
|
|
562
|
+
resolvedScroller = null;
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const s = vOpts.scroller;
|
|
566
|
+
let el: HTMLElement | Window | null = null;
|
|
567
|
+
if (s === 'window') el = window;
|
|
568
|
+
else if (s === 'container') el = scrollEl;
|
|
569
|
+
else if (s === 'parent') el = wrapperEl?.parentElement ?? null;
|
|
570
|
+
else if (typeof s === 'string') el = document.querySelector<HTMLElement>(s);
|
|
571
|
+
else if (s instanceof HTMLElement) el = s;
|
|
572
|
+
if (!el && typeof s === 'string' && s !== 'parent') {
|
|
573
|
+
console.warn(`[Table] virtual_scroll scroller "${s}" matched no element.`);
|
|
574
|
+
}
|
|
575
|
+
resolvedScroller = el;
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
// ---- Track the scroller's scroll position + viewport height ----
|
|
579
|
+
$effect(() => {
|
|
580
|
+
if (!virtualActive) return;
|
|
581
|
+
const scroller = resolvedScroller;
|
|
582
|
+
if (!scroller) return;
|
|
583
|
+
let raf = 0;
|
|
584
|
+
const onScroll = () => {
|
|
585
|
+
if (raf) return;
|
|
586
|
+
raf = requestAnimationFrame(() => {
|
|
587
|
+
raf = 0;
|
|
588
|
+
measureViewport();
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
measureViewport();
|
|
592
|
+
scroller.addEventListener('scroll', onScroll, { passive: true });
|
|
593
|
+
window.addEventListener('resize', onScroll);
|
|
594
|
+
let ro: ResizeObserver | undefined;
|
|
595
|
+
if (!(scroller instanceof Window)) {
|
|
596
|
+
ro = new ResizeObserver(onScroll);
|
|
597
|
+
ro.observe(scroller);
|
|
598
|
+
}
|
|
599
|
+
return () => {
|
|
600
|
+
scroller.removeEventListener('scroll', onScroll);
|
|
601
|
+
window.removeEventListener('resize', onScroll);
|
|
602
|
+
if (raf) cancelAnimationFrame(raf);
|
|
603
|
+
ro?.disconnect();
|
|
604
|
+
};
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// Recompute how far the row list has scrolled past the viewport top, in the
|
|
608
|
+
// scrolling element's coordinate space. The list begins at
|
|
609
|
+
// `tableTop + headerHeight`; its offset below the viewport top is therefore
|
|
610
|
+
// `viewportTop - listTop`. This is geometry-based (not `scrollTop`-based), so
|
|
611
|
+
// it works the same for the container frame, the page, or any ancestor — and
|
|
612
|
+
// correctly accounts for any content sitting above the Table.
|
|
613
|
+
function measureViewport() {
|
|
614
|
+
const scroller = resolvedScroller;
|
|
615
|
+
if (!scroller || !tableEl) return;
|
|
616
|
+
let viewportTop: number;
|
|
617
|
+
let viewportH: number;
|
|
618
|
+
if (scroller instanceof Window) {
|
|
619
|
+
viewportTop = 0;
|
|
620
|
+
viewportH = window.innerHeight;
|
|
621
|
+
} else {
|
|
622
|
+
const r = scroller.getBoundingClientRect();
|
|
623
|
+
viewportTop = r.top;
|
|
624
|
+
viewportH = scroller.clientHeight;
|
|
625
|
+
}
|
|
626
|
+
const listTop = tableEl.getBoundingClientRect().top + measuredHeaderHeight;
|
|
627
|
+
virtualOffset = viewportTop - listTop;
|
|
628
|
+
virtualViewport = viewportH;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ---- Sorted data ----
|
|
632
|
+
const sortedData = $derived.by(() => {
|
|
633
|
+
if (!sort_by || onsort) return data;
|
|
634
|
+
const key = sort_by;
|
|
635
|
+
const dir = sort_direction === 'asc' ? 1 : -1;
|
|
636
|
+
return [...data].sort((a, b) => {
|
|
637
|
+
const aVal = a[key];
|
|
638
|
+
const bVal = b[key];
|
|
639
|
+
if (aVal == null && bVal == null) return 0;
|
|
640
|
+
if (aVal == null) return 1;
|
|
641
|
+
if (bVal == null) return -1;
|
|
642
|
+
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
|
643
|
+
return aVal.localeCompare(bVal) * dir;
|
|
644
|
+
}
|
|
645
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
646
|
+
return (aVal - bVal) * dir;
|
|
647
|
+
}
|
|
648
|
+
return String(aVal).localeCompare(String(bVal)) * dir;
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
// ---- Pagination ----
|
|
653
|
+
// Slices the sorted rows down to the active page (client-side), or — when
|
|
654
|
+
// `total_items` is supplied — treats `data` as ALREADY being the current page
|
|
655
|
+
// (server-side) and just drives the pager. `flatRows`/`groupedData` build off
|
|
656
|
+
// `renderData` below, so each rendered row's `visual_index` is page-local while
|
|
657
|
+
// its `data_index` stays global (via `rowIndexMap`) — selection and inline
|
|
658
|
+
// editing keep working across pages. Bind `page`/`page_size` to observe/control.
|
|
659
|
+
const pgConfig = $derived.by(() => {
|
|
660
|
+
const o = pagination && pagination !== true ? pagination : {};
|
|
661
|
+
return {
|
|
662
|
+
variant: o.variant ?? 'default',
|
|
663
|
+
position: o.position ?? 'bottom',
|
|
664
|
+
align: o.align ?? 'between',
|
|
665
|
+
show_info: o.show_info ?? true,
|
|
666
|
+
page_size_options: o.page_size_options,
|
|
667
|
+
total_items: o.total_items,
|
|
668
|
+
sibling_count: o.sibling_count ?? 1,
|
|
669
|
+
boundary_count: o.boundary_count ?? 1,
|
|
670
|
+
size: o.size ?? ('1' as '0' | '1' | '2' | '3'),
|
|
671
|
+
};
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const paginationActive = $derived(!!pagination && !skeleton);
|
|
675
|
+
// Server-side mode: `data` is already one page, so we never slice — we only
|
|
676
|
+
// derive the page count and drive the pager (the consumer fetches each page).
|
|
677
|
+
const serverPaginated = $derived(paginationActive && pgConfig.total_items != null);
|
|
678
|
+
|
|
679
|
+
const pgTotalItems = $derived(
|
|
680
|
+
serverPaginated ? (pgConfig.total_items ?? 0) : sortedData.length,
|
|
681
|
+
);
|
|
682
|
+
const pgTotalPages = $derived(
|
|
683
|
+
Math.max(1, Math.ceil(pgTotalItems / Math.max(1, page_size))),
|
|
684
|
+
);
|
|
685
|
+
// The pager only shows when there's something to page through.
|
|
686
|
+
const showPager = $derived(paginationActive && pgTotalItems > 0);
|
|
687
|
+
|
|
688
|
+
// Keep `page` within range as the data, page size, sort, or filters change.
|
|
689
|
+
$effect(() => {
|
|
690
|
+
if (!paginationActive) return;
|
|
691
|
+
const clamped = clamp(page, 1, pgTotalPages);
|
|
692
|
+
if (clamped !== page) page = clamped;
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Client-side page slice (server mode renders `sortedData`/`data` as-is).
|
|
696
|
+
const pagedData = $derived.by(() => {
|
|
697
|
+
if (!paginationActive || serverPaginated) return sortedData;
|
|
698
|
+
const start = (page - 1) * page_size;
|
|
699
|
+
return sortedData.slice(start, start + page_size);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// The rows actually rendered: the page slice when paginating client-side,
|
|
703
|
+
// otherwise the full sorted set.
|
|
704
|
+
const renderData = $derived(
|
|
705
|
+
paginationActive && !serverPaginated ? pagedData : sortedData,
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
// ---- Row index map ----
|
|
709
|
+
// Rows in sortedData are the same object references as in `data` (just
|
|
710
|
+
// reordered), so an identity lookup gives each rendered row its stable index
|
|
711
|
+
// in `data`. Selection and expansion key off this stable `data_index` so they
|
|
712
|
+
// survive re-sorting; shift-range selection keys off the visual position.
|
|
713
|
+
const rowIndexMap = $derived.by(() => {
|
|
714
|
+
const m = new Map<T, number>();
|
|
715
|
+
data.forEach((row, i) => m.set(row, i));
|
|
716
|
+
return m;
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// Keyed-each key for a rendered row. A stable `row_key` makes Svelte move the
|
|
720
|
+
// row's DOM node when the order changes (so selection survives a reorder
|
|
721
|
+
// commit without redrawing); falling back to the data index re-keys rows by
|
|
722
|
+
// position, which is fine for tables whose order never changes underneath them.
|
|
723
|
+
function keyOf(row: T, dataIndex: number): string | number {
|
|
724
|
+
if (row_key === undefined) return dataIndex;
|
|
725
|
+
if (typeof row_key === 'function') return row_key(row);
|
|
726
|
+
const v = row[row_key];
|
|
727
|
+
return typeof v === 'string' || typeof v === 'number' ? v : String(v);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
interface RenderRow {
|
|
731
|
+
row: T;
|
|
732
|
+
data_index: number;
|
|
733
|
+
visual_index: number;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ---- Grouped data ----
|
|
737
|
+
interface Group {
|
|
738
|
+
key: string;
|
|
739
|
+
label: string;
|
|
740
|
+
rows: RenderRow[];
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const groupedData = $derived.by((): Group[] | null => {
|
|
744
|
+
if (!group_by) return null;
|
|
745
|
+
const groupKey = group_by;
|
|
746
|
+
const map = new Map<string, RenderRow[]>();
|
|
747
|
+
const order: string[] = [];
|
|
748
|
+
for (let i = 0; i < renderData.length; i++) {
|
|
749
|
+
const row = renderData[i];
|
|
750
|
+
const val = String(row[groupKey] ?? 'Other');
|
|
751
|
+
if (!map.has(val)) {
|
|
752
|
+
map.set(val, []);
|
|
753
|
+
order.push(val);
|
|
754
|
+
}
|
|
755
|
+
map.get(val)!.push({ row, data_index: rowIndexMap.get(row) ?? i, visual_index: i });
|
|
756
|
+
}
|
|
757
|
+
return order.map((key) => ({
|
|
758
|
+
key,
|
|
759
|
+
label: key,
|
|
760
|
+
rows: map.get(key)!,
|
|
761
|
+
}));
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// ---- Flat rows for rendering ----
|
|
765
|
+
const flatRows = $derived.by((): RenderRow[] => {
|
|
766
|
+
return renderData.map((row, i) => ({
|
|
767
|
+
row,
|
|
768
|
+
data_index: rowIndexMap.get(row) ?? i,
|
|
769
|
+
visual_index: i,
|
|
770
|
+
}));
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// ---- Virtual scrolling ----
|
|
774
|
+
// Windowing renders only the rows near the viewport (plus an overscan buffer)
|
|
775
|
+
// so tables with thousands of rows stay fast. It engages for the flat,
|
|
776
|
+
// non-grouped data path; grouped/skeleton/empty tables render normally. Heights
|
|
777
|
+
// are uniform (measured, or the `row_height` option); any expanded detail rows
|
|
778
|
+
// are measured and folded into the offset math so scroll positions stay
|
|
779
|
+
// accurate. The `scroller` option chooses which element scrolls.
|
|
780
|
+
|
|
781
|
+
// Normalise the `boolean | options` prop to concrete values used below.
|
|
782
|
+
const vOpts = $derived.by(() => {
|
|
783
|
+
const o: VirtualScrollOptions =
|
|
784
|
+
virtual_scroll && virtual_scroll !== true ? virtual_scroll : {};
|
|
785
|
+
return {
|
|
786
|
+
row_height: o.row_height,
|
|
787
|
+
overscan: o.overscan ?? 8,
|
|
788
|
+
scroller: o.scroller ?? 'container',
|
|
789
|
+
max_height: o.max_height,
|
|
790
|
+
};
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
const virtualActive = $derived(
|
|
794
|
+
!!virtual_scroll && !group_by && !skeleton && !paginationActive && data.length > 0,
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
// The frame owns the scrollbar only for the default `'container'` scroller;
|
|
798
|
+
// for any other scroller an outer element drives the scroll and the frame must
|
|
799
|
+
// not establish its own scroll container (see `.scroll.passthrough`).
|
|
800
|
+
const containerScroll = $derived(virtualActive && vOpts.scroller === 'container');
|
|
801
|
+
|
|
802
|
+
const densityRowEstimate = $derived(dense ? 33 : comfortable ? 57 : 45);
|
|
803
|
+
const effectiveRowHeight = $derived(
|
|
804
|
+
vOpts.row_height ?? measuredRowHeight ?? densityRowEstimate,
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
// max_height only makes sense for the container scroller; other scrollers own
|
|
808
|
+
// their own height, so it is ignored there.
|
|
809
|
+
const resolvedMaxHeight = $derived.by((): string | undefined => {
|
|
810
|
+
if (!containerScroll) return undefined;
|
|
811
|
+
const mh = vOpts.max_height;
|
|
812
|
+
if (mh != null) return typeof mh === 'number' ? `${mh}px` : mh;
|
|
813
|
+
return '420px';
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// Fallback viewport height for the first render (before the scroller is
|
|
817
|
+
// measured), so the initial window isn't empty during SSR/hydration.
|
|
818
|
+
const initialViewportEstimate = $derived.by((): number => {
|
|
819
|
+
const m = resolvedMaxHeight;
|
|
820
|
+
if (m) {
|
|
821
|
+
const n = parseFloat(m);
|
|
822
|
+
if (Number.isFinite(n)) return n;
|
|
823
|
+
}
|
|
824
|
+
return 600;
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
// Expanded detail rows in visual order with their (measured or estimated)
|
|
828
|
+
// extra height — empty unless row expansion is in use.
|
|
829
|
+
const expandedVisual = $derived.by((): { visual_index: number; extra: number }[] => {
|
|
830
|
+
if (!expandable || expandedRows.size === 0) return [];
|
|
831
|
+
const estimate = effectiveRowHeight;
|
|
832
|
+
const out: { visual_index: number; extra: number }[] = [];
|
|
833
|
+
for (let v = 0; v < flatRows.length; v++) {
|
|
834
|
+
const di = flatRows[v].data_index;
|
|
835
|
+
if (expandedRows.has(di)) {
|
|
836
|
+
out.push({ visual_index: v, extra: expandedHeights.get(di) ?? estimate });
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return out;
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
interface VirtualWindow {
|
|
843
|
+
first: number;
|
|
844
|
+
last: number;
|
|
845
|
+
top_pad: number;
|
|
846
|
+
bottom_pad: number;
|
|
847
|
+
rows: RenderRow[];
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const virtualWindow = $derived.by((): VirtualWindow | null => {
|
|
851
|
+
if (!virtualActive) return null;
|
|
852
|
+
const rh = effectiveRowHeight;
|
|
853
|
+
const overscan = vOpts.overscan;
|
|
854
|
+
const total = flatRows.length;
|
|
855
|
+
const vh = virtualViewport || initialViewportEstimate;
|
|
856
|
+
const exp = expandedVisual;
|
|
857
|
+
const totalExtra = exp.reduce((s, e) => s + e.extra, 0);
|
|
858
|
+
|
|
859
|
+
// Pixel offset of the top of visual row `i` within the body (base rows plus
|
|
860
|
+
// any expanded detail above it). `exp` is sorted by visual_index.
|
|
861
|
+
const topAt = (i: number): number => {
|
|
862
|
+
let extra = 0;
|
|
863
|
+
for (const e of exp) {
|
|
864
|
+
if (e.visual_index < i) extra += e.extra;
|
|
865
|
+
else break;
|
|
866
|
+
}
|
|
867
|
+
return i * rh + extra;
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
// Inverse of topAt: the visual row index at a given body pixel offset.
|
|
871
|
+
const indexAt = (offset: number): number => {
|
|
872
|
+
let remaining = offset;
|
|
873
|
+
let idx = 0;
|
|
874
|
+
for (const e of exp) {
|
|
875
|
+
const gap = e.visual_index - idx;
|
|
876
|
+
if (remaining < gap * rh) return idx + Math.floor(remaining / rh);
|
|
877
|
+
remaining -= gap * rh;
|
|
878
|
+
idx = e.visual_index;
|
|
879
|
+
const rowTotal = rh + e.extra;
|
|
880
|
+
if (remaining < rowTotal) return idx;
|
|
881
|
+
remaining -= rowTotal;
|
|
882
|
+
idx += 1;
|
|
883
|
+
}
|
|
884
|
+
return idx + Math.floor(remaining / rh);
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// `virtualOffset` already measures how far the row list has scrolled past
|
|
888
|
+
// the viewport top (header excluded), in the scroller's coordinate space.
|
|
889
|
+
const scroll = Math.max(0, virtualOffset);
|
|
890
|
+
const first = Math.max(0, indexAt(scroll) - overscan);
|
|
891
|
+
const last = Math.min(total, indexAt(scroll + vh) + overscan + 1);
|
|
892
|
+
const contentHeight = total * rh + totalExtra;
|
|
893
|
+
const top_pad = topAt(first);
|
|
894
|
+
const bottom_pad = Math.max(0, contentHeight - topAt(last));
|
|
895
|
+
return { first, last, top_pad, bottom_pad, rows: flatRows.slice(first, last) };
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// When a table is both reorderable and editable, every data cell is
|
|
899
|
+
// `data-no-drag` (a click edits it), so reordering needs an explicit grip handle
|
|
900
|
+
// in its own leading column.
|
|
901
|
+
const reorderGrip = $derived(
|
|
902
|
+
reorderable &&
|
|
903
|
+
editable &&
|
|
904
|
+
!group_by &&
|
|
905
|
+
!skeleton &&
|
|
906
|
+
!paginationActive &&
|
|
907
|
+
data.length > 0,
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
// ---- Total columns count ----
|
|
911
|
+
const totalColumns = $derived(
|
|
912
|
+
columns.length + (selectable ? 1 : 0) + (expandable ? 1 : 0) + (reorderGrip ? 1 : 0),
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
// ---- Grid track template ----
|
|
916
|
+
// The table renders as a CSS grid (with subgrid rows) instead of a native
|
|
917
|
+
// table layout, so each <tr> is a real block box that can host a row-wide
|
|
918
|
+
// ripple, hover and press effects. The column tracks are declared here so
|
|
919
|
+
// every subgrid row stays aligned.
|
|
920
|
+
const gridTemplateColumns = $derived.by(() => {
|
|
921
|
+
const tracks: string[] = [];
|
|
922
|
+
if (reorderGrip) tracks.push('auto');
|
|
923
|
+
if (selectable) tracks.push('auto');
|
|
924
|
+
if (expandable) tracks.push('auto');
|
|
925
|
+
for (const col of columns) {
|
|
926
|
+
const w = columnWidths[col.key];
|
|
927
|
+
if (w) {
|
|
928
|
+
tracks.push(`${w}px`);
|
|
929
|
+
} else if (col.width) {
|
|
930
|
+
tracks.push(col.width);
|
|
931
|
+
} else if (col.minWidth) {
|
|
932
|
+
tracks.push(`minmax(${col.minWidth}, 1fr)`);
|
|
933
|
+
} else {
|
|
934
|
+
// `min-content` (not max-content) so a full-width spanning cell — e.g.
|
|
935
|
+
// an expanded detail row with long wrapping text — can't inflate the
|
|
936
|
+
// column tracks. For the nowrap data cells min-content == max-content,
|
|
937
|
+
// so columns still size to fit their content.
|
|
938
|
+
tracks.push('minmax(min-content, 1fr)');
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return tracks.join(' ');
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
// ---- Selection identity ----
|
|
945
|
+
// `selected` round-trips through the consumer's binding, which is often a
|
|
946
|
+
// `$state` array. Svelte deeply proxies state, so the objects read back out
|
|
947
|
+
// are proxies whose identity no longer `===` the raw rows in `data`. We
|
|
948
|
+
// therefore track selection by row index (matched identity-first, then by a
|
|
949
|
+
// shallow value compare so proxied rows still resolve to their data index).
|
|
950
|
+
function shallowEqual(a: unknown, b: unknown): boolean {
|
|
951
|
+
if (a === b) return true;
|
|
952
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
const ak = Object.keys(a as Record<string, unknown>);
|
|
956
|
+
const bk = Object.keys(b as Record<string, unknown>);
|
|
957
|
+
if (ak.length !== bk.length) return false;
|
|
958
|
+
for (const k of ak) {
|
|
959
|
+
if ((a as Record<string, unknown>)[k] !== (b as Record<string, unknown>)[k]) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return true;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const selectedIndexSet = $derived.by((): Set<number> => {
|
|
967
|
+
const set = new Set<number>();
|
|
968
|
+
if (!selectable || selected.length === 0) return set;
|
|
969
|
+
for (const sel of selected) {
|
|
970
|
+
let idx = data.indexOf(sel as T);
|
|
971
|
+
if (idx === -1) idx = data.findIndex((d) => shallowEqual(d, sel));
|
|
972
|
+
if (idx !== -1) set.add(idx);
|
|
973
|
+
}
|
|
974
|
+
return set;
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
function isSelectedIndex(index: number): boolean {
|
|
978
|
+
return selectedIndexSet.has(index);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// ---- Select all state ----
|
|
982
|
+
const allSelected = $derived(data.length > 0 && selectedIndexSet.size === data.length);
|
|
983
|
+
const someSelected = $derived(
|
|
984
|
+
selectedIndexSet.size > 0 && selectedIndexSet.size < data.length,
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
// ---- Shift-range preview ----
|
|
988
|
+
// Preview range is expressed in VISUAL positions (the rows between the anchor
|
|
989
|
+
// and the hovered row, inclusive).
|
|
990
|
+
const previewRange = $derived.by((): Set<number> | null => {
|
|
991
|
+
if (!selectable || !shiftHeld || lastSelectedVisual === null || hoverIndex === null) {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
const start = Math.min(lastSelectedVisual, hoverIndex);
|
|
995
|
+
const end = Math.max(lastSelectedVisual, hoverIndex);
|
|
996
|
+
const set = new Set<number>();
|
|
997
|
+
for (let i = start; i <= end; i++) set.add(i);
|
|
998
|
+
return set;
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
function isPreviewingVisual(visualIndex: number): boolean {
|
|
1002
|
+
return previewRange?.has(visualIndex) ?? false;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// ---- Sorting ----
|
|
1006
|
+
function handleSort(columnKey: string) {
|
|
1007
|
+
const col = columns.find((c) => c.key === columnKey);
|
|
1008
|
+
if (!col?.sortable) return;
|
|
1009
|
+
|
|
1010
|
+
let newDirection: 'asc' | 'desc' = 'asc';
|
|
1011
|
+
let newSortBy: string | undefined = columnKey;
|
|
1012
|
+
|
|
1013
|
+
if (sort_by === columnKey) {
|
|
1014
|
+
if (sort_direction === 'asc') {
|
|
1015
|
+
newDirection = 'desc';
|
|
1016
|
+
} else {
|
|
1017
|
+
// Third click: clear sort
|
|
1018
|
+
newSortBy = undefined;
|
|
1019
|
+
newDirection = 'asc';
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (onsort) {
|
|
1024
|
+
onsort({ column: newSortBy ?? columnKey, direction: newDirection });
|
|
1025
|
+
} else {
|
|
1026
|
+
sort_by = newSortBy;
|
|
1027
|
+
sort_direction = newDirection;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function headerJustify(col: Column<T>): string {
|
|
1032
|
+
if (col.align === 'right') return 'flex-end';
|
|
1033
|
+
if (col.align === 'center') return 'center';
|
|
1034
|
+
return 'flex-start';
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// ---- Selection ----
|
|
1038
|
+
// `selected` is rebuilt from `data` by index on every change so it always
|
|
1039
|
+
// holds real `data` rows (never stale proxies), keeping membership reliable.
|
|
1040
|
+
function emitSelected(indices: Set<number>) {
|
|
1041
|
+
selected = [...indices].sort((a, b) => a - b).map((i) => data[i]);
|
|
1042
|
+
onselect?.({ selected });
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function toggleSelectAll() {
|
|
1046
|
+
if (allSelected) {
|
|
1047
|
+
emitSelected(new Set());
|
|
1048
|
+
} else {
|
|
1049
|
+
emitSelected(new Set(data.map((_, i) => i)));
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function handleSelectAllKeydown(e: KeyboardEvent) {
|
|
1054
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1055
|
+
e.preventDefault();
|
|
1056
|
+
toggleSelectAll();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function toggleSelectRow(
|
|
1061
|
+
dataIndex: number,
|
|
1062
|
+
visualIndex: number,
|
|
1063
|
+
event?: MouseEvent | KeyboardEvent,
|
|
1064
|
+
) {
|
|
1065
|
+
const next = new Set(selectedIndexSet);
|
|
1066
|
+
if (event?.shiftKey && lastSelectedVisual !== null) {
|
|
1067
|
+
// Select the visually-contiguous range, mapping each displayed row back
|
|
1068
|
+
// to its stable data index.
|
|
1069
|
+
const start = Math.min(lastSelectedVisual, visualIndex);
|
|
1070
|
+
const end = Math.max(lastSelectedVisual, visualIndex);
|
|
1071
|
+
for (let v = start; v <= end; v++) {
|
|
1072
|
+
const di = rowIndexMap.get(renderData[v]);
|
|
1073
|
+
if (di !== undefined) next.add(di);
|
|
1074
|
+
}
|
|
1075
|
+
// Keep the anchor so the range can be re-extended with another shift-click.
|
|
1076
|
+
} else {
|
|
1077
|
+
if (next.has(dataIndex)) {
|
|
1078
|
+
next.delete(dataIndex);
|
|
1079
|
+
} else {
|
|
1080
|
+
next.add(dataIndex);
|
|
1081
|
+
}
|
|
1082
|
+
lastSelectedVisual = visualIndex;
|
|
1083
|
+
}
|
|
1084
|
+
emitSelected(next);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function handleRowCheckKeydown(
|
|
1088
|
+
e: KeyboardEvent,
|
|
1089
|
+
dataIndex: number,
|
|
1090
|
+
visualIndex: number,
|
|
1091
|
+
) {
|
|
1092
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
1093
|
+
e.preventDefault();
|
|
1094
|
+
toggleSelectRow(dataIndex, visualIndex, e);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ---- Column resizing ----
|
|
1099
|
+
// Unified across mouse / touch / pen via Pointer Events + pointer capture, so
|
|
1100
|
+
// the drag keeps tracking even when the pointer slides off the thin handle.
|
|
1101
|
+
// The handle straddles the 1px divider with a few px of slack on each side
|
|
1102
|
+
// (see `.resize-handle`), so you never have to land on the hairline itself.
|
|
1103
|
+
const RESIZE_MIN_FALLBACK = 60;
|
|
1104
|
+
|
|
1105
|
+
function colMinWidth(columnKey: string): number {
|
|
1106
|
+
const col = columns.find((c) => c.key === columnKey);
|
|
1107
|
+
const n = parseInt(col?.minWidth || '', 10);
|
|
1108
|
+
return Number.isFinite(n) && n > 0 ? n : RESIZE_MIN_FALLBACK;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// Current rendered width of a column, looked up BY KEY via its header cell —
|
|
1112
|
+
// not by the handle's own cell, because a left-edge zone resizes the *previous*
|
|
1113
|
+
// column and so must measure that column, not the cell it lives in. Falls back
|
|
1114
|
+
// to the explicit override / a constant when the header isn't measurable.
|
|
1115
|
+
function currentColWidth(columnKey: string): number {
|
|
1116
|
+
if (columnWidths[columnKey]) return columnWidths[columnKey];
|
|
1117
|
+
const th = tableEl?.querySelector<HTMLElement>(
|
|
1118
|
+
`th[data-col-key="${CSS.escape(columnKey)}"]`,
|
|
1119
|
+
);
|
|
1120
|
+
return th ? Math.round(th.getBoundingClientRect().width) : 100;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function startResize(e: PointerEvent, columnKey: string) {
|
|
1124
|
+
// Mouse: primary button only. Touch / pen: always.
|
|
1125
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
1126
|
+
e.preventDefault();
|
|
1127
|
+
e.stopPropagation();
|
|
1128
|
+
|
|
1129
|
+
const handle = e.currentTarget as HTMLElement;
|
|
1130
|
+
const minW = colMinWidth(columnKey);
|
|
1131
|
+
const startWidth = currentColWidth(columnKey);
|
|
1132
|
+
resizing = { column_key: columnKey, start_x: e.clientX, start_width: startWidth };
|
|
1133
|
+
hoveredResizeKey = null; // the active highlight takes over from the hover preview
|
|
1134
|
+
// Capture so pointermove/up keep firing on the handle even off-target.
|
|
1135
|
+
try {
|
|
1136
|
+
handle.setPointerCapture(e.pointerId);
|
|
1137
|
+
} catch {
|
|
1138
|
+
/* capture is best-effort */
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
function onMove(ev: PointerEvent) {
|
|
1142
|
+
if (!resizing) return;
|
|
1143
|
+
const next = Math.max(
|
|
1144
|
+
minW,
|
|
1145
|
+
Math.round(resizing.start_width + (ev.clientX - resizing.start_x)),
|
|
1146
|
+
);
|
|
1147
|
+
columnWidths[resizing.column_key] = next;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function finish(commit: boolean) {
|
|
1151
|
+
if (resizing) {
|
|
1152
|
+
const finalWidth = columnWidths[resizing.column_key];
|
|
1153
|
+
// Only announce a real change — a plain click on the handle (down then
|
|
1154
|
+
// up, no movement) shouldn't fire a no-op resize event.
|
|
1155
|
+
if (commit && finalWidth && finalWidth !== resizing.start_width) {
|
|
1156
|
+
oncolumnresize?.({ column: resizing.column_key, width: finalWidth });
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
resizing = null;
|
|
1160
|
+
try {
|
|
1161
|
+
handle.releasePointerCapture(e.pointerId);
|
|
1162
|
+
} catch {
|
|
1163
|
+
/* ignore */
|
|
1164
|
+
}
|
|
1165
|
+
handle.removeEventListener('pointermove', onMove);
|
|
1166
|
+
handle.removeEventListener('pointerup', onUp);
|
|
1167
|
+
handle.removeEventListener('pointercancel', onCancel);
|
|
1168
|
+
window.removeEventListener('keydown', onKey);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function onUp() {
|
|
1172
|
+
finish(true);
|
|
1173
|
+
}
|
|
1174
|
+
function onCancel() {
|
|
1175
|
+
finish(false);
|
|
1176
|
+
}
|
|
1177
|
+
// Escape mid-drag snaps the column back to where it started.
|
|
1178
|
+
function onKey(ev: KeyboardEvent) {
|
|
1179
|
+
if (ev.key === 'Escape' && resizing) {
|
|
1180
|
+
columnWidths[resizing.column_key] = resizing.start_width;
|
|
1181
|
+
finish(false);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
handle.addEventListener('pointermove', onMove);
|
|
1186
|
+
handle.addEventListener('pointerup', onUp);
|
|
1187
|
+
handle.addEventListener('pointercancel', onCancel);
|
|
1188
|
+
window.addEventListener('keydown', onKey);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Double-click / Enter / Home on the handle: drop the explicit width so the
|
|
1192
|
+
// column returns to its content-driven (auto / flex) size.
|
|
1193
|
+
function autoFitColumn(e: Event, columnKey: string) {
|
|
1194
|
+
e.preventDefault();
|
|
1195
|
+
e.stopPropagation();
|
|
1196
|
+
if (!(columnKey in columnWidths)) return;
|
|
1197
|
+
const next = { ...columnWidths };
|
|
1198
|
+
delete next[columnKey];
|
|
1199
|
+
columnWidths = next;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Keyboard resizing from a focused handle (WAI-ARIA separator pattern):
|
|
1203
|
+
// arrow keys nudge, Shift = larger step, Home/Enter auto-fits.
|
|
1204
|
+
function nudgeColumn(columnKey: string, delta: number) {
|
|
1205
|
+
const minW = colMinWidth(columnKey);
|
|
1206
|
+
const next = Math.max(minW, Math.round(currentColWidth(columnKey) + delta));
|
|
1207
|
+
columnWidths[columnKey] = next;
|
|
1208
|
+
oncolumnresize?.({ column: columnKey, width: next });
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function handleResizeKeydown(e: KeyboardEvent, columnKey: string) {
|
|
1212
|
+
const step = e.shiftKey ? 32 : 12;
|
|
1213
|
+
if (e.key === 'ArrowLeft') {
|
|
1214
|
+
e.preventDefault();
|
|
1215
|
+
nudgeColumn(columnKey, -step);
|
|
1216
|
+
} else if (e.key === 'ArrowRight') {
|
|
1217
|
+
e.preventDefault();
|
|
1218
|
+
nudgeColumn(columnKey, step);
|
|
1219
|
+
} else if (e.key === 'Home' || e.key === 'Enter') {
|
|
1220
|
+
e.preventDefault();
|
|
1221
|
+
autoFitColumn(e, columnKey);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// One delegated `mouseover` on the table tracks which column boundary the mouse
|
|
1226
|
+
// is over (the handles carry `data-resize-key`). Cheaper than a pair of
|
|
1227
|
+
// enter/leave listeners on every cell's hit zone, and it powers the full-height
|
|
1228
|
+
// hover preview that fires only inside a zone — not across the whole column.
|
|
1229
|
+
function onResizeHover(e: MouseEvent) {
|
|
1230
|
+
if (resizing) return;
|
|
1231
|
+
const handle = (e.target as HTMLElement)?.closest?.(
|
|
1232
|
+
'.resize-handle',
|
|
1233
|
+
) as HTMLElement | null;
|
|
1234
|
+
const key = handle?.dataset.resizeKey ?? null;
|
|
1235
|
+
if (key !== hoveredResizeKey) hoveredResizeKey = key;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// ---- Row expansion ----
|
|
1239
|
+
function toggleExpand(index: number) {
|
|
1240
|
+
const next = new Set(expandedRows);
|
|
1241
|
+
if (next.has(index)) {
|
|
1242
|
+
next.delete(index);
|
|
1243
|
+
} else {
|
|
1244
|
+
next.add(index);
|
|
1245
|
+
}
|
|
1246
|
+
expandedRows = next;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// Measure an expanded detail block so virtual-scroll offsets can account for
|
|
1250
|
+
// its height. No-op unless virtual scrolling is active; the observer is torn
|
|
1251
|
+
// down when the row collapses (node removed).
|
|
1252
|
+
function measureExpanded(dataIndex: number) {
|
|
1253
|
+
return (node: HTMLElement) => {
|
|
1254
|
+
if (!virtualActive) return;
|
|
1255
|
+
const update = () => {
|
|
1256
|
+
const h = node.getBoundingClientRect().height;
|
|
1257
|
+
if (h && expandedHeights.get(dataIndex) !== h) {
|
|
1258
|
+
const next = new Map(expandedHeights);
|
|
1259
|
+
next.set(dataIndex, h);
|
|
1260
|
+
expandedHeights = next;
|
|
1261
|
+
}
|
|
1262
|
+
};
|
|
1263
|
+
update();
|
|
1264
|
+
const ro = new ResizeObserver(update);
|
|
1265
|
+
ro.observe(node);
|
|
1266
|
+
return () => ro.disconnect();
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// ---- Group collapse ----
|
|
1271
|
+
function toggleGroup(groupKey: string) {
|
|
1272
|
+
const next = new Set(collapsedGroups);
|
|
1273
|
+
if (next.has(groupKey)) {
|
|
1274
|
+
next.delete(groupKey);
|
|
1275
|
+
} else {
|
|
1276
|
+
next.add(groupKey);
|
|
1277
|
+
}
|
|
1278
|
+
collapsedGroups = next;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// ---- Row click ----
|
|
1282
|
+
function handleRowClick(
|
|
1283
|
+
row: T,
|
|
1284
|
+
dataIndex: number,
|
|
1285
|
+
visualIndex: number,
|
|
1286
|
+
event: MouseEvent,
|
|
1287
|
+
) {
|
|
1288
|
+
// Swallow the click that the browser fires at the end of a drag so it
|
|
1289
|
+
// doesn't also toggle selection / fire onrowclick.
|
|
1290
|
+
if (suppressNextClick) {
|
|
1291
|
+
suppressNextClick = false;
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const target = event.target as HTMLElement;
|
|
1295
|
+
// Clicks on the checkbox, expand toggle, a column-resize border, or an
|
|
1296
|
+
// editable cell are handled by those controls — never treat them as a row
|
|
1297
|
+
// click (so clicking a cell to edit doesn't also select/expand the row).
|
|
1298
|
+
if (
|
|
1299
|
+
target.closest('.check-wrap') ||
|
|
1300
|
+
target.closest('.expand-btn') ||
|
|
1301
|
+
target.closest('.resize-handle') ||
|
|
1302
|
+
target.closest('.editable-cell')
|
|
1303
|
+
) {
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
if (selectable) {
|
|
1308
|
+
toggleSelectRow(dataIndex, visualIndex, event);
|
|
1309
|
+
onrowclick?.({ row, index: dataIndex });
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
if (expandable) {
|
|
1314
|
+
toggleExpand(dataIndex);
|
|
1315
|
+
}
|
|
1316
|
+
onrowclick?.({ row, index: dataIndex });
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// ---- Export ----
|
|
1320
|
+
function exportCSV() {
|
|
1321
|
+
const headers = columns.map((c) => c.label);
|
|
1322
|
+
const rows = data.map((row) =>
|
|
1323
|
+
columns.map((col) => {
|
|
1324
|
+
const val = row[col.key];
|
|
1325
|
+
const str = val == null ? '' : String(val);
|
|
1326
|
+
// Escape quotes and wrap in quotes if contains comma/quote/newline
|
|
1327
|
+
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
1328
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
1329
|
+
}
|
|
1330
|
+
return str;
|
|
1331
|
+
}),
|
|
1332
|
+
);
|
|
1333
|
+
const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
|
|
1334
|
+
downloadFile(csv, 'table-export.csv', 'text/csv');
|
|
1335
|
+
showExportMenu = false;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function exportJSON() {
|
|
1339
|
+
const exportData = data.map((row) => {
|
|
1340
|
+
const obj: Record<string, unknown> = {};
|
|
1341
|
+
for (const col of columns) {
|
|
1342
|
+
obj[col.key] = row[col.key];
|
|
1343
|
+
}
|
|
1344
|
+
return obj;
|
|
1345
|
+
});
|
|
1346
|
+
const json = JSON.stringify(exportData, null, 2);
|
|
1347
|
+
downloadFile(json, 'table-export.json', 'application/json');
|
|
1348
|
+
showExportMenu = false;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function downloadFile(content: string, filename: string, mimeType: string) {
|
|
1352
|
+
const blob = new Blob([content], { type: mimeType });
|
|
1353
|
+
const url = URL.createObjectURL(blob);
|
|
1354
|
+
const a = document.createElement('a');
|
|
1355
|
+
a.href = url;
|
|
1356
|
+
a.download = filename;
|
|
1357
|
+
document.body.appendChild(a);
|
|
1358
|
+
a.click();
|
|
1359
|
+
document.body.removeChild(a);
|
|
1360
|
+
URL.revokeObjectURL(url);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// ---- Cell alignment ----
|
|
1364
|
+
// Width/min-width are handled by the grid tracks (see gridTemplateColumns).
|
|
1365
|
+
// Cells are flex containers, so alignment maps to justify-content (text-align
|
|
1366
|
+
// is kept for any wrapping content).
|
|
1367
|
+
function getColumnStyle(col: Column<T>): string {
|
|
1368
|
+
if (!col.align) return '';
|
|
1369
|
+
const justify =
|
|
1370
|
+
col.align === 'right'
|
|
1371
|
+
? 'flex-end'
|
|
1372
|
+
: col.align === 'center'
|
|
1373
|
+
? 'center'
|
|
1374
|
+
: 'flex-start';
|
|
1375
|
+
return `justify-content: ${justify}; text-align: ${col.align}`;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// ---- Cell value access ----
|
|
1379
|
+
function getCellValue(row: T, key: string): unknown {
|
|
1380
|
+
return row[key];
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// ============================================================
|
|
1384
|
+
// Inline editing
|
|
1385
|
+
// ============================================================
|
|
1386
|
+
const cellKey = (rowId: string | number, colKey: string) => `${rowId}:${colKey}`;
|
|
1387
|
+
|
|
1388
|
+
// Reactivity-safe Map/Set updates (plain Map/Set aren't deeply reactive in
|
|
1389
|
+
// Svelte 5 — reassign a clone, matching `expandedRows`/`expandedHeights`).
|
|
1390
|
+
function setMap<V>(m: Map<string, V>, k: string, v: V): Map<string, V> {
|
|
1391
|
+
const n = new Map(m);
|
|
1392
|
+
n.set(k, v);
|
|
1393
|
+
return n;
|
|
1394
|
+
}
|
|
1395
|
+
function delMap<V>(m: Map<string, V>, k: string): Map<string, V> {
|
|
1396
|
+
const n = new Map(m);
|
|
1397
|
+
n.delete(k);
|
|
1398
|
+
return n;
|
|
1399
|
+
}
|
|
1400
|
+
function addSet(s: Set<string>, k: string): Set<string> {
|
|
1401
|
+
const n = new Set(s);
|
|
1402
|
+
n.add(k);
|
|
1403
|
+
return n;
|
|
1404
|
+
}
|
|
1405
|
+
function delSet(s: Set<string>, k: string): Set<string> {
|
|
1406
|
+
const n = new Set(s);
|
|
1407
|
+
n.delete(k);
|
|
1408
|
+
return n;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function resolveEditable(col: Column<T>, row: T): boolean {
|
|
1412
|
+
if (!editable) return false;
|
|
1413
|
+
const e = col.editable;
|
|
1414
|
+
if (e === undefined) return true;
|
|
1415
|
+
if (typeof e === 'function') return e(row);
|
|
1416
|
+
return e;
|
|
1417
|
+
}
|
|
1418
|
+
function editableColsFor(row: T): Column<T>[] {
|
|
1419
|
+
if (!editable) return [];
|
|
1420
|
+
return columns.filter((c) => resolveEditable(c, row));
|
|
1421
|
+
}
|
|
1422
|
+
function formatCell(col: Column<T>, value: unknown, row: T): string {
|
|
1423
|
+
if (col.format) return col.format(value, row);
|
|
1424
|
+
return value == null ? '' : String(value);
|
|
1425
|
+
}
|
|
1426
|
+
function editorTypeOf(col: Column<T>): string {
|
|
1427
|
+
return typeof col.editor === 'string' ? col.editor : 'text';
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Ordered navigable rows for the active render mode. Group-header and expanded
|
|
1431
|
+
// detail rows carry no editable cells, so flattening the data-row lists skips
|
|
1432
|
+
// them automatically (we never navigate by DOM sibling order).
|
|
1433
|
+
const navRows = $derived.by((): RenderRow[] => {
|
|
1434
|
+
if (!editable) return [];
|
|
1435
|
+
if (groupedData) {
|
|
1436
|
+
const out: RenderRow[] = [];
|
|
1437
|
+
for (const g of groupedData) {
|
|
1438
|
+
if (collapsedGroups.has(g.key)) continue;
|
|
1439
|
+
out.push(...g.rows);
|
|
1440
|
+
}
|
|
1441
|
+
return out;
|
|
1442
|
+
}
|
|
1443
|
+
return flatRows;
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
const activeKey = $derived(
|
|
1447
|
+
active_cell ? cellKey(active_cell.row_id, active_cell.col_key) : null,
|
|
1448
|
+
);
|
|
1449
|
+
|
|
1450
|
+
function isActiveCell(rowId: string | number, colKey: string): boolean {
|
|
1451
|
+
return (
|
|
1452
|
+
!!active_cell && active_cell.row_id === rowId && active_cell.col_key === colKey
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
function isRovingCell(rowId: string | number, colKey: string): boolean {
|
|
1456
|
+
return (
|
|
1457
|
+
!active_cell &&
|
|
1458
|
+
!!roving_cell &&
|
|
1459
|
+
roving_cell.row_id === rowId &&
|
|
1460
|
+
roving_cell.col_key === colKey
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Keep `roving_cell` (the single tabbable entry point when idle) pointing at a
|
|
1465
|
+
// real editable cell. Re-validates when the data/columns change.
|
|
1466
|
+
$effect(() => {
|
|
1467
|
+
if (!editable) return;
|
|
1468
|
+
const valid =
|
|
1469
|
+
roving_cell &&
|
|
1470
|
+
navRows.some(
|
|
1471
|
+
(r) =>
|
|
1472
|
+
keyOf(r.row, r.data_index) === roving_cell!.row_id &&
|
|
1473
|
+
editableColsFor(r.row).some((c) => c.key === roving_cell!.col_key),
|
|
1474
|
+
);
|
|
1475
|
+
if (valid) return;
|
|
1476
|
+
const first = navRows[0];
|
|
1477
|
+
const col = first && editableColsFor(first.row)[0];
|
|
1478
|
+
roving_cell =
|
|
1479
|
+
first && col
|
|
1480
|
+
? { row_id: keyOf(first.row, first.data_index), col_key: col.key }
|
|
1481
|
+
: null;
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
// Commit-and-scroll: when the active cell's row scrolls out of the virtual
|
|
1485
|
+
// window it unmounts (the editor commits a dirty draft in its onDestroy), so
|
|
1486
|
+
// drop the active state rather than leaving a stale editor reference.
|
|
1487
|
+
$effect(() => {
|
|
1488
|
+
if (!editable || !active_cell || !virtualActive) return;
|
|
1489
|
+
const vw = virtualWindow;
|
|
1490
|
+
if (!vw) return;
|
|
1491
|
+
const pos = navRows.findIndex(
|
|
1492
|
+
(r) => keyOf(r.row, r.data_index) === active_cell!.row_id,
|
|
1493
|
+
);
|
|
1494
|
+
if (pos < 0) return;
|
|
1495
|
+
const vi = navRows[pos].visual_index;
|
|
1496
|
+
if (vi < vw.first || vi >= vw.last) active_cell = null;
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
function navPosition(
|
|
1500
|
+
ref: CellRef,
|
|
1501
|
+
): { row: number; col: number; cols: Column<T>[] } | null {
|
|
1502
|
+
const rowPos = navRows.findIndex((r) => keyOf(r.row, r.data_index) === ref.row_id);
|
|
1503
|
+
if (rowPos < 0) return null;
|
|
1504
|
+
const cols = editableColsFor(navRows[rowPos].row);
|
|
1505
|
+
const colPos = cols.findIndex((c) => c.key === ref.col_key);
|
|
1506
|
+
if (colPos < 0) return null;
|
|
1507
|
+
return { row: rowPos, col: colPos, cols };
|
|
1508
|
+
}
|
|
1509
|
+
function isFirstNavCell(ref: CellRef): boolean {
|
|
1510
|
+
const p = navPosition(ref);
|
|
1511
|
+
return !!p && p.row === 0 && p.col === 0;
|
|
1512
|
+
}
|
|
1513
|
+
function isLastNavCell(ref: CellRef): boolean {
|
|
1514
|
+
const p = navPosition(ref);
|
|
1515
|
+
return !!p && p.row === navRows.length - 1 && p.col === p.cols.length - 1;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Whether the next-mounted editor should auto-open its autocomplete panel.
|
|
1519
|
+
// Entering a cell DELIBERATELY (click / Tab-focus) opens the menu — that's
|
|
1520
|
+
// what the user asked for. Arriving via a keyboard ADVANCE (Enter/Tab/arrow
|
|
1521
|
+
// commit moving to the next cell) only moves focus; the menu stays closed
|
|
1522
|
+
// until the user types (or presses Alt+ArrowDown), so committing a select
|
|
1523
|
+
// doesn't cascade an unexpected open menu down the column.
|
|
1524
|
+
let autoOpenEditorMenu = $state(true);
|
|
1525
|
+
|
|
1526
|
+
function enterEdit(rowId: string | number, colKey: string) {
|
|
1527
|
+
if (!editable) return;
|
|
1528
|
+
autoOpenEditorMenu = true;
|
|
1529
|
+
active_cell = { row_id: rowId, col_key: colKey };
|
|
1530
|
+
roving_cell = { row_id: rowId, col_key: colKey };
|
|
1531
|
+
}
|
|
1532
|
+
// Identity-guarded: only clears when the cell that asked to exit is still active,
|
|
1533
|
+
// so the blur that fires during a cell→cell navigation can't cancel the move.
|
|
1534
|
+
function exitEdit(rowId: string | number, colKey: string) {
|
|
1535
|
+
if (isActiveCell(rowId, colKey)) active_cell = null;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
function navigate(dir: 'up' | 'down' | 'left' | 'right' | 'next' | 'prev') {
|
|
1539
|
+
if (!active_cell) return;
|
|
1540
|
+
const p = navPosition(active_cell);
|
|
1541
|
+
if (!p) return;
|
|
1542
|
+
let nr = p.row;
|
|
1543
|
+
let nc = p.col;
|
|
1544
|
+
switch (dir) {
|
|
1545
|
+
case 'up':
|
|
1546
|
+
nr = Math.max(0, p.row - 1);
|
|
1547
|
+
break;
|
|
1548
|
+
case 'down':
|
|
1549
|
+
nr = Math.min(navRows.length - 1, p.row + 1);
|
|
1550
|
+
break;
|
|
1551
|
+
case 'left':
|
|
1552
|
+
nc = Math.max(0, p.col - 1);
|
|
1553
|
+
break;
|
|
1554
|
+
case 'right':
|
|
1555
|
+
nc = Math.min(p.cols.length - 1, p.col + 1);
|
|
1556
|
+
break;
|
|
1557
|
+
case 'next':
|
|
1558
|
+
if (p.col < p.cols.length - 1) nc = p.col + 1;
|
|
1559
|
+
else if (p.row < navRows.length - 1) {
|
|
1560
|
+
nr = p.row + 1;
|
|
1561
|
+
nc = 0;
|
|
1562
|
+
}
|
|
1563
|
+
break;
|
|
1564
|
+
case 'prev':
|
|
1565
|
+
if (p.col > 0) nc = p.col - 1;
|
|
1566
|
+
else if (p.row > 0) {
|
|
1567
|
+
nr = p.row - 1;
|
|
1568
|
+
nc = editableColsFor(navRows[p.row - 1].row).length - 1;
|
|
1569
|
+
}
|
|
1570
|
+
break;
|
|
1571
|
+
}
|
|
1572
|
+
const targetRow = navRows[nr];
|
|
1573
|
+
if (!targetRow) return;
|
|
1574
|
+
const targetCols = editableColsFor(targetRow.row);
|
|
1575
|
+
const targetCol = targetCols[Math.min(nc, targetCols.length - 1)];
|
|
1576
|
+
if (!targetCol) return;
|
|
1577
|
+
const ref = {
|
|
1578
|
+
row_id: keyOf(targetRow.row, targetRow.data_index),
|
|
1579
|
+
col_key: targetCol.key,
|
|
1580
|
+
};
|
|
1581
|
+
autoOpenEditorMenu = false; // keyboard advance: focus moves, the menu stays closed
|
|
1582
|
+
active_cell = ref;
|
|
1583
|
+
roving_cell = ref;
|
|
1584
|
+
scrollActiveIntoView(targetRow.visual_index);
|
|
1585
|
+
focusActiveCell();
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
async function scrollActiveIntoView(visualIndex: number) {
|
|
1589
|
+
if (!virtualActive) return;
|
|
1590
|
+
await tick();
|
|
1591
|
+
const el = tableEl?.querySelector(
|
|
1592
|
+
`tbody tr.row[data-row-index="${visualIndex}"]`,
|
|
1593
|
+
) as HTMLElement | null;
|
|
1594
|
+
el?.scrollIntoView({ block: 'nearest' });
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
function findRowById(id: string | number): { row: T; index: number } | null {
|
|
1598
|
+
for (let i = 0; i < data.length; i++) {
|
|
1599
|
+
if (keyOf(data[i], i) === id) return { row: data[i], index: i };
|
|
1600
|
+
}
|
|
1601
|
+
return null;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// Per-keystroke notification (not a commit).
|
|
1605
|
+
function liveInput(
|
|
1606
|
+
rowId: string | number,
|
|
1607
|
+
colKey: string,
|
|
1608
|
+
row: T,
|
|
1609
|
+
index: number,
|
|
1610
|
+
col: Column<T>,
|
|
1611
|
+
value: unknown,
|
|
1612
|
+
) {
|
|
1613
|
+
(col.oninput ?? oncellinput)?.({
|
|
1614
|
+
value,
|
|
1615
|
+
previous: getCellValue(row, colKey),
|
|
1616
|
+
row,
|
|
1617
|
+
index,
|
|
1618
|
+
column: col,
|
|
1619
|
+
key: colKey,
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// Commit a (validated, changed) value: optimistic override + fire onedit, then
|
|
1624
|
+
// track pending/saved/error so the display cell can show a spinner / ring / check.
|
|
1625
|
+
function applyEdit(
|
|
1626
|
+
rowId: string | number,
|
|
1627
|
+
colKey: string,
|
|
1628
|
+
row: T,
|
|
1629
|
+
index: number,
|
|
1630
|
+
col: Column<T>,
|
|
1631
|
+
next: unknown,
|
|
1632
|
+
previous: unknown,
|
|
1633
|
+
) {
|
|
1634
|
+
const key = cellKey(rowId, colKey);
|
|
1635
|
+
cellError = delMap(cellError, key);
|
|
1636
|
+
cellOptimistic = setMap(cellOptimistic, key, next);
|
|
1637
|
+
const handler = col.onedit ?? oncelledit;
|
|
1638
|
+
let result: void | Promise<void>;
|
|
1639
|
+
try {
|
|
1640
|
+
result = handler?.({ value: next, previous, row, index, column: col, key: colKey });
|
|
1641
|
+
} catch (err) {
|
|
1642
|
+
handleEditError(key, err);
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
if (result instanceof Promise) {
|
|
1646
|
+
cellPending = addSet(cellPending, key);
|
|
1647
|
+
result
|
|
1648
|
+
.then(() => {
|
|
1649
|
+
cellPending = delSet(cellPending, key);
|
|
1650
|
+
flashSaved(key);
|
|
1651
|
+
clearOptimistic(key);
|
|
1652
|
+
})
|
|
1653
|
+
.catch((err) => {
|
|
1654
|
+
cellPending = delSet(cellPending, key);
|
|
1655
|
+
handleEditError(key, err);
|
|
1656
|
+
});
|
|
1657
|
+
} else {
|
|
1658
|
+
flashSaved(key);
|
|
1659
|
+
clearOptimistic(key);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
async function clearOptimistic(key: string) {
|
|
1664
|
+
// Let the parent's reactive `data` update flush first, so the cell never
|
|
1665
|
+
// flickers old → new → old.
|
|
1666
|
+
await tick();
|
|
1667
|
+
cellOptimistic = delMap(cellOptimistic, key);
|
|
1668
|
+
}
|
|
1669
|
+
function flashSaved(key: string) {
|
|
1670
|
+
cellSaved = addSet(cellSaved, key);
|
|
1671
|
+
setTimeout(() => {
|
|
1672
|
+
cellSaved = delSet(cellSaved, key);
|
|
1673
|
+
}, 1100);
|
|
1674
|
+
}
|
|
1675
|
+
function handleEditError(key: string, err: unknown) {
|
|
1676
|
+
const msg =
|
|
1677
|
+
err instanceof Error ? err.message : typeof err === 'string' ? err : 'Save failed';
|
|
1678
|
+
cellError = setMap(cellError, key, msg);
|
|
1679
|
+
// Keep the optimistic value so the user's input stays visible for a retry.
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// Called by the editor when a changed value is committed.
|
|
1683
|
+
function commitCell(
|
|
1684
|
+
rowId: string | number,
|
|
1685
|
+
colKey: string,
|
|
1686
|
+
row: T,
|
|
1687
|
+
index: number,
|
|
1688
|
+
col: Column<T>,
|
|
1689
|
+
next: unknown,
|
|
1690
|
+
) {
|
|
1691
|
+
const previous = getCellValue(row, colKey);
|
|
1692
|
+
if (Object.is(next, previous)) return;
|
|
1693
|
+
undoStack.push({ row_id: rowId, col_key: colKey, prev: previous, next });
|
|
1694
|
+
if (undoStack.length > 100) undoStack.shift();
|
|
1695
|
+
redoStack = [];
|
|
1696
|
+
applyEdit(rowId, colKey, row, index, col, next, previous);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function replayEdit(edit: CellEdit, value: unknown) {
|
|
1700
|
+
const key = cellKey(edit.row_id, edit.col_key);
|
|
1701
|
+
if (cellPending.has(key)) return false; // serialize per cell — skip while busy
|
|
1702
|
+
const found = findRowById(edit.row_id);
|
|
1703
|
+
const col = columns.find((c) => c.key === edit.col_key);
|
|
1704
|
+
if (!found || !col) return false;
|
|
1705
|
+
applyEdit(
|
|
1706
|
+
edit.row_id,
|
|
1707
|
+
edit.col_key,
|
|
1708
|
+
found.row,
|
|
1709
|
+
found.index,
|
|
1710
|
+
col,
|
|
1711
|
+
value,
|
|
1712
|
+
getCellValue(found.row, edit.col_key),
|
|
1713
|
+
);
|
|
1714
|
+
return true;
|
|
1715
|
+
}
|
|
1716
|
+
function undoEdit() {
|
|
1717
|
+
const edit = undoStack[undoStack.length - 1];
|
|
1718
|
+
if (!edit) return;
|
|
1719
|
+
if (replayEdit(edit, edit.prev)) {
|
|
1720
|
+
undoStack.pop();
|
|
1721
|
+
redoStack.push(edit);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
function redoEdit() {
|
|
1725
|
+
const edit = redoStack[redoStack.length - 1];
|
|
1726
|
+
if (!edit) return;
|
|
1727
|
+
if (replayEdit(edit, edit.next)) {
|
|
1728
|
+
redoStack.pop();
|
|
1729
|
+
undoStack.push(edit);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
function tableKeydown(e: KeyboardEvent) {
|
|
1733
|
+
if (!editable || !(e.ctrlKey || e.metaKey)) return;
|
|
1734
|
+
const k = e.key.toLowerCase();
|
|
1735
|
+
if (k === 'z' && !e.shiftKey) {
|
|
1736
|
+
e.preventDefault();
|
|
1737
|
+
undoEdit();
|
|
1738
|
+
} else if ((k === 'z' && e.shiftKey) || k === 'y') {
|
|
1739
|
+
e.preventDefault();
|
|
1740
|
+
redoEdit();
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// Click into a cell to edit it (and don't let the click toggle row selection /
|
|
1745
|
+
// expansion — see the `handleRowClick` guard). Boolean cells are handled by their
|
|
1746
|
+
// own always-present button (`toggleBooleanCell`), not here.
|
|
1747
|
+
function handleCellClick(e: MouseEvent, rowId: string | number, colKey: string) {
|
|
1748
|
+
if (!editable) return;
|
|
1749
|
+
const target = e.target as HTMLElement;
|
|
1750
|
+
if (target.closest('.resize-handle')) return;
|
|
1751
|
+
e.stopPropagation();
|
|
1752
|
+
if (!isActiveCell(rowId, colKey)) enterEdit(rowId, colKey);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Boolean cells render a persistent button (never swapped for a text editor), so
|
|
1756
|
+
// a single click toggles them — focusing-then-mounting an editor would remove the
|
|
1757
|
+
// click's target node and the browser would swallow the click.
|
|
1758
|
+
function currentCellValue(rowId: string | number, colKey: string, row: T): unknown {
|
|
1759
|
+
const key = cellKey(rowId, colKey);
|
|
1760
|
+
return cellOptimistic.has(key) ? cellOptimistic.get(key) : getCellValue(row, colKey);
|
|
1761
|
+
}
|
|
1762
|
+
function toggleBooleanCell(
|
|
1763
|
+
rowId: string | number,
|
|
1764
|
+
col: Column<T>,
|
|
1765
|
+
row: T,
|
|
1766
|
+
index: number,
|
|
1767
|
+
) {
|
|
1768
|
+
if (!isActiveCell(rowId, col.key)) enterEdit(rowId, col.key);
|
|
1769
|
+
const cur = !!currentCellValue(rowId, col.key, row);
|
|
1770
|
+
commitCell(rowId, col.key, row, index, col, !cur);
|
|
1771
|
+
}
|
|
1772
|
+
function booleanCellKeydown(
|
|
1773
|
+
e: KeyboardEvent,
|
|
1774
|
+
rowId: string | number,
|
|
1775
|
+
col: Column<T>,
|
|
1776
|
+
row: T,
|
|
1777
|
+
index: number,
|
|
1778
|
+
) {
|
|
1779
|
+
if (e.isComposing) return;
|
|
1780
|
+
const ref: CellRef = { row_id: rowId, col_key: col.key };
|
|
1781
|
+
switch (e.key) {
|
|
1782
|
+
case ' ':
|
|
1783
|
+
e.preventDefault();
|
|
1784
|
+
toggleBooleanCell(rowId, col, row, index);
|
|
1785
|
+
break;
|
|
1786
|
+
case 'Enter':
|
|
1787
|
+
e.preventDefault();
|
|
1788
|
+
navigate('down');
|
|
1789
|
+
break;
|
|
1790
|
+
case 'ArrowUp':
|
|
1791
|
+
e.preventDefault();
|
|
1792
|
+
navigate('up');
|
|
1793
|
+
break;
|
|
1794
|
+
case 'ArrowDown':
|
|
1795
|
+
e.preventDefault();
|
|
1796
|
+
navigate('down');
|
|
1797
|
+
break;
|
|
1798
|
+
case 'ArrowLeft':
|
|
1799
|
+
e.preventDefault();
|
|
1800
|
+
navigate('left');
|
|
1801
|
+
break;
|
|
1802
|
+
case 'ArrowRight':
|
|
1803
|
+
e.preventDefault();
|
|
1804
|
+
navigate('right');
|
|
1805
|
+
break;
|
|
1806
|
+
case 'Tab':
|
|
1807
|
+
if ((!e.shiftKey && isLastNavCell(ref)) || (e.shiftKey && isFirstNavCell(ref)))
|
|
1808
|
+
return;
|
|
1809
|
+
e.preventDefault();
|
|
1810
|
+
navigate(e.shiftKey ? 'prev' : 'next');
|
|
1811
|
+
break;
|
|
1812
|
+
case 'Escape':
|
|
1813
|
+
e.preventDefault();
|
|
1814
|
+
exitEdit(rowId, col.key);
|
|
1815
|
+
break;
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// After a keyboard move, focus the active cell's control. Text cells autofocus
|
|
1820
|
+
// their editor on mount; boolean buttons need an explicit focus.
|
|
1821
|
+
async function focusActiveCell() {
|
|
1822
|
+
await tick();
|
|
1823
|
+
const el = tableEl?.querySelector(
|
|
1824
|
+
'td.cell-active .cell-input, td.cell-active .cell-checkbox',
|
|
1825
|
+
) as HTMLElement | null;
|
|
1826
|
+
el?.focus();
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// ---- Aria sort ----
|
|
1830
|
+
function getAriaSort(col: Column<T>): 'ascending' | 'descending' | 'none' | undefined {
|
|
1831
|
+
if (!col.sortable) return undefined;
|
|
1832
|
+
if (sort_by !== col.key) return 'none';
|
|
1833
|
+
return sort_direction === 'asc' ? 'ascending' : 'descending';
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// ---- Skeleton widths ----
|
|
1837
|
+
function getSkeletonWidth(row: number, col: number): string {
|
|
1838
|
+
// Deterministic pseudo-random widths
|
|
1839
|
+
const seed = ((row + 1) * 7 + (col + 1) * 13) % 100;
|
|
1840
|
+
return `${40 + (seed % 45)}%`;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// ---- Close export menu on outside click ----
|
|
1844
|
+
function handleExportBlur(e: FocusEvent) {
|
|
1845
|
+
const related = e.relatedTarget as HTMLElement | null;
|
|
1846
|
+
if (!related?.closest('.export')) {
|
|
1847
|
+
showExportMenu = false;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// ======================================================================
|
|
1852
|
+
// Reorder (drag-to-reorder rows)
|
|
1853
|
+
// ----------------------------------------------------------------------
|
|
1854
|
+
// Drag is unified across mouse + touch via Pointer Events. The grabbed
|
|
1855
|
+
// row(s) are cloned into a fixed-position overlay that follows the finger
|
|
1856
|
+
// (so the drag keeps working even when virtual scrolling unmounts the
|
|
1857
|
+
// original row), while the originals stay in the DOM as hidden placeholders
|
|
1858
|
+
// to keep the scroll height stable. The other rows shift via `transform` to
|
|
1859
|
+
// open a gap at the drop target. The new order is committed to the parent
|
|
1860
|
+
// only AFTER the drop animation completes, so the parent re-rendering the
|
|
1861
|
+
// list can't interrupt the animation.
|
|
1862
|
+
// ======================================================================
|
|
1863
|
+
|
|
1864
|
+
const reorderActive = $derived(
|
|
1865
|
+
reorderable && !group_by && !skeleton && !paginationActive && data.length > 0,
|
|
1866
|
+
);
|
|
1867
|
+
|
|
1868
|
+
// Tear down any in-flight drag if the table unmounts mid-gesture, so the
|
|
1869
|
+
// document-level pointer/key listeners and timers don't leak.
|
|
1870
|
+
$effect(() => {
|
|
1871
|
+
return () => {
|
|
1872
|
+
if (drag?.raf) cancelAnimationFrame(drag.raf);
|
|
1873
|
+
if (drag?.hold_timer) clearTimeout(drag.hold_timer);
|
|
1874
|
+
if (drag?.settle_timeout) clearTimeout(drag.settle_timeout);
|
|
1875
|
+
teardownDragListeners();
|
|
1876
|
+
};
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
// Pointer Events alone can't keep a touch-drag from scrolling the page — on
|
|
1880
|
+
// touch, only `preventDefault()` on a *non-passive* `touchmove` (or
|
|
1881
|
+
// `touch-action: none`) blocks the native pan, and the listener has to be
|
|
1882
|
+
// attached before the touch starts for the browser to keep `touchmove`
|
|
1883
|
+
// cancelable. So we mount it on the wrapper for as long as the table is
|
|
1884
|
+
// reorderable rather than per-gesture. Touch events stay bound to their start
|
|
1885
|
+
// target, so this still receives every move of a drag that wanders off the
|
|
1886
|
+
// table. It only swallows the event once a long-press has armed the drag (or
|
|
1887
|
+
// the drag is already running), leaving ordinary scroll-by-drag untouched.
|
|
1888
|
+
$effect(() => {
|
|
1889
|
+
const el = wrapperEl;
|
|
1890
|
+
if (!el || !reorderActive) return;
|
|
1891
|
+
el.addEventListener('touchmove', onDragTouchMove, { passive: false });
|
|
1892
|
+
el.addEventListener('contextmenu', onDragContextMenu);
|
|
1893
|
+
return () => {
|
|
1894
|
+
el.removeEventListener('touchmove', onDragTouchMove);
|
|
1895
|
+
el.removeEventListener('contextmenu', onDragContextMenu);
|
|
1896
|
+
};
|
|
1897
|
+
});
|
|
1898
|
+
function onDragTouchMove(e: TouchEvent) {
|
|
1899
|
+
if ((drag?.armed || reorderDragging) && e.cancelable) e.preventDefault();
|
|
1900
|
+
}
|
|
1901
|
+
// Android fires `contextmenu` on a long-press (`-webkit-touch-callout` only
|
|
1902
|
+
// covers iOS), which would interrupt the hold-to-drag. Swallow it only while a
|
|
1903
|
+
// reorder gesture is live: a normal right-click never creates `drag` (it's
|
|
1904
|
+
// gated to the primary button in `onRowPointerDown`), so desktop context menus
|
|
1905
|
+
// elsewhere are unaffected.
|
|
1906
|
+
function onDragContextMenu(e: Event) {
|
|
1907
|
+
if (drag) e.preventDefault();
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// ---- Geometry helpers (content space: body top = 0, scroll-independent) ----
|
|
1911
|
+
function headerHeightPx(): number {
|
|
1912
|
+
const head = tableEl?.querySelector('thead tr') as HTMLElement | null;
|
|
1913
|
+
return head ? head.getBoundingClientRect().height : 0;
|
|
1914
|
+
}
|
|
1915
|
+
function bodyTopClient(): number {
|
|
1916
|
+
if (!tableEl) return 0;
|
|
1917
|
+
// table box top scrolls with content; the header track sits on top of it.
|
|
1918
|
+
return tableEl.getBoundingClientRect().top + headerHeightPx();
|
|
1919
|
+
}
|
|
1920
|
+
function contentY(clientY: number): number {
|
|
1921
|
+
return clientY - bodyTopClient();
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// ---- Which element scrolls for edge auto-scroll ----
|
|
1925
|
+
function nearestScrollable(el: HTMLElement | null): HTMLElement | null {
|
|
1926
|
+
let node = el?.parentElement ?? null;
|
|
1927
|
+
while (node) {
|
|
1928
|
+
const s = getComputedStyle(node);
|
|
1929
|
+
if (/(auto|scroll)/.test(s.overflowY) && node.scrollHeight > node.clientHeight) {
|
|
1930
|
+
return node;
|
|
1931
|
+
}
|
|
1932
|
+
node = node.parentElement;
|
|
1933
|
+
}
|
|
1934
|
+
return null;
|
|
1935
|
+
}
|
|
1936
|
+
function reorderScrollTarget(): HTMLElement | Window {
|
|
1937
|
+
if (containerScroll && scrollEl) return scrollEl;
|
|
1938
|
+
if (virtualActive && resolvedScroller) return resolvedScroller;
|
|
1939
|
+
return nearestScrollable(wrapperEl) ?? window;
|
|
1940
|
+
}
|
|
1941
|
+
function applyAutoScroll(clientY: number) {
|
|
1942
|
+
const t = reorderScrollTarget();
|
|
1943
|
+
let top: number, bottom: number;
|
|
1944
|
+
if (t instanceof Window) {
|
|
1945
|
+
top = 0;
|
|
1946
|
+
bottom = window.innerHeight;
|
|
1947
|
+
} else {
|
|
1948
|
+
const r = t.getBoundingClientRect();
|
|
1949
|
+
top = r.top;
|
|
1950
|
+
bottom = r.bottom;
|
|
1951
|
+
}
|
|
1952
|
+
let speed = 0;
|
|
1953
|
+
if (clientY < top + EDGE_SIZE) {
|
|
1954
|
+
speed = -MAX_SCROLL_SPEED * clamp((top + EDGE_SIZE - clientY) / EDGE_SIZE, 0, 1);
|
|
1955
|
+
} else if (clientY > bottom - EDGE_SIZE) {
|
|
1956
|
+
speed =
|
|
1957
|
+
MAX_SCROLL_SPEED * clamp((clientY - (bottom - EDGE_SIZE)) / EDGE_SIZE, 0, 1);
|
|
1958
|
+
}
|
|
1959
|
+
if (!speed) return;
|
|
1960
|
+
if (t instanceof Window) window.scrollBy(0, speed);
|
|
1961
|
+
else t.scrollTop += speed;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// ---- Pointer down on a row ----
|
|
1965
|
+
function onRowPointerDown(e: PointerEvent, dataIndex: number, visualIndex: number) {
|
|
1966
|
+
if (!reorderActive || drag) return;
|
|
1967
|
+
// Mouse: primary button only. Touch/pen: proceed.
|
|
1968
|
+
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
1969
|
+
const target = e.target as HTMLElement;
|
|
1970
|
+
// Let interactive controls (and the row's own checkbox/expand toggles) win.
|
|
1971
|
+
if (
|
|
1972
|
+
target.closest(
|
|
1973
|
+
'a, button, input, select, textarea, label, [data-no-drag], .check-wrap, .expand-btn, .resize-handle',
|
|
1974
|
+
)
|
|
1975
|
+
) {
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
suppressNextClick = false;
|
|
1980
|
+
|
|
1981
|
+
// Drag the whole selection when grabbing a selected row in a multi-select;
|
|
1982
|
+
// otherwise just the grabbed row.
|
|
1983
|
+
let draggedVis: number[];
|
|
1984
|
+
if (selectable && isSelectedIndex(dataIndex) && selectedIndexSet.size > 1) {
|
|
1985
|
+
draggedVis = flatRows
|
|
1986
|
+
.filter((f) => selectedIndexSet.has(f.data_index))
|
|
1987
|
+
.map((f) => f.visual_index)
|
|
1988
|
+
.sort((a, b) => a - b);
|
|
1989
|
+
} else {
|
|
1990
|
+
draggedVis = [visualIndex];
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// Measure the row itself — the gesture may have started on the grip cell
|
|
1994
|
+
// (editable + reorderable) rather than the whole <tr>.
|
|
1995
|
+
const grabbedEl =
|
|
1996
|
+
(e.currentTarget as HTMLElement).closest('tr.row') ??
|
|
1997
|
+
(e.currentTarget as HTMLElement);
|
|
1998
|
+
const grabbedRect = grabbedEl.getBoundingClientRect();
|
|
1999
|
+
const draggedRows = draggedVis.map((vi) => flatRows[vi].row);
|
|
2000
|
+
|
|
2001
|
+
drag = {
|
|
2002
|
+
pointer_id: e.pointerId,
|
|
2003
|
+
pointer_type: e.pointerType,
|
|
2004
|
+
start_client_x: e.clientX,
|
|
2005
|
+
start_client_y: e.clientY,
|
|
2006
|
+
last_client_x: e.clientX,
|
|
2007
|
+
last_client_y: e.clientY,
|
|
2008
|
+
grab_vi: visualIndex,
|
|
2009
|
+
grab_row_top: grabbedRect.top,
|
|
2010
|
+
grab_within_block: 0,
|
|
2011
|
+
grab_row_offset_in_block: 0,
|
|
2012
|
+
overlay_grab_offset: 0,
|
|
2013
|
+
overlay_top_content_offset: 0,
|
|
2014
|
+
collapsed: false,
|
|
2015
|
+
dragged_vis: draggedVis,
|
|
2016
|
+
dragged_set: new Set(draggedVis),
|
|
2017
|
+
dragged_rows: draggedRows,
|
|
2018
|
+
dragged_row_set: new Set(draggedRows),
|
|
2019
|
+
virtual: virtualActive,
|
|
2020
|
+
rh: effectiveRowHeight,
|
|
2021
|
+
total: flatRows.length,
|
|
2022
|
+
block_height: 0,
|
|
2023
|
+
top_by_vi: [],
|
|
2024
|
+
h_by_vi: [],
|
|
2025
|
+
removed_above: [],
|
|
2026
|
+
r_rank: [],
|
|
2027
|
+
r_vis: [],
|
|
2028
|
+
insert_at: 0,
|
|
2029
|
+
last_insert_at: -1,
|
|
2030
|
+
block_top_content: 0,
|
|
2031
|
+
prev_center: null,
|
|
2032
|
+
move_dir: 1,
|
|
2033
|
+
armed: false,
|
|
2034
|
+
hold_timer: null,
|
|
2035
|
+
raf: null,
|
|
2036
|
+
settling: false,
|
|
2037
|
+
settle_timeout: null,
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
document.addEventListener('pointermove', onDragPointerMove, { passive: false });
|
|
2041
|
+
document.addEventListener('pointerup', onDragPointerUp);
|
|
2042
|
+
document.addEventListener('pointercancel', onDragPointerCancel);
|
|
2043
|
+
window.addEventListener('keydown', onDragKeydown);
|
|
2044
|
+
|
|
2045
|
+
if (e.pointerType === 'mouse') {
|
|
2046
|
+
drag.armed = true; // desktop: ready to drag at once (threshold gates it)
|
|
2047
|
+
} else {
|
|
2048
|
+
// Touch/pen: require a held long-press so scrolling still works.
|
|
2049
|
+
drag.hold_timer = window.setTimeout(() => armTouchDrag(dataIndex), HOLD_DELAY);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
function armTouchDrag(dataIndex: number) {
|
|
2054
|
+
if (!drag) return;
|
|
2055
|
+
drag.armed = true;
|
|
2056
|
+
drag.hold_timer = null;
|
|
2057
|
+
armedDataIndex = dataIndex; // shows the "ready to move" lift
|
|
2058
|
+
if (typeof navigator !== 'undefined') navigator.vibrate?.(12);
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function onDragPointerMove(e: PointerEvent) {
|
|
2062
|
+
if (!drag || e.pointerId !== drag.pointer_id) return;
|
|
2063
|
+
drag.last_client_x = e.clientX;
|
|
2064
|
+
drag.last_client_y = e.clientY;
|
|
2065
|
+
const dist = Math.hypot(
|
|
2066
|
+
e.clientX - drag.start_client_x,
|
|
2067
|
+
e.clientY - drag.start_client_y,
|
|
2068
|
+
);
|
|
2069
|
+
|
|
2070
|
+
if (!drag.armed) {
|
|
2071
|
+
// Touch, pre-arm: real movement means the user is scrolling — bail and
|
|
2072
|
+
// let the browser scroll (we never called preventDefault).
|
|
2073
|
+
if (dist > SCROLL_TOLERANCE) cancelPendingDrag();
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
if (!reorderDragging) {
|
|
2078
|
+
// Mouse needs a small threshold so a plain click still selects; a held
|
|
2079
|
+
// touch drag starts on the first move.
|
|
2080
|
+
if (drag.pointer_type === 'mouse' && dist < DRAG_THRESHOLD) return;
|
|
2081
|
+
startDrag();
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
if (reorderDragging) {
|
|
2085
|
+
// Non-passive listener: take over the gesture so the page can't scroll.
|
|
2086
|
+
e.preventDefault();
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function startDrag() {
|
|
2091
|
+
if (!drag) return;
|
|
2092
|
+
if (drag.hold_timer) {
|
|
2093
|
+
clearTimeout(drag.hold_timer);
|
|
2094
|
+
drag.hold_timer = null;
|
|
2095
|
+
}
|
|
2096
|
+
armedDataIndex = null;
|
|
2097
|
+
reorderDragging = true;
|
|
2098
|
+
suppressNextClick = true;
|
|
2099
|
+
|
|
2100
|
+
// Land `.reordering` in the DOM *before* we measure: it triggers the press
|
|
2101
|
+
// suppressor (.wrapper.reordering .row.clickable:active), so the grabbed row
|
|
2102
|
+
// is measured at its true height, not the scaled-down :active press height.
|
|
2103
|
+
// Otherwise the gap opens by a too-short block and every shifted row snaps
|
|
2104
|
+
// ~1px on drop, accumulating with drag distance.
|
|
2105
|
+
flushSync();
|
|
2106
|
+
|
|
2107
|
+
buildDragLayout();
|
|
2108
|
+
|
|
2109
|
+
// Hide the originals.
|
|
2110
|
+
draggedDataSet = new Set(drag.dragged_rows.map((r) => rowIndexMap.get(r) as number));
|
|
2111
|
+
|
|
2112
|
+
// Build the floating overlay. Many rows collapse to just the grabbed row
|
|
2113
|
+
// plus a "+N" badge so the drag stays compact (the gap still reserves the
|
|
2114
|
+
// full block, so the commit doesn't jump). The overlay then tracks and
|
|
2115
|
+
// settles on the GRABBED row's slot, not the whole block's top.
|
|
2116
|
+
const withinRow = drag.start_client_y - drag.grab_row_top;
|
|
2117
|
+
if (drag.dragged_vis.length >= REORDER_COLLAPSE_AT) {
|
|
2118
|
+
drag.collapsed = true;
|
|
2119
|
+
const g = flatRows[drag.grab_vi];
|
|
2120
|
+
overlayRows = [{ row: g.row, data_index: g.data_index }];
|
|
2121
|
+
overlayMore = drag.dragged_vis.length;
|
|
2122
|
+
drag.overlay_grab_offset = withinRow;
|
|
2123
|
+
drag.overlay_top_content_offset = drag.grab_row_offset_in_block;
|
|
2124
|
+
} else {
|
|
2125
|
+
drag.collapsed = false;
|
|
2126
|
+
overlayRows = drag.dragged_vis.map((vi) => ({
|
|
2127
|
+
row: flatRows[vi].row,
|
|
2128
|
+
data_index: flatRows[vi].data_index,
|
|
2129
|
+
}));
|
|
2130
|
+
overlayMore = 0;
|
|
2131
|
+
drag.overlay_grab_offset = drag.grab_within_block;
|
|
2132
|
+
drag.overlay_top_content_offset = 0;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
updateInsertAt();
|
|
2136
|
+
// applyNeighborTransforms();
|
|
2137
|
+
// Position + lift the overlay once it's mounted, before the first paint.
|
|
2138
|
+
tick().then(liftInOverlay);
|
|
2139
|
+
drag.raf = requestAnimationFrame(dragFrame);
|
|
2140
|
+
|
|
2141
|
+
onreorderstart?.({ from: drag.dragged_vis.map((vi) => flatRows[vi].data_index) });
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// Snapshot the layout needed to compute the gap. Virtual mode is uniform
|
|
2145
|
+
// (measured row height); normal mode measures every rendered row.
|
|
2146
|
+
function buildDragLayout() {
|
|
2147
|
+
if (!drag) return;
|
|
2148
|
+
const rh = drag.rh;
|
|
2149
|
+
if (drag.virtual) {
|
|
2150
|
+
drag.block_height = drag.dragged_vis.length * rh;
|
|
2151
|
+
// grab offset within the block (uniform heights)
|
|
2152
|
+
let off = 0;
|
|
2153
|
+
for (const vi of drag.dragged_vis) {
|
|
2154
|
+
if (vi === drag.grab_vi) break;
|
|
2155
|
+
off += rh;
|
|
2156
|
+
}
|
|
2157
|
+
drag.grab_row_offset_in_block = off;
|
|
2158
|
+
drag.grab_within_block = off + (drag.start_client_y - drag.grab_row_top);
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
const els = tableEl?.querySelectorAll('tbody tr.row') ?? [];
|
|
2163
|
+
const topByVi: number[] = [];
|
|
2164
|
+
const hByVi: number[] = [];
|
|
2165
|
+
els.forEach((el) => {
|
|
2166
|
+
const vi = Number((el as HTMLElement).dataset.rowIndex);
|
|
2167
|
+
if (Number.isNaN(vi)) return;
|
|
2168
|
+
const r = el.getBoundingClientRect();
|
|
2169
|
+
topByVi[vi] = contentY(r.top);
|
|
2170
|
+
hByVi[vi] = r.height;
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
// `tr.row:last-child` drops its border-bottom (a 1px overshoot would force a
|
|
2174
|
+
// phantom scrollbar — see the divider rules in the stylesheet), so the final
|
|
2175
|
+
// row measures ~1px shorter than every other row. The reorder treats heights as
|
|
2176
|
+
// position-independent, so without this the bordered row that ENDS UP last
|
|
2177
|
+
// after a drop lands 1px off — most visible dragging the top row to the very
|
|
2178
|
+
// bottom. Normalise the last row back to the shared "with divider" height.
|
|
2179
|
+
const lastEl = els[els.length - 1] as HTMLElement | undefined;
|
|
2180
|
+
const firstEl = els[0] as HTMLElement | undefined;
|
|
2181
|
+
if (lastEl && firstEl && lastEl !== firstEl) {
|
|
2182
|
+
const lastVi = Number(lastEl.dataset.rowIndex);
|
|
2183
|
+
const lastBorder = parseFloat(getComputedStyle(lastEl).borderBottomWidth) || 0;
|
|
2184
|
+
if (!Number.isNaN(lastVi) && lastBorder === 0) {
|
|
2185
|
+
hByVi[lastVi] += parseFloat(getComputedStyle(firstEl).borderBottomWidth) || 0;
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// Grab offset within the (assembled) block, using measured heights.
|
|
2190
|
+
let off = 0;
|
|
2191
|
+
for (const vi of drag.dragged_vis) {
|
|
2192
|
+
if (vi === drag.grab_vi) break;
|
|
2193
|
+
off += hByVi[vi] ?? rh;
|
|
2194
|
+
}
|
|
2195
|
+
drag.grab_row_offset_in_block = off;
|
|
2196
|
+
drag.grab_within_block = off + (drag.start_client_y - drag.grab_row_top);
|
|
2197
|
+
|
|
2198
|
+
// Single pass: running dragged-height-removed and non-dragged rank.
|
|
2199
|
+
const removedAbove: number[] = [];
|
|
2200
|
+
const rRank: number[] = [];
|
|
2201
|
+
const rVis: number[] = [];
|
|
2202
|
+
let removed = 0;
|
|
2203
|
+
let rank = 0;
|
|
2204
|
+
let blockH = 0;
|
|
2205
|
+
for (let vi = 0; vi < drag.total; vi++) {
|
|
2206
|
+
removedAbove[vi] = removed;
|
|
2207
|
+
rRank[vi] = rank;
|
|
2208
|
+
if (drag.dragged_set.has(vi)) {
|
|
2209
|
+
removed += hByVi[vi] ?? rh;
|
|
2210
|
+
blockH += hByVi[vi] ?? rh;
|
|
2211
|
+
} else {
|
|
2212
|
+
rVis.push(vi);
|
|
2213
|
+
rank++;
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
drag.top_by_vi = topByVi;
|
|
2217
|
+
drag.h_by_vi = hByVi;
|
|
2218
|
+
drag.removed_above = removedAbove;
|
|
2219
|
+
drag.r_rank = rRank;
|
|
2220
|
+
drag.r_vis = rVis;
|
|
2221
|
+
drag.block_height = blockH;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
function draggedBefore(vi: number): number {
|
|
2225
|
+
if (!drag) return 0;
|
|
2226
|
+
let n = 0;
|
|
2227
|
+
for (const dv of drag.dragged_vis) {
|
|
2228
|
+
if (dv < vi) n++;
|
|
2229
|
+
else break;
|
|
2230
|
+
}
|
|
2231
|
+
return n;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// Where (in non-dragged "R" space) the block should be inserted. The anchor is
|
|
2235
|
+
// the GRABBED row's centre (not the raw pointer, and independent of where
|
|
2236
|
+
// within the row you grabbed). The gap moves past a neighbour once that centre
|
|
2237
|
+
// crosses the neighbour's *near* edge in the direction of travel — its TOP when
|
|
2238
|
+
// moving down, its BOTTOM when moving up — so the trigger is a symmetric ~50%
|
|
2239
|
+
// overlap in both directions. Direction is sticky (hysteresis) so the gap
|
|
2240
|
+
// doesn't jump when the pointer pauses.
|
|
2241
|
+
function updateInsertAt() {
|
|
2242
|
+
if (!drag) return;
|
|
2243
|
+
const withinRow = drag.start_client_y - drag.grab_row_top;
|
|
2244
|
+
const grabbedH = drag.virtual ? drag.rh : (drag.h_by_vi[drag.grab_vi] ?? drag.rh);
|
|
2245
|
+
const grabbedCenter = contentY(drag.last_client_y) - withinRow + grabbedH / 2;
|
|
2246
|
+
|
|
2247
|
+
const delta = grabbedCenter - (drag.prev_center ?? grabbedCenter);
|
|
2248
|
+
if (delta > 0.5) drag.move_dir = 1;
|
|
2249
|
+
else if (delta < -0.5) drag.move_dir = -1;
|
|
2250
|
+
drag.prev_center = grabbedCenter;
|
|
2251
|
+
const down = drag.move_dir === 1;
|
|
2252
|
+
|
|
2253
|
+
let insertAt: number;
|
|
2254
|
+
if (drag.virtual) {
|
|
2255
|
+
const rh = drag.rh;
|
|
2256
|
+
// Down: count rows whose top is above the centre. Up: whose bottom is.
|
|
2257
|
+
// (epsilon stabilises the exact-boundary case against float jitter.)
|
|
2258
|
+
const rawSlot = down
|
|
2259
|
+
? clamp(Math.ceil(grabbedCenter / rh - 1e-4), 0, drag.total)
|
|
2260
|
+
: clamp(Math.floor(grabbedCenter / rh - 1e-4), 0, drag.total);
|
|
2261
|
+
insertAt = clamp(
|
|
2262
|
+
rawSlot - draggedBefore(rawSlot),
|
|
2263
|
+
0,
|
|
2264
|
+
drag.total - drag.dragged_vis.length,
|
|
2265
|
+
);
|
|
2266
|
+
} else {
|
|
2267
|
+
let count = 0;
|
|
2268
|
+
for (const vi of drag.r_vis) {
|
|
2269
|
+
const edge = down ? drag.top_by_vi[vi] : drag.top_by_vi[vi] + drag.h_by_vi[vi];
|
|
2270
|
+
if (edge < grabbedCenter) count++;
|
|
2271
|
+
else break;
|
|
2272
|
+
}
|
|
2273
|
+
insertAt = clamp(count, 0, drag.r_vis.length);
|
|
2274
|
+
}
|
|
2275
|
+
drag.insert_at = insertAt;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// Shift the non-dragged rows to open the gap; also records where the block
|
|
2279
|
+
// will land (block_top_content) for the drop animation.
|
|
2280
|
+
function applyNeighborTransforms() {
|
|
2281
|
+
if (!drag) return;
|
|
2282
|
+
const insertAt = drag.insert_at;
|
|
2283
|
+
const blockH = drag.block_height;
|
|
2284
|
+
const m = new Map<number, number>();
|
|
2285
|
+
|
|
2286
|
+
if (drag.virtual) {
|
|
2287
|
+
const rh = drag.rh;
|
|
2288
|
+
const win = virtualWindow;
|
|
2289
|
+
if (win) {
|
|
2290
|
+
for (const rr of win.rows) {
|
|
2291
|
+
if (drag.dragged_set.has(rr.visual_index)) continue;
|
|
2292
|
+
const vi = rr.visual_index;
|
|
2293
|
+
const rR = vi - draggedBefore(vi);
|
|
2294
|
+
const targetTop = rR * rh + (rR >= insertAt ? blockH : 0);
|
|
2295
|
+
m.set(rr.data_index, targetTop - vi * rh);
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
drag.block_top_content = insertAt * rh;
|
|
2299
|
+
} else {
|
|
2300
|
+
for (const vi of drag.r_vis) {
|
|
2301
|
+
const inserted = drag.r_rank[vi] >= insertAt ? blockH : 0;
|
|
2302
|
+
m.set(flatRows[vi].data_index, inserted - drag.removed_above[vi]);
|
|
2303
|
+
}
|
|
2304
|
+
// The block lands at the (closed-up) top of the row now at insertAt, or
|
|
2305
|
+
// just past the last row when inserting at the very end.
|
|
2306
|
+
const rv = drag.r_vis;
|
|
2307
|
+
if (rv.length === 0) {
|
|
2308
|
+
drag.block_top_content = 0;
|
|
2309
|
+
} else if (insertAt < rv.length) {
|
|
2310
|
+
const vi = rv[insertAt];
|
|
2311
|
+
drag.block_top_content = drag.top_by_vi[vi] - drag.removed_above[vi];
|
|
2312
|
+
} else {
|
|
2313
|
+
const vi = rv[rv.length - 1];
|
|
2314
|
+
drag.block_top_content =
|
|
2315
|
+
drag.top_by_vi[vi] - drag.removed_above[vi] + drag.h_by_vi[vi];
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
rowTransforms = m;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function positionOverlay() {
|
|
2322
|
+
if (!drag || !overlayEl || !tableEl) return;
|
|
2323
|
+
const y = drag.last_client_y - drag.overlay_grab_offset;
|
|
2324
|
+
const tr = tableEl.getBoundingClientRect();
|
|
2325
|
+
overlayEl.style.width = `${tableEl.scrollWidth}px`;
|
|
2326
|
+
// Position via `translate` and lift via `scale` as separate properties: the
|
|
2327
|
+
// per-frame pointer-follow (translate) stays instant while `scale` carries
|
|
2328
|
+
// its own transition (the lift-in here and the drop-settle in finishDrop).
|
|
2329
|
+
// Scale is centred on the card, so the lift grows symmetrically instead of
|
|
2330
|
+
// shifting sideways by the translate offset.
|
|
2331
|
+
overlayEl.style.translate = `${tr.left}px ${y}px`;
|
|
2332
|
+
overlayEl.style.scale = `${LIFT_SCALE}`;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// First paint of the overlay: pin it at the grabbed row's pressed scale and ease
|
|
2336
|
+
// up to the lift, so the float grows out of the :active push rather than snapping
|
|
2337
|
+
// to full size. Only `scale` transitions — `translate` keeps tracking the pointer
|
|
2338
|
+
// with no lag because it's not in the transition list.
|
|
2339
|
+
function liftInOverlay() {
|
|
2340
|
+
if (!drag || !overlayEl) return;
|
|
2341
|
+
const start = drag.pointer_type === 'mouse' ? GRAB_PRESS_SCALE : 1;
|
|
2342
|
+
positionOverlay();
|
|
2343
|
+
overlayEl.style.transition = 'none';
|
|
2344
|
+
overlayEl.style.scale = `${start}`;
|
|
2345
|
+
void overlayEl.offsetWidth; // commit the compressed start frame
|
|
2346
|
+
overlayEl.style.transition = `scale ${LIFT_IN_MS}ms ${LIFT_IN_EASE}`;
|
|
2347
|
+
overlayEl.style.scale = `${LIFT_SCALE}`;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// rAF loop: auto-scroll near the edges, keep the overlay under the finger,
|
|
2351
|
+
// and recompute the gap (the pointer may be stationary while auto-scrolling).
|
|
2352
|
+
function dragFrame() {
|
|
2353
|
+
if (!drag || !reorderDragging) return;
|
|
2354
|
+
applyAutoScroll(drag.last_client_y);
|
|
2355
|
+
positionOverlay();
|
|
2356
|
+
updateInsertAt();
|
|
2357
|
+
// Virtual: the rendered window changes as we auto-scroll, so refresh every
|
|
2358
|
+
// frame. Normal: only when the target actually moves.
|
|
2359
|
+
if (drag.virtual) {
|
|
2360
|
+
applyNeighborTransforms();
|
|
2361
|
+
} else if (drag.insert_at !== drag.last_insert_at) {
|
|
2362
|
+
applyNeighborTransforms();
|
|
2363
|
+
drag.last_insert_at = drag.insert_at;
|
|
2364
|
+
}
|
|
2365
|
+
drag.raf = requestAnimationFrame(dragFrame);
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
function onDragPointerUp(e: PointerEvent) {
|
|
2369
|
+
if (!drag || e.pointerId !== drag.pointer_id) return;
|
|
2370
|
+
if (reorderDragging) finishDrop(false);
|
|
2371
|
+
else cancelPendingDrag();
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function onDragPointerCancel(e: PointerEvent) {
|
|
2375
|
+
if (!drag || e.pointerId !== drag.pointer_id) return;
|
|
2376
|
+
if (reorderDragging)
|
|
2377
|
+
finishDrop(true); // abort: animate home, don't commit
|
|
2378
|
+
else cancelPendingDrag();
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
function onDragKeydown(e: KeyboardEvent) {
|
|
2382
|
+
if (!drag || e.key !== 'Escape') return;
|
|
2383
|
+
if (reorderDragging) finishDrop(true);
|
|
2384
|
+
else cancelPendingDrag();
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
// A press that never became a drag (a click, or a touch that turned into a
|
|
2388
|
+
// scroll): tear everything down and let the normal click handler run.
|
|
2389
|
+
function cancelPendingDrag() {
|
|
2390
|
+
if (!drag) return;
|
|
2391
|
+
if (drag.hold_timer) clearTimeout(drag.hold_timer);
|
|
2392
|
+
if (drag.raf) cancelAnimationFrame(drag.raf);
|
|
2393
|
+
teardownDragListeners();
|
|
2394
|
+
armedDataIndex = null;
|
|
2395
|
+
drag = null;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
function teardownDragListeners() {
|
|
2399
|
+
document.removeEventListener('pointermove', onDragPointerMove);
|
|
2400
|
+
document.removeEventListener('pointerup', onDragPointerUp);
|
|
2401
|
+
document.removeEventListener('pointercancel', onDragPointerCancel);
|
|
2402
|
+
window.removeEventListener('keydown', onDragKeydown);
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function sameSeq(a: T[], b: T[]): boolean {
|
|
2406
|
+
if (a.length !== b.length) return false;
|
|
2407
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
2408
|
+
return true;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// Insertion index that leaves the order unchanged (block's original home).
|
|
2412
|
+
function homeInsertAt(): number {
|
|
2413
|
+
if (!drag) return 0;
|
|
2414
|
+
const first = drag.dragged_vis[0];
|
|
2415
|
+
let c = 0;
|
|
2416
|
+
for (let vi = 0; vi < first; vi++) if (!drag.dragged_set.has(vi)) c++;
|
|
2417
|
+
return c;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
// Pointer released (or aborted): freeze the final target, animate the overlay
|
|
2421
|
+
// into the gap, and only THEN commit the new order to the parent.
|
|
2422
|
+
function finishDrop(abort: boolean) {
|
|
2423
|
+
if (!drag || drag.settling) return;
|
|
2424
|
+
if (drag.raf) {
|
|
2425
|
+
cancelAnimationFrame(drag.raf);
|
|
2426
|
+
drag.raf = null;
|
|
2427
|
+
}
|
|
2428
|
+
// No more pointer/key input drives the drag once it's settling; the drop
|
|
2429
|
+
// completes via the overlay's transitionend (or the fallback timeout).
|
|
2430
|
+
teardownDragListeners();
|
|
2431
|
+
|
|
2432
|
+
updateInsertAt();
|
|
2433
|
+
if (abort) drag.insert_at = homeInsertAt();
|
|
2434
|
+
applyNeighborTransforms();
|
|
2435
|
+
|
|
2436
|
+
const from = drag.dragged_vis.map((vi) => flatRows[vi].data_index);
|
|
2437
|
+
const insertAt = drag.insert_at;
|
|
2438
|
+
const oldVisual = flatRows.map((f) => f.row);
|
|
2439
|
+
const dset = drag.dragged_row_set;
|
|
2440
|
+
const draggedRows = drag.dragged_rows;
|
|
2441
|
+
const rRows = oldVisual.filter((r) => !dset.has(r));
|
|
2442
|
+
const newData = abort
|
|
2443
|
+
? data
|
|
2444
|
+
: [...rRows.slice(0, insertAt), ...draggedRows, ...rRows.slice(insertAt)];
|
|
2445
|
+
const changed = !abort && !sameSeq(newData, oldVisual);
|
|
2446
|
+
|
|
2447
|
+
reorderDragging = false;
|
|
2448
|
+
reorderDropping = true;
|
|
2449
|
+
drag.settling = true;
|
|
2450
|
+
if (!abort) ondrop?.({ from, to: insertAt });
|
|
2451
|
+
|
|
2452
|
+
// Animate the overlay to the gap's current on-screen position, easing the
|
|
2453
|
+
// lift scale back to 1. A collapsed overlay lands on the grabbed row's slot
|
|
2454
|
+
// within the block, not the top.
|
|
2455
|
+
const targetY =
|
|
2456
|
+
bodyTopClient() + drag.block_top_content + drag.overlay_top_content_offset;
|
|
2457
|
+
const left = tableEl ? tableEl.getBoundingClientRect().left : 0;
|
|
2458
|
+
const done = () => finishSettle(changed, from, insertAt, newData);
|
|
2459
|
+
if (overlayEl) {
|
|
2460
|
+
overlayEl.classList.add('settling');
|
|
2461
|
+
overlayEl.style.transition = `translate ${SETTLE_MS}ms ${SETTLE_EASE}, scale ${SETTLE_MS}ms ${SETTLE_EASE}, filter ${SETTLE_MS}ms ease`;
|
|
2462
|
+
overlayEl.style.translate = `${left}px ${targetY}px`;
|
|
2463
|
+
overlayEl.style.scale = '1';
|
|
2464
|
+
overlayEl.addEventListener('transitionend', done, { once: true });
|
|
2465
|
+
}
|
|
2466
|
+
// Fallback (reduced motion / no movement = no transitionend).
|
|
2467
|
+
drag.settle_timeout = window.setTimeout(done, SETTLE_MS + 80);
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
// Drop animation finished: reset all visual state and commit in the SAME
|
|
2471
|
+
// synchronous tick so Svelte flushes once — the rows re-render in their new
|
|
2472
|
+
// order with no transforms, so nothing visibly jumps.
|
|
2473
|
+
function finishSettle(changed: boolean, from: number[], to: number, newData: T[]) {
|
|
2474
|
+
if (!drag) return;
|
|
2475
|
+
if (drag.settle_timeout) clearTimeout(drag.settle_timeout);
|
|
2476
|
+
teardownDragListeners();
|
|
2477
|
+
|
|
2478
|
+
const oldData = data;
|
|
2479
|
+
|
|
2480
|
+
// Reset visuals.
|
|
2481
|
+
reorderDropping = false;
|
|
2482
|
+
draggedDataSet = new Set();
|
|
2483
|
+
rowTransforms = new Map();
|
|
2484
|
+
overlayRows = [];
|
|
2485
|
+
armedDataIndex = null;
|
|
2486
|
+
drag = null;
|
|
2487
|
+
|
|
2488
|
+
if (changed) onreorder?.({ from, to, oldData, newData });
|
|
2489
|
+
// Drop any straggler click and release the suppressor on the next tick, so a
|
|
2490
|
+
// completed drag never toggles selection on the trailing pointerup/click.
|
|
2491
|
+
setTimeout(() => (suppressNextClick = false), 0);
|
|
2492
|
+
}
|
|
2493
|
+
</script>
|
|
2494
|
+
|
|
2495
|
+
<div
|
|
2496
|
+
bind:this={wrapperEl}
|
|
2497
|
+
class={['wrapper', class_name].filter(Boolean).join(' ')}
|
|
2498
|
+
class:dense
|
|
2499
|
+
class:comfortable
|
|
2500
|
+
class:striped
|
|
2501
|
+
class:reordering={reorderDragging || reorderDropping}
|
|
2502
|
+
class:resizing-active={!!resizing}
|
|
2503
|
+
{id}>
|
|
2504
|
+
{#if showPager && (pgConfig.position === 'top' || pgConfig.position === 'both')}
|
|
2505
|
+
{@render pager('top')}
|
|
2506
|
+
{/if}
|
|
2507
|
+
{#if exportable}
|
|
2508
|
+
<div class="toolbar">
|
|
2509
|
+
<div class="export" onfocusout={handleExportBlur}>
|
|
2510
|
+
<button
|
|
2511
|
+
type="button"
|
|
2512
|
+
aria-haspopup="true"
|
|
2513
|
+
aria-expanded={showExportMenu}
|
|
2514
|
+
onclick={() => (showExportMenu = !showExportMenu)}>
|
|
2515
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
2516
|
+
<path
|
|
2517
|
+
d="M8 2v8M8 10L5 7M8 10l3-3M3 12h10"
|
|
2518
|
+
stroke="currentColor"
|
|
2519
|
+
stroke-width="1.5"
|
|
2520
|
+
stroke-linecap="round"
|
|
2521
|
+
stroke-linejoin="round" />
|
|
2522
|
+
</svg>
|
|
2523
|
+
Export
|
|
2524
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
|
2525
|
+
<path
|
|
2526
|
+
d="M3 5l3 3 3-3"
|
|
2527
|
+
stroke="currentColor"
|
|
2528
|
+
stroke-width="1.5"
|
|
2529
|
+
stroke-linecap="round"
|
|
2530
|
+
stroke-linejoin="round" />
|
|
2531
|
+
</svg>
|
|
2532
|
+
</button>
|
|
2533
|
+
{#if showExportMenu}
|
|
2534
|
+
<div class="menu" role="menu">
|
|
2535
|
+
<button type="button" role="menuitem" onclick={exportCSV}>Export CSV</button>
|
|
2536
|
+
<button type="button" role="menuitem" onclick={exportJSON}>
|
|
2537
|
+
Export JSON
|
|
2538
|
+
</button>
|
|
2539
|
+
</div>
|
|
2540
|
+
{/if}
|
|
2541
|
+
</div>
|
|
2542
|
+
</div>
|
|
2543
|
+
{/if}
|
|
2544
|
+
|
|
2545
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
2546
|
+
<div
|
|
2547
|
+
class="scroll"
|
|
2548
|
+
class:bounded={containerScroll}
|
|
2549
|
+
class:passthrough={virtualActive && !containerScroll}
|
|
2550
|
+
style:max-height={resolvedMaxHeight}
|
|
2551
|
+
bind:this={scrollEl}
|
|
2552
|
+
{@attach scrollbar({
|
|
2553
|
+
// With a sticky header the scrollable region visually starts below it,
|
|
2554
|
+
// so pin the track top to the header's bottom edge instead of the radius
|
|
2555
|
+
track_insets: (el) => ({
|
|
2556
|
+
top: el.querySelector<HTMLElement>('thead tr.sticky')?.offsetHeight,
|
|
2557
|
+
}),
|
|
2558
|
+
})}
|
|
2559
|
+
onmouseleave={() => {
|
|
2560
|
+
hoverIndex = null;
|
|
2561
|
+
hoveredResizeKey = null;
|
|
2562
|
+
}}>
|
|
2563
|
+
<!-- onmouseover only drives the visual resize-border hover preview; keyboard
|
|
2564
|
+
users resize via the focusable header separators, so no focus pairing. -->
|
|
2565
|
+
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
|
2566
|
+
<table
|
|
2567
|
+
role="grid"
|
|
2568
|
+
class:editable
|
|
2569
|
+
bind:this={tableEl}
|
|
2570
|
+
onmouseover={resizable ? onResizeHover : undefined}
|
|
2571
|
+
onkeydown={editable ? tableKeydown : undefined}
|
|
2572
|
+
style:grid-template-columns={gridTemplateColumns}>
|
|
2573
|
+
<!-- The CSS `display` override (grid/subgrid) strips the implicit ARIA
|
|
2574
|
+
roles of the native table elements, so they are restored explicitly.
|
|
2575
|
+
Svelte's a11y_no_redundant_roles check can't see the CSS, hence the
|
|
2576
|
+
ignores below. -->
|
|
2577
|
+
<thead>
|
|
2578
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
2579
|
+
<tr role="row" class:sticky={sticky_header}>
|
|
2580
|
+
{#if reorderGrip}
|
|
2581
|
+
<th class="grip-cell" role="columnheader" aria-label="Reorder"></th>
|
|
2582
|
+
{/if}
|
|
2583
|
+
{#if selectable}
|
|
2584
|
+
<th class="checkbox-cell" role="columnheader">
|
|
2585
|
+
<div
|
|
2586
|
+
class="check-wrap"
|
|
2587
|
+
class:checked={allSelected}
|
|
2588
|
+
class:indeterminate={someSelected}
|
|
2589
|
+
role="checkbox"
|
|
2590
|
+
tabindex="0"
|
|
2591
|
+
aria-checked={someSelected ? 'mixed' : allSelected}
|
|
2592
|
+
aria-label="Select all rows"
|
|
2593
|
+
{@attach ripple({ centered: true, opacity: 0.15 })}
|
|
2594
|
+
onclick={toggleSelectAll}
|
|
2595
|
+
onkeydown={handleSelectAllKeydown}>
|
|
2596
|
+
{@render checkIndicator(allSelected, someSelected, false)}
|
|
2597
|
+
</div>
|
|
2598
|
+
</th>
|
|
2599
|
+
{/if}
|
|
2600
|
+
{#if expandable}
|
|
2601
|
+
<th class="expand-cell" role="columnheader"></th>
|
|
2602
|
+
{/if}
|
|
2603
|
+
{#each columns as col, ci (col.key)}
|
|
2604
|
+
<th
|
|
2605
|
+
style={getColumnStyle(col)}
|
|
2606
|
+
data-col-key={col.key}
|
|
2607
|
+
role="columnheader"
|
|
2608
|
+
aria-sort={getAriaSort(col)}
|
|
2609
|
+
class:sortable={col.sortable && !col.header}
|
|
2610
|
+
class:col-resizing={resizing?.column_key === col.key}
|
|
2611
|
+
class:col-hover={hoveredResizeKey === col.key}>
|
|
2612
|
+
{#if col.header}
|
|
2613
|
+
<div class="th-content">
|
|
2614
|
+
{@render col.header({ column: col })}
|
|
2615
|
+
</div>
|
|
2616
|
+
{:else if col.sortable}
|
|
2617
|
+
<button
|
|
2618
|
+
class="th-button"
|
|
2619
|
+
type="button"
|
|
2620
|
+
style:justify-content={headerJustify(col)}
|
|
2621
|
+
onclick={() => handleSort(col.key)}
|
|
2622
|
+
{@attach ripple({ opacity: 0.12 })}>
|
|
2623
|
+
<span>{col.label}</span>
|
|
2624
|
+
<span class="sort-icon" class:active={sort_by === col.key}>
|
|
2625
|
+
{#if sort_by === col.key}
|
|
2626
|
+
<span class="arrow-rot" class:desc={sort_direction === 'desc'}>
|
|
2627
|
+
<svg
|
|
2628
|
+
class="arrow"
|
|
2629
|
+
width="17"
|
|
2630
|
+
height="17"
|
|
2631
|
+
viewBox="0 0 20 20"
|
|
2632
|
+
fill="none"
|
|
2633
|
+
aria-hidden="true">
|
|
2634
|
+
<path
|
|
2635
|
+
d="M10 15.5V5M5.5 9.5L10 5l4.5 4.5"
|
|
2636
|
+
stroke="currentColor"
|
|
2637
|
+
stroke-width="2"
|
|
2638
|
+
stroke-linecap="round"
|
|
2639
|
+
stroke-linejoin="round" />
|
|
2640
|
+
</svg>
|
|
2641
|
+
</span>
|
|
2642
|
+
{:else}
|
|
2643
|
+
<svg
|
|
2644
|
+
class="arrow-hint"
|
|
2645
|
+
width="17"
|
|
2646
|
+
height="17"
|
|
2647
|
+
viewBox="0 0 20 20"
|
|
2648
|
+
fill="none"
|
|
2649
|
+
aria-hidden="true">
|
|
2650
|
+
<path
|
|
2651
|
+
d="M6.5 8L10 4.5L13.5 8M6.5 12L10 15.5L13.5 12"
|
|
2652
|
+
stroke="currentColor"
|
|
2653
|
+
stroke-width="1.75"
|
|
2654
|
+
stroke-linecap="round"
|
|
2655
|
+
stroke-linejoin="round" />
|
|
2656
|
+
</svg>
|
|
2657
|
+
{/if}
|
|
2658
|
+
</span>
|
|
2659
|
+
</button>
|
|
2660
|
+
{:else}
|
|
2661
|
+
<div class="th-content" style:justify-content={headerJustify(col)}>
|
|
2662
|
+
<span>{col.label}</span>
|
|
2663
|
+
</div>
|
|
2664
|
+
{/if}
|
|
2665
|
+
{#if resizable}
|
|
2666
|
+
<!-- A focusable, resizable separator (the WAI-ARIA window-splitter
|
|
2667
|
+
pattern): the tabindex + key/pointer handlers are intentional. -->
|
|
2668
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
2669
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
2670
|
+
<span
|
|
2671
|
+
class="resize-handle"
|
|
2672
|
+
class:active={resizing?.column_key === col.key}
|
|
2673
|
+
class:edge={ci === columns.length - 1}
|
|
2674
|
+
role="separator"
|
|
2675
|
+
aria-orientation="vertical"
|
|
2676
|
+
aria-label="Resize {col.label} column"
|
|
2677
|
+
tabindex="0"
|
|
2678
|
+
data-resize-key={col.key}
|
|
2679
|
+
title="Drag to resize · double-click to auto-fit"
|
|
2680
|
+
onpointerdown={(e) => startResize(e, col.key)}
|
|
2681
|
+
ondblclick={(e) => autoFitColumn(e, col.key)}
|
|
2682
|
+
onkeydown={(e) => handleResizeKeydown(e, col.key)}>
|
|
2683
|
+
<span class="resize-line" aria-hidden="true"></span>
|
|
2684
|
+
</span>
|
|
2685
|
+
{/if}
|
|
2686
|
+
</th>
|
|
2687
|
+
{/each}
|
|
2688
|
+
</tr>
|
|
2689
|
+
</thead>
|
|
2690
|
+
<tbody>
|
|
2691
|
+
{#if skeleton}
|
|
2692
|
+
{#each { length: skeleton_count } as _, ri}
|
|
2693
|
+
<!-- --shimmer-delay inherits to every bar's animating ::after,
|
|
2694
|
+
staggering the shimmer into a wave down the rows. -->
|
|
2695
|
+
<tr
|
|
2696
|
+
class="skeleton-row"
|
|
2697
|
+
aria-hidden="true"
|
|
2698
|
+
style:--shimmer-delay="{ri * 120}ms">
|
|
2699
|
+
{#if selectable}
|
|
2700
|
+
<td class="checkbox-cell">
|
|
2701
|
+
<div
|
|
2702
|
+
class="skeleton-bar glyph"
|
|
2703
|
+
style="width: 18px; height: 18px; border-radius: 5px;">
|
|
2704
|
+
</div>
|
|
2705
|
+
</td>
|
|
2706
|
+
{/if}
|
|
2707
|
+
{#if expandable}
|
|
2708
|
+
<td class="expand-cell">
|
|
2709
|
+
<div
|
|
2710
|
+
class="skeleton-bar glyph"
|
|
2711
|
+
style="width: 18px; height: 18px; border-radius: 50%;">
|
|
2712
|
+
</div>
|
|
2713
|
+
</td>
|
|
2714
|
+
{/if}
|
|
2715
|
+
{#each columns as col, ci (col.key)}
|
|
2716
|
+
<td style={col.align ? `text-align: ${col.align}` : ''}>
|
|
2717
|
+
<div class="skeleton-bar" style="width: {getSkeletonWidth(ri, ci)}">
|
|
2718
|
+
</div>
|
|
2719
|
+
</td>
|
|
2720
|
+
{/each}
|
|
2721
|
+
</tr>
|
|
2722
|
+
{/each}
|
|
2723
|
+
{:else if data.length === 0}
|
|
2724
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
2725
|
+
<tr class="empty-row" role="row">
|
|
2726
|
+
<td colspan={totalColumns} role="gridcell">
|
|
2727
|
+
{#if empty}
|
|
2728
|
+
{@render empty()}
|
|
2729
|
+
{:else}
|
|
2730
|
+
<div class="empty">
|
|
2731
|
+
<svg
|
|
2732
|
+
width="48"
|
|
2733
|
+
height="48"
|
|
2734
|
+
viewBox="0 0 48 48"
|
|
2735
|
+
fill="none"
|
|
2736
|
+
aria-hidden="true">
|
|
2737
|
+
<rect
|
|
2738
|
+
x="6"
|
|
2739
|
+
y="10"
|
|
2740
|
+
width="36"
|
|
2741
|
+
height="28"
|
|
2742
|
+
rx="4"
|
|
2743
|
+
stroke="currentColor"
|
|
2744
|
+
stroke-width="2"
|
|
2745
|
+
fill="none"
|
|
2746
|
+
opacity="0.3" />
|
|
2747
|
+
<line
|
|
2748
|
+
x1="6"
|
|
2749
|
+
y1="18"
|
|
2750
|
+
x2="42"
|
|
2751
|
+
y2="18"
|
|
2752
|
+
stroke="currentColor"
|
|
2753
|
+
stroke-width="2"
|
|
2754
|
+
opacity="0.3" />
|
|
2755
|
+
<line
|
|
2756
|
+
x1="18"
|
|
2757
|
+
y1="18"
|
|
2758
|
+
x2="18"
|
|
2759
|
+
y2="38"
|
|
2760
|
+
stroke="currentColor"
|
|
2761
|
+
stroke-width="2"
|
|
2762
|
+
opacity="0.2" />
|
|
2763
|
+
</svg>
|
|
2764
|
+
<p>No data available</p>
|
|
2765
|
+
</div>
|
|
2766
|
+
{/if}
|
|
2767
|
+
</td>
|
|
2768
|
+
</tr>
|
|
2769
|
+
{:else if groupedData}
|
|
2770
|
+
{#each groupedData as group (group.key)}
|
|
2771
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
2772
|
+
<tr class="group-row" role="row">
|
|
2773
|
+
<td colspan={totalColumns} role="gridcell">
|
|
2774
|
+
<button
|
|
2775
|
+
class="group-toggle"
|
|
2776
|
+
type="button"
|
|
2777
|
+
onclick={() => toggleGroup(group.key)}
|
|
2778
|
+
aria-expanded={!collapsedGroups.has(group.key)}>
|
|
2779
|
+
<svg
|
|
2780
|
+
class="group-chevron"
|
|
2781
|
+
class:group-collapsed={collapsedGroups.has(group.key)}
|
|
2782
|
+
width="14"
|
|
2783
|
+
height="14"
|
|
2784
|
+
viewBox="0 0 14 14"
|
|
2785
|
+
fill="none"
|
|
2786
|
+
aria-hidden="true">
|
|
2787
|
+
<path
|
|
2788
|
+
d="M5 3l4 4-4 4"
|
|
2789
|
+
stroke="currentColor"
|
|
2790
|
+
stroke-width="1.5"
|
|
2791
|
+
stroke-linecap="round"
|
|
2792
|
+
stroke-linejoin="round" />
|
|
2793
|
+
</svg>
|
|
2794
|
+
<span class="group-label">{group.label}</span>
|
|
2795
|
+
<span class="group-count">({group.rows.length})</span>
|
|
2796
|
+
</button>
|
|
2797
|
+
</td>
|
|
2798
|
+
</tr>
|
|
2799
|
+
{#if !collapsedGroups.has(group.key)}
|
|
2800
|
+
{#each group.rows as { row, data_index, visual_index } (keyOf(row, data_index))}
|
|
2801
|
+
{@render dataRow(row, data_index, visual_index)}
|
|
2802
|
+
{#if expandable && expandedRows.has(data_index) && expanded_row}
|
|
2803
|
+
{@render expandedRowTr(row, data_index)}
|
|
2804
|
+
{/if}
|
|
2805
|
+
{/each}
|
|
2806
|
+
{/if}
|
|
2807
|
+
{/each}
|
|
2808
|
+
{:else if virtualWindow}
|
|
2809
|
+
{#if virtualWindow.top_pad > 0}
|
|
2810
|
+
<tr
|
|
2811
|
+
class="v-spacer"
|
|
2812
|
+
aria-hidden="true"
|
|
2813
|
+
style:height="{virtualWindow.top_pad}px">
|
|
2814
|
+
</tr>
|
|
2815
|
+
{/if}
|
|
2816
|
+
{#each virtualWindow.rows as { row, data_index, visual_index } (keyOf(row, data_index))}
|
|
2817
|
+
{@render dataRow(row, data_index, visual_index)}
|
|
2818
|
+
{#if expandable && expandedRows.has(data_index) && expanded_row}
|
|
2819
|
+
{@render expandedRowTr(row, data_index)}
|
|
2820
|
+
{/if}
|
|
2821
|
+
{/each}
|
|
2822
|
+
{#if virtualWindow.bottom_pad > 0}
|
|
2823
|
+
<tr
|
|
2824
|
+
class="v-spacer"
|
|
2825
|
+
aria-hidden="true"
|
|
2826
|
+
style:height="{virtualWindow.bottom_pad}px">
|
|
2827
|
+
</tr>
|
|
2828
|
+
{/if}
|
|
2829
|
+
{:else}
|
|
2830
|
+
{#each flatRows as { row, data_index, visual_index } (keyOf(row, data_index))}
|
|
2831
|
+
{@render dataRow(row, data_index, visual_index)}
|
|
2832
|
+
{#if expandable && expandedRows.has(data_index) && expanded_row}
|
|
2833
|
+
{@render expandedRowTr(row, data_index)}
|
|
2834
|
+
{/if}
|
|
2835
|
+
{/each}
|
|
2836
|
+
{/if}
|
|
2837
|
+
</tbody>
|
|
2838
|
+
</table>
|
|
2839
|
+
</div>
|
|
2840
|
+
|
|
2841
|
+
<!-- Floating drag overlay: the lifted row(s), cloned, following the pointer.
|
|
2842
|
+
Position is driven imperatively (see positionOverlay/finishDrop). Lives
|
|
2843
|
+
outside `.scroll` so it isn't clipped, and is purely presentational. -->
|
|
2844
|
+
{#if reorderDragging || reorderDropping}
|
|
2845
|
+
<div
|
|
2846
|
+
bind:this={overlayEl}
|
|
2847
|
+
class="drag-overlay"
|
|
2848
|
+
class:dense
|
|
2849
|
+
class:comfortable
|
|
2850
|
+
class:collapsed={overlayMore > 0}
|
|
2851
|
+
style:grid-template-columns={gridTemplateColumns}
|
|
2852
|
+
aria-hidden="true">
|
|
2853
|
+
{#each overlayRows as { row, data_index } (data_index)}
|
|
2854
|
+
<div class="ghost-row">
|
|
2855
|
+
{#if reorderGrip}
|
|
2856
|
+
<div class="ghost-cell grip-cell">
|
|
2857
|
+
<svg
|
|
2858
|
+
class="grip-dots"
|
|
2859
|
+
viewBox="0 0 10 16"
|
|
2860
|
+
width="10"
|
|
2861
|
+
height="16"
|
|
2862
|
+
aria-hidden="true">
|
|
2863
|
+
<circle cx="2.5" cy="3" r="1.2" />
|
|
2864
|
+
<circle cx="7.5" cy="3" r="1.2" />
|
|
2865
|
+
<circle cx="2.5" cy="8" r="1.2" />
|
|
2866
|
+
<circle cx="7.5" cy="8" r="1.2" />
|
|
2867
|
+
<circle cx="2.5" cy="13" r="1.2" />
|
|
2868
|
+
<circle cx="7.5" cy="13" r="1.2" />
|
|
2869
|
+
</svg>
|
|
2870
|
+
</div>
|
|
2871
|
+
{/if}
|
|
2872
|
+
{#if selectable}
|
|
2873
|
+
<div class="ghost-cell checkbox-cell">
|
|
2874
|
+
<span class="check-wrap" class:checked={isSelectedIndex(data_index)}>
|
|
2875
|
+
{@render checkIndicator(isSelectedIndex(data_index), false, false)}
|
|
2876
|
+
</span>
|
|
2877
|
+
</div>
|
|
2878
|
+
{/if}
|
|
2879
|
+
{#if expandable}
|
|
2880
|
+
<div class="ghost-cell expand-cell"><span class="expand-btn"></span></div>
|
|
2881
|
+
{/if}
|
|
2882
|
+
{#each columns as col (col.key)}
|
|
2883
|
+
<div class="ghost-cell" style={getColumnStyle(col)}>
|
|
2884
|
+
{#if col.cell}
|
|
2885
|
+
{@render col.cell({
|
|
2886
|
+
value: getCellValue(row, col.key),
|
|
2887
|
+
row,
|
|
2888
|
+
index: data_index,
|
|
2889
|
+
})}
|
|
2890
|
+
{:else}
|
|
2891
|
+
<span class="cell-text">{getCellValue(row, col.key) ?? ''}</span>
|
|
2892
|
+
{/if}
|
|
2893
|
+
</div>
|
|
2894
|
+
{/each}
|
|
2895
|
+
</div>
|
|
2896
|
+
{/each}
|
|
2897
|
+
{#if overlayMore > 0}
|
|
2898
|
+
<div class="drag-count" aria-hidden="true">{overlayMore}</div>
|
|
2899
|
+
{/if}
|
|
2900
|
+
</div>
|
|
2901
|
+
{/if}
|
|
2902
|
+
|
|
2903
|
+
{#if showPager && (pgConfig.position === 'bottom' || pgConfig.position === 'both')}
|
|
2904
|
+
{@render pager('bottom')}
|
|
2905
|
+
{/if}
|
|
2906
|
+
</div>
|
|
2907
|
+
|
|
2908
|
+
{#snippet pager(placement: 'top' | 'bottom')}
|
|
2909
|
+
<div class="pager align-{pgConfig.align}" class:top={placement === 'top'}>
|
|
2910
|
+
<Pagination
|
|
2911
|
+
bind:page
|
|
2912
|
+
bind:page_size
|
|
2913
|
+
total_pages={pgTotalPages}
|
|
2914
|
+
total_items={pgTotalItems}
|
|
2915
|
+
page_size_options={pgConfig.page_size_options ?? [10, 25, 50, 100]}
|
|
2916
|
+
simple={pgConfig.variant === 'simple'}
|
|
2917
|
+
compact={pgConfig.variant === 'compact'}
|
|
2918
|
+
show_page_size={!!pgConfig.page_size_options}
|
|
2919
|
+
show_info={pgConfig.show_info}
|
|
2920
|
+
sibling_count={pgConfig.sibling_count}
|
|
2921
|
+
boundary_count={pgConfig.boundary_count}
|
|
2922
|
+
size={pgConfig.size}
|
|
2923
|
+
onchange={(d) => onpagechange?.({ page: d.page, page_size })} />
|
|
2924
|
+
</div>
|
|
2925
|
+
{/snippet}
|
|
2926
|
+
|
|
2927
|
+
{#snippet checkIndicator(checked: boolean, indeterminate: boolean, preview: boolean)}
|
|
2928
|
+
<svg
|
|
2929
|
+
class="check-icon"
|
|
2930
|
+
class:checked={checked || indeterminate}
|
|
2931
|
+
class:indeterminate
|
|
2932
|
+
class:preview
|
|
2933
|
+
viewBox="0 0 24 24"
|
|
2934
|
+
width="20"
|
|
2935
|
+
height="20"
|
|
2936
|
+
fill="none"
|
|
2937
|
+
aria-hidden="true">
|
|
2938
|
+
<rect class="box" x="2" y="2" width="20" height="20" rx="5" stroke-width="2" />
|
|
2939
|
+
{#if indeterminate}
|
|
2940
|
+
<line
|
|
2941
|
+
class="dash"
|
|
2942
|
+
x1="7"
|
|
2943
|
+
y1="12"
|
|
2944
|
+
x2="17"
|
|
2945
|
+
y2="12"
|
|
2946
|
+
stroke-width="2.5"
|
|
2947
|
+
stroke-linecap="round" />
|
|
2948
|
+
{:else}
|
|
2949
|
+
<path
|
|
2950
|
+
class="check"
|
|
2951
|
+
d="M6 12.5 L10 16.5 L18 8"
|
|
2952
|
+
stroke-width="2.5"
|
|
2953
|
+
stroke-linecap="round"
|
|
2954
|
+
stroke-linejoin="round" />
|
|
2955
|
+
{/if}
|
|
2956
|
+
</svg>
|
|
2957
|
+
{/snippet}
|
|
2958
|
+
|
|
2959
|
+
{#snippet dataRow(row: T, dataIndex: number, visualIndex: number)}
|
|
2960
|
+
{@const rowClickable = selectable || !!onrowclick || expandable}
|
|
2961
|
+
{@const rowSelected = selectable && isSelectedIndex(dataIndex)}
|
|
2962
|
+
{@const previewing = selectable && isPreviewingVisual(visualIndex) && !rowSelected}
|
|
2963
|
+
{@const dragShift = rowTransforms.get(dataIndex)}
|
|
2964
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
2965
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
2966
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
2967
|
+
<tr
|
|
2968
|
+
role="row"
|
|
2969
|
+
class="row"
|
|
2970
|
+
data-row-index={visualIndex}
|
|
2971
|
+
class:stripe={striped && visualIndex % 2 === 1}
|
|
2972
|
+
class:selected={rowSelected}
|
|
2973
|
+
class:preview={previewing}
|
|
2974
|
+
class:clickable={rowClickable}
|
|
2975
|
+
class:reorderable={reorderActive}
|
|
2976
|
+
class:drag-source={draggedDataSet.has(dataIndex)}
|
|
2977
|
+
class:drag-armed={armedDataIndex === dataIndex}
|
|
2978
|
+
style:transform={dragShift ? `translateY(${dragShift}px)` : undefined}
|
|
2979
|
+
onclick={(e) => handleRowClick(row, dataIndex, visualIndex, e)}
|
|
2980
|
+
onmouseenter={() => {
|
|
2981
|
+
if (selectable) hoverIndex = visualIndex;
|
|
2982
|
+
}}
|
|
2983
|
+
onpointerdown={reorderActive && !reorderGrip
|
|
2984
|
+
? (e) => onRowPointerDown(e, dataIndex, visualIndex)
|
|
2985
|
+
: undefined}
|
|
2986
|
+
{@attach ripple({ enabled: rowClickable && !editable })}>
|
|
2987
|
+
{#if reorderGrip}
|
|
2988
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
2989
|
+
<td
|
|
2990
|
+
class="grip-cell"
|
|
2991
|
+
role="gridcell"
|
|
2992
|
+
aria-label="Drag to reorder row {dataIndex + 1}"
|
|
2993
|
+
onpointerdown={(e) => onRowPointerDown(e, dataIndex, visualIndex)}>
|
|
2994
|
+
<svg
|
|
2995
|
+
class="grip-dots"
|
|
2996
|
+
viewBox="0 0 10 16"
|
|
2997
|
+
width="10"
|
|
2998
|
+
height="16"
|
|
2999
|
+
aria-hidden="true">
|
|
3000
|
+
<circle cx="2.5" cy="3" r="1.2" />
|
|
3001
|
+
<circle cx="7.5" cy="3" r="1.2" />
|
|
3002
|
+
<circle cx="2.5" cy="8" r="1.2" />
|
|
3003
|
+
<circle cx="7.5" cy="8" r="1.2" />
|
|
3004
|
+
<circle cx="2.5" cy="13" r="1.2" />
|
|
3005
|
+
<circle cx="7.5" cy="13" r="1.2" />
|
|
3006
|
+
</svg>
|
|
3007
|
+
</td>
|
|
3008
|
+
{/if}
|
|
3009
|
+
{#if selectable}
|
|
3010
|
+
<td class="checkbox-cell" role="gridcell">
|
|
3011
|
+
<div
|
|
3012
|
+
class="check-wrap"
|
|
3013
|
+
class:checked={rowSelected}
|
|
3014
|
+
class:preview={previewing}
|
|
3015
|
+
role="checkbox"
|
|
3016
|
+
tabindex="0"
|
|
3017
|
+
aria-checked={rowSelected}
|
|
3018
|
+
aria-label="Select row {dataIndex + 1}"
|
|
3019
|
+
{@attach ripple({ centered: true, opacity: 0.15 })}
|
|
3020
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
3021
|
+
onclick={(e) => {
|
|
3022
|
+
e.stopPropagation();
|
|
3023
|
+
toggleSelectRow(dataIndex, visualIndex, e);
|
|
3024
|
+
}}
|
|
3025
|
+
onkeydown={(e) => handleRowCheckKeydown(e, dataIndex, visualIndex)}>
|
|
3026
|
+
{@render checkIndicator(rowSelected || previewing, false, previewing)}
|
|
3027
|
+
</div>
|
|
3028
|
+
</td>
|
|
3029
|
+
{/if}
|
|
3030
|
+
{#if expandable}
|
|
3031
|
+
<td class="expand-cell" role="gridcell">
|
|
3032
|
+
<button
|
|
3033
|
+
class="expand-btn"
|
|
3034
|
+
type="button"
|
|
3035
|
+
aria-expanded={expandedRows.has(dataIndex)}
|
|
3036
|
+
aria-label={expandedRows.has(dataIndex) ? 'Collapse row' : 'Expand row'}
|
|
3037
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
3038
|
+
onclick={(e) => {
|
|
3039
|
+
e.stopPropagation();
|
|
3040
|
+
toggleExpand(dataIndex);
|
|
3041
|
+
}}>
|
|
3042
|
+
<svg
|
|
3043
|
+
class="expand-chevron"
|
|
3044
|
+
class:expanded={expandedRows.has(dataIndex)}
|
|
3045
|
+
width="15"
|
|
3046
|
+
height="15"
|
|
3047
|
+
viewBox="0 0 14 14"
|
|
3048
|
+
fill="none"
|
|
3049
|
+
aria-hidden="true">
|
|
3050
|
+
<path
|
|
3051
|
+
d="M5 3l4 4-4 4"
|
|
3052
|
+
stroke="currentColor"
|
|
3053
|
+
stroke-width="1.75"
|
|
3054
|
+
stroke-linecap="round"
|
|
3055
|
+
stroke-linejoin="round" />
|
|
3056
|
+
</svg>
|
|
3057
|
+
</button>
|
|
3058
|
+
</td>
|
|
3059
|
+
{/if}
|
|
3060
|
+
{#each columns as col, ci (col.key)}
|
|
3061
|
+
{@const rowId = keyOf(row, dataIndex)}
|
|
3062
|
+
{@const editableCell = resolveEditable(col, row)}
|
|
3063
|
+
{@const isBoolCol = editableCell && editorTypeOf(col) === 'boolean'}
|
|
3064
|
+
{@const ckey = cellKey(rowId, col.key)}
|
|
3065
|
+
{@const active = isActiveCell(rowId, col.key)}
|
|
3066
|
+
{@const dispVal = cellOptimistic.has(ckey)
|
|
3067
|
+
? cellOptimistic.get(ckey)
|
|
3068
|
+
: getCellValue(row, col.key)}
|
|
3069
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
3070
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
3071
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
3072
|
+
<td
|
|
3073
|
+
style={getColumnStyle(col)}
|
|
3074
|
+
role="gridcell"
|
|
3075
|
+
class:col-resizing={resizing?.column_key === col.key}
|
|
3076
|
+
class:col-hover={hoveredResizeKey === col.key}
|
|
3077
|
+
class:editable-cell={editableCell}
|
|
3078
|
+
class:cell-active={active}
|
|
3079
|
+
class:cell-error={cellError.has(ckey)}
|
|
3080
|
+
data-no-drag={editableCell ? '' : undefined}
|
|
3081
|
+
tabindex={editableCell && !isBoolCol && !active
|
|
3082
|
+
? isRovingCell(rowId, col.key)
|
|
3083
|
+
? 0
|
|
3084
|
+
: -1
|
|
3085
|
+
: undefined}
|
|
3086
|
+
onclick={editableCell && !isBoolCol
|
|
3087
|
+
? (e) => handleCellClick(e, rowId, col.key)
|
|
3088
|
+
: undefined}
|
|
3089
|
+
onfocus={editableCell && !isBoolCol && !active
|
|
3090
|
+
? () => enterEdit(rowId, col.key)
|
|
3091
|
+
: undefined}>
|
|
3092
|
+
{#if isBoolCol}
|
|
3093
|
+
<!-- A persistent button (never swapped for an editor), so a single click
|
|
3094
|
+
toggles it. Fills the cell so a click anywhere in it counts. -->
|
|
3095
|
+
<button
|
|
3096
|
+
type="button"
|
|
3097
|
+
class="cell-checkbox"
|
|
3098
|
+
role="switch"
|
|
3099
|
+
aria-checked={!!dispVal}
|
|
3100
|
+
aria-label={col.label}
|
|
3101
|
+
tabindex={active || isRovingCell(rowId, col.key) ? 0 : -1}
|
|
3102
|
+
onclick={(e) => {
|
|
3103
|
+
e.stopPropagation();
|
|
3104
|
+
toggleBooleanCell(rowId, col, row, dataIndex);
|
|
3105
|
+
}}
|
|
3106
|
+
onkeydown={(e) => booleanCellKeydown(e, rowId, col, row, dataIndex)}
|
|
3107
|
+
onfocus={() => enterEdit(rowId, col.key)}>
|
|
3108
|
+
<span class="cell-bool" class:checked={!!dispVal} aria-hidden="true">
|
|
3109
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
|
|
3110
|
+
<rect
|
|
3111
|
+
class="box"
|
|
3112
|
+
x="2"
|
|
3113
|
+
y="2"
|
|
3114
|
+
width="20"
|
|
3115
|
+
height="20"
|
|
3116
|
+
rx="3"
|
|
3117
|
+
stroke-width="2" />
|
|
3118
|
+
<path
|
|
3119
|
+
class="check"
|
|
3120
|
+
d="M6 12.5 L10 16.5 L18 8"
|
|
3121
|
+
stroke-width="2.5"
|
|
3122
|
+
stroke-linecap="round"
|
|
3123
|
+
stroke-linejoin="round" />
|
|
3124
|
+
</svg>
|
|
3125
|
+
</span>
|
|
3126
|
+
</button>
|
|
3127
|
+
{:else if active}
|
|
3128
|
+
{#key activeKey}
|
|
3129
|
+
<TableCellEditor
|
|
3130
|
+
column={col}
|
|
3131
|
+
{row}
|
|
3132
|
+
index={dataIndex}
|
|
3133
|
+
value={dispVal}
|
|
3134
|
+
errorMessage={cellError.get(ckey)}
|
|
3135
|
+
{dense}
|
|
3136
|
+
{comfortable}
|
|
3137
|
+
isFirstCell={isFirstNavCell({ row_id: rowId, col_key: col.key })}
|
|
3138
|
+
isLastCell={isLastNavCell({ row_id: rowId, col_key: col.key })}
|
|
3139
|
+
autoOpenMenu={autoOpenEditorMenu}
|
|
3140
|
+
oncommit={(d) => commitCell(rowId, col.key, row, dataIndex, col, d.value)}
|
|
3141
|
+
onliveinput={(d) => liveInput(rowId, col.key, row, dataIndex, col, d.value)}
|
|
3142
|
+
onnavigate={(d) => navigate(d.dir)}
|
|
3143
|
+
onexit={() => exitEdit(rowId, col.key)} />
|
|
3144
|
+
{/key}
|
|
3145
|
+
{:else if col.cell}
|
|
3146
|
+
{@render col.cell({ value: dispVal, row, index: dataIndex })}
|
|
3147
|
+
{:else}
|
|
3148
|
+
<span class="cell-text">{formatCell(col, dispVal, row)}</span>
|
|
3149
|
+
{/if}
|
|
3150
|
+
{#if editableCell && !isBoolCol && cellPending.has(ckey)}
|
|
3151
|
+
<span class="cell-status pending" aria-label="Saving">
|
|
3152
|
+
<Progress size="00" color="currentColor" />
|
|
3153
|
+
</span>
|
|
3154
|
+
{:else if editableCell && !isBoolCol && cellSaved.has(ckey)}
|
|
3155
|
+
<span class="cell-status saved" aria-hidden="true">
|
|
3156
|
+
<svg viewBox="0 0 18 18" width="16" height="16">
|
|
3157
|
+
<path
|
|
3158
|
+
d="M4 9.5l3.2 3.2L14 5.5"
|
|
3159
|
+
fill="none"
|
|
3160
|
+
stroke="currentColor"
|
|
3161
|
+
stroke-width="2.75"
|
|
3162
|
+
stroke-linecap="round"
|
|
3163
|
+
stroke-linejoin="round" />
|
|
3164
|
+
</svg>
|
|
3165
|
+
</span>
|
|
3166
|
+
{/if}
|
|
3167
|
+
{#if resizable && !active}
|
|
3168
|
+
{#if ci > 0}
|
|
3169
|
+
<!-- Left-edge zone: resizes the PREVIOUS column, covering the RIGHT
|
|
3170
|
+
side of that border. Body cells are `overflow: hidden` for text
|
|
3171
|
+
ellipsis, so a right-edge zone alone can only reach the LEFT side
|
|
3172
|
+
of a border; pairing it with this one straddles the border from
|
|
3173
|
+
both cells without anything needing to overflow. -->
|
|
3174
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
3175
|
+
<span
|
|
3176
|
+
class="resize-handle body start"
|
|
3177
|
+
class:active={resizing?.column_key === columns[ci - 1].key}
|
|
3178
|
+
data-resize-key={columns[ci - 1].key}
|
|
3179
|
+
aria-hidden="true"
|
|
3180
|
+
onpointerdown={(e) => startResize(e, columns[ci - 1].key)}
|
|
3181
|
+
ondblclick={(e) => autoFitColumn(e, columns[ci - 1].key)}>
|
|
3182
|
+
</span>
|
|
3183
|
+
{/if}
|
|
3184
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
3185
|
+
<span
|
|
3186
|
+
class="resize-handle body"
|
|
3187
|
+
class:active={resizing?.column_key === col.key}
|
|
3188
|
+
class:edge={ci === columns.length - 1}
|
|
3189
|
+
data-resize-key={col.key}
|
|
3190
|
+
aria-hidden="true"
|
|
3191
|
+
onpointerdown={(e) => startResize(e, col.key)}
|
|
3192
|
+
ondblclick={(e) => autoFitColumn(e, col.key)}>
|
|
3193
|
+
</span>
|
|
3194
|
+
{/if}
|
|
3195
|
+
</td>
|
|
3196
|
+
{/each}
|
|
3197
|
+
</tr>
|
|
3198
|
+
{/snippet}
|
|
3199
|
+
|
|
3200
|
+
{#snippet expandedRowTr(row: T, index: number)}
|
|
3201
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
3202
|
+
<tr class="expanded-row" role="row">
|
|
3203
|
+
<td colspan={totalColumns} role="gridcell">
|
|
3204
|
+
<div
|
|
3205
|
+
class="expanded-content"
|
|
3206
|
+
transition:slide={{
|
|
3207
|
+
duration: prefersReducedMotion() ? 0 : 240,
|
|
3208
|
+
easing: quintOut,
|
|
3209
|
+
}}
|
|
3210
|
+
{@attach measureExpanded(index)}>
|
|
3211
|
+
{#if expanded_row}
|
|
3212
|
+
{@render expanded_row(row)}
|
|
3213
|
+
{/if}
|
|
3214
|
+
</div>
|
|
3215
|
+
</td>
|
|
3216
|
+
</tr>
|
|
3217
|
+
{/snippet}
|
|
3218
|
+
|
|
3219
|
+
<style>
|
|
3220
|
+
/* ========== Wrapper ========== */
|
|
3221
|
+
.wrapper {
|
|
3222
|
+
width: 100%;
|
|
3223
|
+
position: relative;
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
/* ========== Pagination ========== */
|
|
3227
|
+
/* The pager sits in its own bar above and/or below the bordered table frame.
|
|
3228
|
+
`align` decides the layout: `between` (default) splits the info/summary to the
|
|
3229
|
+
left and the page controls to the right; the others align the whole pager. */
|
|
3230
|
+
.pager {
|
|
3231
|
+
display: flex;
|
|
3232
|
+
align-items: center;
|
|
3233
|
+
padding-top: 0.875rem;
|
|
3234
|
+
|
|
3235
|
+
&.top {
|
|
3236
|
+
padding-top: 0;
|
|
3237
|
+
padding-bottom: 0.875rem;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
&.align-start {
|
|
3241
|
+
justify-content: flex-start;
|
|
3242
|
+
}
|
|
3243
|
+
&.align-center {
|
|
3244
|
+
justify-content: center;
|
|
3245
|
+
}
|
|
3246
|
+
&.align-end {
|
|
3247
|
+
justify-content: flex-end;
|
|
3248
|
+
}
|
|
3249
|
+
|
|
3250
|
+
/* `between`: stretch the pager full-width and push just the page controls to
|
|
3251
|
+
the far edge, leaving the rows-per-page selector + summary grouped left. */
|
|
3252
|
+
&.align-between :global(.pagination) {
|
|
3253
|
+
width: 100%;
|
|
3254
|
+
}
|
|
3255
|
+
&.align-between :global(.pagination .pagination-controls) {
|
|
3256
|
+
margin-left: auto;
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
|
|
3260
|
+
/* ========== Toolbar ========== */
|
|
3261
|
+
.toolbar {
|
|
3262
|
+
display: flex;
|
|
3263
|
+
justify-content: flex-end;
|
|
3264
|
+
padding: 0 0 0.5rem;
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
.export {
|
|
3268
|
+
position: relative;
|
|
3269
|
+
|
|
3270
|
+
/* The trigger (the only direct button child; the menu options are nested
|
|
3271
|
+
inside `.menu`). */
|
|
3272
|
+
> button {
|
|
3273
|
+
display: inline-flex;
|
|
3274
|
+
align-items: center;
|
|
3275
|
+
gap: 0.375rem;
|
|
3276
|
+
padding: 0.375rem 0.75rem;
|
|
3277
|
+
font-size: 0.8125rem;
|
|
3278
|
+
font-family: inherit;
|
|
3279
|
+
border: 1px solid
|
|
3280
|
+
light-dark(var(--color-border, #d1d5db), var(--color-border, #4b5563));
|
|
3281
|
+
border-radius: var(--radius-lg, 10px);
|
|
3282
|
+
@supports (corner-shape: squircle) {
|
|
3283
|
+
corner-shape: squircle;
|
|
3284
|
+
border-radius: calc(var(--radius-lg, 10px) * var(--squircle-ratio, 2));
|
|
3285
|
+
}
|
|
3286
|
+
background: light-dark(var(--color-bg, #fff), var(--color-bg, #1a1a1a));
|
|
3287
|
+
color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
|
|
3288
|
+
cursor: pointer;
|
|
3289
|
+
line-height: 1;
|
|
3290
|
+
|
|
3291
|
+
&:hover {
|
|
3292
|
+
background: light-dark(
|
|
3293
|
+
rgb(from var(--color-text, #000) r g b / 0.04),
|
|
3294
|
+
rgb(from var(--color-text, #fff) r g b / 0.08)
|
|
3295
|
+
);
|
|
3296
|
+
transition: none;
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
.menu {
|
|
3301
|
+
position: absolute;
|
|
3302
|
+
top: 100%;
|
|
3303
|
+
right: 0;
|
|
3304
|
+
margin-top: 0.25rem;
|
|
3305
|
+
min-width: 140px;
|
|
3306
|
+
background: light-dark(var(--color-bg, #fff), var(--color-bg, #1a1a1a));
|
|
3307
|
+
border: 1px solid
|
|
3308
|
+
light-dark(var(--color-border, #d1d5db), var(--color-border, #4b5563));
|
|
3309
|
+
border-radius: var(--radius-lg, 10px);
|
|
3310
|
+
@supports (corner-shape: squircle) {
|
|
3311
|
+
corner-shape: squircle;
|
|
3312
|
+
border-radius: calc(var(--radius-lg, 10px) * var(--squircle-ratio, 2));
|
|
3313
|
+
}
|
|
3314
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
|
|
3315
|
+
z-index: 10;
|
|
3316
|
+
overflow: hidden;
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
.menu button {
|
|
3320
|
+
display: block;
|
|
3321
|
+
width: 100%;
|
|
3322
|
+
padding: 0.5rem 0.75rem;
|
|
3323
|
+
font-size: 0.8125rem;
|
|
3324
|
+
font-family: inherit;
|
|
3325
|
+
text-align: left;
|
|
3326
|
+
border: none;
|
|
3327
|
+
background: none;
|
|
3328
|
+
color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
|
|
3329
|
+
cursor: pointer;
|
|
3330
|
+
|
|
3331
|
+
&:hover {
|
|
3332
|
+
background: light-dark(
|
|
3333
|
+
rgb(from var(--color-text, #000) r g b / 0.06),
|
|
3334
|
+
rgb(from var(--color-text, #fff) r g b / 0.08)
|
|
3335
|
+
);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
&:not(:last-child) {
|
|
3339
|
+
border-bottom: 1px solid
|
|
3340
|
+
light-dark(var(--color-border, #e5e7eb), var(--color-border, #374151));
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
/* ========== Scroll Container / Frame ========== */
|
|
3346
|
+
.scroll {
|
|
3347
|
+
overflow-x: auto;
|
|
3348
|
+
border: 1px solid
|
|
3349
|
+
light-dark(var(--color-border, #e5e7eb), var(--color-border, #3a3a3a));
|
|
3350
|
+
border-radius: var(--table-radius, 14px);
|
|
3351
|
+
@supports (corner-shape: squircle) {
|
|
3352
|
+
corner-shape: squircle;
|
|
3353
|
+
border-radius: calc(var(--table-radius, 14px) * var(--squircle-ratio, 2));
|
|
3354
|
+
}
|
|
3355
|
+
/* Clip the rounded corners over the table + sticky header */
|
|
3356
|
+
}
|
|
3357
|
+
|
|
3358
|
+
/* Container scroller: the table scrolls vertically inside this frame (bounded
|
|
3359
|
+
by `max-height`) and the sticky header pins to its top. */
|
|
3360
|
+
.scroll.bounded {
|
|
3361
|
+
overflow-y: auto;
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
/* External scroller (parent/window/custom): the frame must NOT establish its
|
|
3365
|
+
own scroll container, so the chosen element's scrollbar drives the table.
|
|
3366
|
+
`overflow: visible` on both axes keeps the frame transparent to scrolling. */
|
|
3367
|
+
.scroll.passthrough {
|
|
3368
|
+
overflow: visible;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
/* Virtual-scroll spacers reserve the height of the off-screen rows above and
|
|
3372
|
+
below the rendered window so the scrollbar reflects the full row count. */
|
|
3373
|
+
.v-spacer {
|
|
3374
|
+
display: block;
|
|
3375
|
+
grid-column: 1 / -1;
|
|
3376
|
+
padding: 0;
|
|
3377
|
+
border: none;
|
|
3378
|
+
background: none;
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
/* ========== Table (CSS grid + subgrid rows) ==========
|
|
3382
|
+
Rendering as a grid (rather than native table layout) lets every <tr> be a
|
|
3383
|
+
real block box — so a row can host a row-wide ripple/hover/press — while
|
|
3384
|
+
subgrid keeps all the cells aligned to shared column tracks. Native
|
|
3385
|
+
table/tr/td tags are kept (with explicit ARIA roles) for semantics. */
|
|
3386
|
+
table {
|
|
3387
|
+
display: grid;
|
|
3388
|
+
/* grid-template-columns is set inline from `gridTemplateColumns` */
|
|
3389
|
+
width: 100%;
|
|
3390
|
+
font-size: 0.875rem;
|
|
3391
|
+
color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
/* Rowgroups collapse so each <tr> is a direct grid item of the table grid. */
|
|
3395
|
+
thead,
|
|
3396
|
+
tbody {
|
|
3397
|
+
display: contents;
|
|
3398
|
+
}
|
|
3399
|
+
|
|
3400
|
+
tr {
|
|
3401
|
+
display: grid;
|
|
3402
|
+
grid-template-columns: subgrid;
|
|
3403
|
+
grid-column: 1 / -1;
|
|
3404
|
+
/* Cells stretch to the full row height so the vertical column dividers
|
|
3405
|
+
span the whole row; each cell then centres its own content. */
|
|
3406
|
+
align-items: stretch;
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
/* ========== Header ========== */
|
|
3410
|
+
/* A recessed band (one+ step below the page bg, which the body rows sit on)
|
|
3411
|
+
so the header reads as a distinct strip in both light and dark mode —
|
|
3412
|
+
clearly deeper than the body's subtle stripe/hover tints. Opaque, because
|
|
3413
|
+
the sticky header scrolls over the rows. */
|
|
3414
|
+
thead tr {
|
|
3415
|
+
border-bottom: 2px solid
|
|
3416
|
+
light-dark(var(--color-border, #d1d5db), var(--color-border, #4b5563));
|
|
3417
|
+
background: light-dark(
|
|
3418
|
+
var(--color-bg-muted, #eef0f3),
|
|
3419
|
+
var(--color-bg-muted, #262626)
|
|
3420
|
+
);
|
|
3421
|
+
}
|
|
3422
|
+
|
|
3423
|
+
thead tr.sticky {
|
|
3424
|
+
position: sticky;
|
|
3425
|
+
top: 0;
|
|
3426
|
+
z-index: 2;
|
|
3427
|
+
}
|
|
3428
|
+
|
|
3429
|
+
th {
|
|
3430
|
+
display: flex;
|
|
3431
|
+
/* Stretch the inner button/content to fill the cell so the sort target
|
|
3432
|
+
(and column divider) cover the full header height. */
|
|
3433
|
+
align-items: stretch;
|
|
3434
|
+
text-align: left;
|
|
3435
|
+
font-weight: 600;
|
|
3436
|
+
white-space: nowrap;
|
|
3437
|
+
min-width: 0;
|
|
3438
|
+
background: light-dark(
|
|
3439
|
+
var(--color-bg-muted, #eef0f3),
|
|
3440
|
+
var(--color-bg-muted, #262626)
|
|
3441
|
+
);
|
|
3442
|
+
/* Full-strength text (not muted): the header labels are wayfinding, they
|
|
3443
|
+
must read at a glance. */
|
|
3444
|
+
color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
|
|
3445
|
+
position: relative;
|
|
3446
|
+
user-select: none;
|
|
3447
|
+
}
|
|
3448
|
+
|
|
3449
|
+
/* Vertical header dividers (subtle), skipped after the final column. The
|
|
3450
|
+
header background is opaque, so a crisp border reads fine here. Body column
|
|
3451
|
+
dividers are drawn separately (see `td::after`) so the row tint + ripple can
|
|
3452
|
+
paint over them. */
|
|
3453
|
+
th:not(:last-child) {
|
|
3454
|
+
border-right: 1px solid
|
|
3455
|
+
light-dark(var(--color-border, #e8eaed), var(--color-border, #2b2b2b));
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
/* Non-interactive header content keeps the regular cell padding */
|
|
3459
|
+
.th-content {
|
|
3460
|
+
display: flex;
|
|
3461
|
+
flex: 1;
|
|
3462
|
+
align-items: center;
|
|
3463
|
+
gap: 0.25rem;
|
|
3464
|
+
padding: 0.75rem 1rem;
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
.dense .th-content {
|
|
3468
|
+
padding: 0.375rem 0.75rem;
|
|
3469
|
+
font-size: 0.75rem;
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
.comfortable .th-content {
|
|
3473
|
+
padding: 1rem 1.25rem;
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
/* ========== Sortable header: full-cell Button-like target ========== */
|
|
3477
|
+
th.sortable {
|
|
3478
|
+
padding: 0;
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
.th-button {
|
|
3482
|
+
display: flex;
|
|
3483
|
+
align-items: center;
|
|
3484
|
+
gap: 0.4rem;
|
|
3485
|
+
width: 100%;
|
|
3486
|
+
padding: 0.75rem 1rem;
|
|
3487
|
+
margin: 0;
|
|
3488
|
+
border: none;
|
|
3489
|
+
background: none;
|
|
3490
|
+
font: inherit;
|
|
3491
|
+
font-weight: 600;
|
|
3492
|
+
color: inherit;
|
|
3493
|
+
text-align: inherit;
|
|
3494
|
+
cursor: pointer;
|
|
3495
|
+
position: relative;
|
|
3496
|
+
overflow: hidden;
|
|
3497
|
+
user-select: none;
|
|
3498
|
+
transition:
|
|
3499
|
+
background-color 300ms ease,
|
|
3500
|
+
color 200ms ease,
|
|
3501
|
+
translate 200ms ease,
|
|
3502
|
+
scale 200ms ease;
|
|
3503
|
+
|
|
3504
|
+
/* Instant hover tint (like Button), eased away on leave */
|
|
3505
|
+
&:hover {
|
|
3506
|
+
background-color: light-dark(
|
|
3507
|
+
rgb(from var(--color-text, #000) r g b / 0.05),
|
|
3508
|
+
rgb(from var(--color-text, #fff) r g b / 0.07)
|
|
3509
|
+
);
|
|
3510
|
+
color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
|
|
3511
|
+
transition: color 200ms ease;
|
|
3512
|
+
}
|
|
3513
|
+
|
|
3514
|
+
&:active {
|
|
3515
|
+
/* Centred scale + nudge == a pure-Z perspective press, kept off the
|
|
3516
|
+
`transform` channel for the same reason as .row.clickable below. */
|
|
3517
|
+
translate: 0 0.95px;
|
|
3518
|
+
scale: 0.952;
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
&:focus-visible {
|
|
3522
|
+
outline: 2px solid var(--color-action, #1976d2);
|
|
3523
|
+
outline-offset: -2px;
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
.dense .th-button {
|
|
3528
|
+
padding: 0.375rem 0.75rem;
|
|
3529
|
+
font-size: 0.75rem;
|
|
3530
|
+
}
|
|
3531
|
+
|
|
3532
|
+
.comfortable .th-button {
|
|
3533
|
+
padding: 1rem 1.25rem;
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
/* ========== Sort Icon ========== */
|
|
3537
|
+
.sort-icon {
|
|
3538
|
+
display: inline-flex;
|
|
3539
|
+
align-items: center;
|
|
3540
|
+
justify-content: center;
|
|
3541
|
+
flex-shrink: 0;
|
|
3542
|
+
/* Full-strength text so the affordance is legible; the inactive hint
|
|
3543
|
+
stays lighter than the active arrow via its own opacity below. */
|
|
3544
|
+
color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3547
|
+
.sort-icon.active {
|
|
3548
|
+
color: light-dark(var(--color-action, #1976d2), var(--color-action, #5c9ce6));
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
/* Up/down hint shown on unsorted sortable columns — lighter than the active
|
|
3552
|
+
arrow, but clearly legible at rest. */
|
|
3553
|
+
.arrow-hint {
|
|
3554
|
+
opacity: 0.55;
|
|
3555
|
+
transition:
|
|
3556
|
+
opacity 180ms ease,
|
|
3557
|
+
translate 180ms ease;
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
.th-button:hover .arrow-hint {
|
|
3561
|
+
opacity: 0.9;
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
/* Active arrow: rotates between asc/desc, pops in on first sort */
|
|
3565
|
+
.arrow-rot {
|
|
3566
|
+
display: inline-flex;
|
|
3567
|
+
transition: transform 300ms var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
.arrow-rot.desc {
|
|
3571
|
+
transform: rotate(180deg);
|
|
3572
|
+
}
|
|
3573
|
+
|
|
3574
|
+
.arrow {
|
|
3575
|
+
animation: sort-pop 340ms var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
@keyframes sort-pop {
|
|
3579
|
+
0% {
|
|
3580
|
+
transform: scale(0.4);
|
|
3581
|
+
opacity: 0;
|
|
3582
|
+
}
|
|
3583
|
+
60% {
|
|
3584
|
+
transform: scale(1.18);
|
|
3585
|
+
}
|
|
3586
|
+
100% {
|
|
3587
|
+
transform: scale(1);
|
|
3588
|
+
opacity: 1;
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
/* ========== Resize Handle ==========
|
|
3593
|
+
A generous, invisible hit zone straddling the 1px column divider — ~4px of
|
|
3594
|
+
slack on each side, so you never have to land on the hairline. The divider
|
|
3595
|
+
itself still renders at 1px; hovering or grabbing the zone springs a 2px
|
|
3596
|
+
accent line to full height as the only visible affordance. */
|
|
3597
|
+
.resize-handle {
|
|
3598
|
+
position: absolute;
|
|
3599
|
+
top: 0;
|
|
3600
|
+
bottom: 0;
|
|
3601
|
+
right: -4px;
|
|
3602
|
+
width: 9px;
|
|
3603
|
+
display: flex;
|
|
3604
|
+
align-items: stretch;
|
|
3605
|
+
justify-content: center;
|
|
3606
|
+
cursor: col-resize;
|
|
3607
|
+
z-index: 3;
|
|
3608
|
+
/* Own the gesture on touch so a drag resizes instead of scrolling. */
|
|
3609
|
+
touch-action: none;
|
|
3610
|
+
user-select: none;
|
|
3611
|
+
-webkit-user-select: none;
|
|
3612
|
+
-webkit-tap-highlight-color: transparent;
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
/* The last column's zone sits flush inside the frame (no negative offset) so it
|
|
3616
|
+
can't overhang the table edge and spawn a px of phantom horizontal scroll. */
|
|
3617
|
+
.resize-handle.edge {
|
|
3618
|
+
right: 0;
|
|
3619
|
+
width: 8px;
|
|
3620
|
+
justify-content: flex-end;
|
|
3621
|
+
}
|
|
3622
|
+
|
|
3623
|
+
/* Body-cell zones extend the drag target down every row so you can grab a
|
|
3624
|
+
border anywhere, not just in the header. Each border is covered from BOTH
|
|
3625
|
+
cells it sits between — the right edge of the cell on its left (`.body`) and
|
|
3626
|
+
the left edge of the cell on its right (`.body.start`), both resizing the
|
|
3627
|
+
same (left) column — so the target straddles the border ~6px on each side
|
|
3628
|
+
while every zone stays fully inside its own cell. Keeping them in-cell (no
|
|
3629
|
+
negative offset) means no column can overhang the frame and spawn a phantom
|
|
3630
|
+
horizontal scrollbar. The visible feedback is the column's `td::after`
|
|
3631
|
+
divider (see `.col-hover` / `.col-resizing`), so these carry no line. */
|
|
3632
|
+
.resize-handle.body {
|
|
3633
|
+
right: 0;
|
|
3634
|
+
width: 6px;
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
.resize-handle.body.start {
|
|
3638
|
+
left: 0;
|
|
3639
|
+
right: auto;
|
|
3640
|
+
width: 6px;
|
|
3641
|
+
justify-content: flex-start;
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
/* Header zones reach 2px past the header cell to cover the thead's 2px bottom
|
|
3645
|
+
border, so the accent line is continuous from the header down through the
|
|
3646
|
+
body instead of breaking at the header/body seam. */
|
|
3647
|
+
.resize-handle:not(.body) {
|
|
3648
|
+
bottom: -2px;
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
.resize-line {
|
|
3652
|
+
width: 2px;
|
|
3653
|
+
align-self: stretch;
|
|
3654
|
+
border-radius: 2px;
|
|
3655
|
+
background: light-dark(var(--color-action, #1976d2), var(--color-action, #5c9ce6));
|
|
3656
|
+
opacity: 0;
|
|
3657
|
+
transform: scaleY(0.5);
|
|
3658
|
+
transform-origin: center;
|
|
3659
|
+
transition:
|
|
3660
|
+
opacity 160ms ease,
|
|
3661
|
+
transform 240ms var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1)),
|
|
3662
|
+
box-shadow 200ms ease;
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
/* Reveal the header accent line only when the pointer is actually inside a
|
|
3666
|
+
resize zone (in the header OR any body cell of this column) — driven by
|
|
3667
|
+
`hoveredResizeKey`, NOT by hovering the cell at large — and on keyboard
|
|
3668
|
+
focus. It springs from a squished scaleY to full height. */
|
|
3669
|
+
th.col-hover .resize-line,
|
|
3670
|
+
.resize-handle:focus-visible .resize-line {
|
|
3671
|
+
opacity: 0.6;
|
|
3672
|
+
transform: scaleY(1);
|
|
3673
|
+
}
|
|
3674
|
+
|
|
3675
|
+
/* Grabbing it (pointer down or while actively resizing): full-strength accent
|
|
3676
|
+
with a soft glow that reads as "live". */
|
|
3677
|
+
.resize-handle:active .resize-line,
|
|
3678
|
+
.resize-handle.active .resize-line {
|
|
3679
|
+
opacity: 1;
|
|
3680
|
+
transform: scaleY(1);
|
|
3681
|
+
box-shadow:
|
|
3682
|
+
0 0 0 1px rgb(from var(--color-action, #1976d2) r g b / 0.35),
|
|
3683
|
+
0 0 8px rgb(from var(--color-action, #1976d2) r g b / 0.55);
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
.resize-handle:focus-visible {
|
|
3687
|
+
outline: none;
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
.resize-handle:focus-visible .resize-line {
|
|
3691
|
+
box-shadow: 0 0 0 2px var(--color-action, #1976d2);
|
|
3692
|
+
}
|
|
3693
|
+
|
|
3694
|
+
/* Hover preview: the whole column boundary previews as a translucent 2px accent
|
|
3695
|
+
line, head to foot, the moment the pointer enters a resize zone. Shown via
|
|
3696
|
+
the body `td::after` dividers (and the header line above). The `:not(:last-
|
|
3697
|
+
child)` both matches the base divider's structure and out-specifies it, so
|
|
3698
|
+
the accent wins regardless of source order. */
|
|
3699
|
+
tbody td.col-hover:not(:last-child)::after {
|
|
3700
|
+
background: light-dark(
|
|
3701
|
+
rgb(from var(--color-action, #1976d2) r g b / 0.6),
|
|
3702
|
+
rgb(from var(--color-action, #5c9ce6) r g b / 0.7)
|
|
3703
|
+
);
|
|
3704
|
+
width: 2px;
|
|
3705
|
+
z-index: 4;
|
|
3706
|
+
/* Reach 1px past the cell to cover the row divider, so the line reads as one
|
|
3707
|
+
continuous stroke down the column rather than dashes between rows. */
|
|
3708
|
+
bottom: -1px;
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3711
|
+
/* While a column is being resized, light up its full-height boundary so it's
|
|
3712
|
+
clear what's moving — a crisp solid accent divider plus a faint column wash.
|
|
3713
|
+
The header keeps its opaque background (a sticky, translucent header would
|
|
3714
|
+
show rows scrolling behind it), so only the body cells take the wash. */
|
|
3715
|
+
tbody td.col-resizing {
|
|
3716
|
+
background-color: light-dark(
|
|
3717
|
+
rgb(from var(--color-action, #1976d2) r g b / 0.05),
|
|
3718
|
+
rgb(from var(--color-action, #5c9ce6) r g b / 0.08)
|
|
3719
|
+
);
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
tbody td.col-resizing:not(:last-child)::after {
|
|
3723
|
+
background: light-dark(var(--color-action, #1976d2), var(--color-action, #5c9ce6));
|
|
3724
|
+
width: 2px;
|
|
3725
|
+
/* Lift above the row tint + ripple for the duration of the drag. */
|
|
3726
|
+
z-index: 4;
|
|
3727
|
+
/* Bridge the row divider so the line is continuous (see `.col-hover`). */
|
|
3728
|
+
bottom: -1px;
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
/* The last row has no divider beneath it, so its accent must NOT overshoot the
|
|
3732
|
+
cell — a 1px overshoot past the final row would add a phantom vertical
|
|
3733
|
+
scrollbar (the frame's overflow-x:auto forces overflow-y to auto). */
|
|
3734
|
+
tbody tr.row:last-child td.col-hover::after,
|
|
3735
|
+
tbody tr.row:last-child td.col-resizing::after {
|
|
3736
|
+
bottom: 0;
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
/* ========== Body Rows ========== */
|
|
3740
|
+
/* Every body row is its own stacking context so its divider / tint / ripple
|
|
3741
|
+
layers (all negative z-index) stay contained to that row. */
|
|
3742
|
+
tbody tr {
|
|
3743
|
+
position: relative;
|
|
3744
|
+
isolation: isolate;
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3747
|
+
tbody tr.row {
|
|
3748
|
+
border-bottom: 1px solid
|
|
3749
|
+
light-dark(var(--color-border, #e5e7eb), var(--color-border, #2e2e2e));
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
/* The row background tint lives on a ::before layer (z-index -2) rather than
|
|
3753
|
+
the row's own background, so the column dividers (z-index -3) can sit
|
|
3754
|
+
BENEATH it — the tint, and then the ripple, paint over the lines. */
|
|
3755
|
+
tbody tr.row::before {
|
|
3756
|
+
content: '';
|
|
3757
|
+
position: absolute;
|
|
3758
|
+
inset: 0;
|
|
3759
|
+
z-index: -2;
|
|
3760
|
+
background-color: var(--row-bg);
|
|
3761
|
+
transition: background-color 260ms ease;
|
|
3762
|
+
pointer-events: none;
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
/* Default tint at zero specificity, so the state rules below (also :where)
|
|
3766
|
+
win purely by source order; the higher-specificity :hover rules still beat
|
|
3767
|
+
them. (A plain `tbody tr.row` default would out-specify the states and
|
|
3768
|
+
silently swallow the stripe/selected tints.) */
|
|
3769
|
+
:where(tbody tr.row) {
|
|
3770
|
+
--row-bg: transparent;
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
tbody tr.row:last-child {
|
|
3774
|
+
border-bottom: none;
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
td {
|
|
3778
|
+
display: flex;
|
|
3779
|
+
align-items: center;
|
|
3780
|
+
padding: 0.75rem 1rem;
|
|
3781
|
+
min-width: 0;
|
|
3782
|
+
/* Note: text clipping/ellipsis lives on the inner `.cell-text` (a flex cell
|
|
3783
|
+
can't ellipsize its own text), which leaves the cell itself
|
|
3784
|
+
`overflow: visible` — so the column-resize accent line (`td::after`) can
|
|
3785
|
+
extend 1px past the cell to bridge the row dividers instead of being
|
|
3786
|
+
chopped at each row. */
|
|
3787
|
+
/* Positioned so the divider pseudo anchors to the cell — but deliberately
|
|
3788
|
+
NOT a stacking context (no z-index), so the divider's negative z-index
|
|
3789
|
+
resolves in the row's stacking context, below the tint and ripple. */
|
|
3790
|
+
position: relative;
|
|
3791
|
+
/* Eases the column-resize wash in and out (see `td.col-resizing`). */
|
|
3792
|
+
transition: background-color 180ms ease;
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3795
|
+
/* Single-line text cells ellipsize here, not on the `td`: a flex container
|
|
3796
|
+
can't apply `text-overflow` to its own text, so the text needs its own block
|
|
3797
|
+
with `min-width: 0` (to allow shrinking) plus the clip/ellipsis. */
|
|
3798
|
+
.cell-text {
|
|
3799
|
+
min-width: 0;
|
|
3800
|
+
overflow: hidden;
|
|
3801
|
+
white-space: nowrap;
|
|
3802
|
+
text-overflow: ellipsis;
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
/* Body column dividers: a low pseudo-layer (z-index -3) that sits BELOW the
|
|
3806
|
+
row background tint (-2) and the ripple (-1), so the hover wash and the
|
|
3807
|
+
ripple paint over the lines while the cell text stays on top. */
|
|
3808
|
+
tbody td:not(:last-child)::after {
|
|
3809
|
+
content: '';
|
|
3810
|
+
position: absolute;
|
|
3811
|
+
top: 0;
|
|
3812
|
+
bottom: 0;
|
|
3813
|
+
right: 0;
|
|
3814
|
+
width: 1px;
|
|
3815
|
+
background: light-dark(var(--color-border, #e8eaed), var(--color-border, #2b2b2b));
|
|
3816
|
+
z-index: -3;
|
|
3817
|
+
pointer-events: none;
|
|
3818
|
+
/* Eases the accent into the divider when its column is being resized. */
|
|
3819
|
+
transition:
|
|
3820
|
+
background-color 180ms ease,
|
|
3821
|
+
width 180ms ease;
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
.dense td {
|
|
3825
|
+
padding: 0.375rem 0.75rem;
|
|
3826
|
+
font-size: 0.8125rem;
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
.comfortable td {
|
|
3830
|
+
padding: 1rem 1.25rem;
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
/* Clickable rows behave like buttons: pointer, press, and a row-wide ripple.
|
|
3834
|
+
Because the row is a grid (not a table-row) box it can be overflow:hidden,
|
|
3835
|
+
so the ripple attachment fills and clips to the row. The row's stacking
|
|
3836
|
+
context (set on `tbody tr` above) keeps the ripple (z-index -1) above the
|
|
3837
|
+
tint and dividers but below the cell content. */
|
|
3838
|
+
.row.clickable {
|
|
3839
|
+
cursor: pointer;
|
|
3840
|
+
user-select: none;
|
|
3841
|
+
overflow: hidden;
|
|
3842
|
+
/* The press eases on `translate`/`scale` ONLY — never `transform`. The reorder
|
|
3843
|
+
gap-shift drives each row's `transform` (style:transform), and finishSettle
|
|
3844
|
+
clears those shifts in the same tick the `.reordering` class drops; if
|
|
3845
|
+
`transform` were transitioned here, that clear would animate and the settled
|
|
3846
|
+
rows would jank. A pure-Z perspective press is exactly a centred scale +
|
|
3847
|
+
nudge, so this reads identically to perspective(100px) translateZ(). */
|
|
3848
|
+
transition:
|
|
3849
|
+
translate 200ms ease,
|
|
3850
|
+
scale 200ms ease;
|
|
3851
|
+
|
|
3852
|
+
&:active {
|
|
3853
|
+
translate: 0 1px;
|
|
3854
|
+
scale: 0.909;
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
/* ---- Row background states (consumed by `tr.row::before`) ----
|
|
3859
|
+
Resting states are written with :where() so they carry zero specificity and
|
|
3860
|
+
resolve by source order (default → stripe → selected → preview); the
|
|
3861
|
+
higher-specificity :hover rules below still win over all of them. */
|
|
3862
|
+
/* Striping is driven by an explicit parity class keyed on the row's visual
|
|
3863
|
+
index (not :nth-child) so it stays stable while virtual scrolling swaps the
|
|
3864
|
+
mounted rows, and doesn't flip when an expanded detail row is inserted. */
|
|
3865
|
+
:where(tbody tr.row.stripe) {
|
|
3866
|
+
--row-bg: light-dark(
|
|
3867
|
+
rgb(from var(--color-text, #000) r g b / 0.03),
|
|
3868
|
+
rgb(from var(--color-text, #fff) r g b / 0.035)
|
|
3869
|
+
);
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
:where(tbody tr.row.selected) {
|
|
3873
|
+
--row-bg: light-dark(
|
|
3874
|
+
rgb(from var(--color-action, #1976d2) r g b / 0.1),
|
|
3875
|
+
rgb(from var(--color-action, #5c9ce6) r g b / 0.16)
|
|
3876
|
+
);
|
|
3877
|
+
}
|
|
3878
|
+
|
|
3879
|
+
/* Hover: a touch stronger than the stripe tint so it still reads clearly when
|
|
3880
|
+
hovering a striped row. Snapped in (see the ::before rule), eased out.
|
|
3881
|
+
Suppressed in `editable` tables, which tint the hovered CELL instead (the
|
|
3882
|
+
`:where()` keeps specificity identical so the resting tints still resolve). */
|
|
3883
|
+
:where(table:not(.editable)) tbody tr.row:hover {
|
|
3884
|
+
--row-bg: light-dark(
|
|
3885
|
+
rgb(from var(--color-text, #000) r g b / 0.06),
|
|
3886
|
+
rgb(from var(--color-text, #fff) r g b / 0.08)
|
|
3887
|
+
);
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
:where(table:not(.editable)) tbody tr.row.selected:hover {
|
|
3891
|
+
--row-bg: light-dark(
|
|
3892
|
+
rgb(from var(--color-action, #1976d2) r g b / 0.17),
|
|
3893
|
+
rgb(from var(--color-action, #5c9ce6) r g b / 0.24)
|
|
3894
|
+
);
|
|
3895
|
+
}
|
|
3896
|
+
|
|
3897
|
+
/* Shift-range preview wins over hover (placed last, equal specificity) */
|
|
3898
|
+
tbody tr.row.preview {
|
|
3899
|
+
--row-bg: light-dark(
|
|
3900
|
+
rgb(from var(--color-action, #1976d2) r g b / 0.14),
|
|
3901
|
+
rgb(from var(--color-action, #5c9ce6) r g b / 0.2)
|
|
3902
|
+
);
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
/* Snap the tint in for hover/preview; the ::before eases it out otherwise. */
|
|
3906
|
+
tbody tr.row:hover::before,
|
|
3907
|
+
tbody tr.row.selected:hover::before,
|
|
3908
|
+
tbody tr.row.preview::before {
|
|
3909
|
+
transition: none;
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
/* ========== Inline editing ========== */
|
|
3913
|
+
/* Editable rows don't clip — so the active-cell ring, the validation tooltip,
|
|
3914
|
+
and the autocomplete popover aren't cut off by the row's overflow. */
|
|
3915
|
+
.editable tbody tr.row {
|
|
3916
|
+
overflow: visible;
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3919
|
+
/* Editable mode replaces the row-level press feedback with per-cell editing, so
|
|
3920
|
+
drop the clickable press scale (the ripple is disabled in markup). Row-level
|
|
3921
|
+
selection/expand still work via the checkbox / grip controls. */
|
|
3922
|
+
.editable tbody tr.row.clickable {
|
|
3923
|
+
cursor: default;
|
|
3924
|
+
}
|
|
3925
|
+
.editable tbody tr.row.clickable:active {
|
|
3926
|
+
translate: none;
|
|
3927
|
+
scale: 1;
|
|
3928
|
+
}
|
|
3929
|
+
|
|
3930
|
+
/* Per-cell hover affordance (replaces the row hover in editable tables). The
|
|
3931
|
+
`td` base rule already eases `background-color` out; `:hover` drops it from
|
|
3932
|
+
the transition so the tint snaps in (see packages/components/CLAUDE.md). */
|
|
3933
|
+
.editable td.editable-cell {
|
|
3934
|
+
cursor: cell;
|
|
3935
|
+
}
|
|
3936
|
+
.editable td.editable-cell:hover {
|
|
3937
|
+
background-color: light-dark(
|
|
3938
|
+
rgb(from var(--color-text, #000) r g b / 0.05),
|
|
3939
|
+
rgb(from var(--color-text, #fff) r g b / 0.07)
|
|
3940
|
+
);
|
|
3941
|
+
transition: none;
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
/* The active cell: a crisp focus ring and an opaque background so the editor
|
|
3945
|
+
reads cleanly over any stripe/selected tint. Kept below the sticky header
|
|
3946
|
+
(z-index 2) and the resize wash (z-index 4). */
|
|
3947
|
+
.editable td.cell-active {
|
|
3948
|
+
z-index: 1;
|
|
3949
|
+
background-color: light-dark(var(--color-bg, #fff), var(--color-bg, #1a1a1a));
|
|
3950
|
+
box-shadow: inset 0 0 0 2px var(--color-action, #1976d2);
|
|
3951
|
+
/* The ring snaps in; the cell hover/wash still eases via the base td rule. */
|
|
3952
|
+
transition: none;
|
|
3953
|
+
}
|
|
3954
|
+
/* A failed async save (or blocked validation) outlines the cell in the error
|
|
3955
|
+
colour — wins over the active ring. */
|
|
3956
|
+
.editable td.cell-error,
|
|
3957
|
+
.editable td.cell-active.cell-error {
|
|
3958
|
+
box-shadow: inset 0 0 0 2px var(--color-error, #dc2626);
|
|
3959
|
+
}
|
|
3960
|
+
|
|
3961
|
+
/* Save status badges on the resting cell (survive the editor unmounting). */
|
|
3962
|
+
.cell-status {
|
|
3963
|
+
display: inline-flex;
|
|
3964
|
+
align-items: center;
|
|
3965
|
+
justify-content: center;
|
|
3966
|
+
margin-left: auto;
|
|
3967
|
+
padding-left: 0.1rem;
|
|
3968
|
+
flex-shrink: 0;
|
|
3969
|
+
width: 1.5em;
|
|
3970
|
+
height: 1.5em;
|
|
3971
|
+
}
|
|
3972
|
+
.cell-status.pending {
|
|
3973
|
+
color: var(--color-action, #1976d2);
|
|
3974
|
+
}
|
|
3975
|
+
.cell-status.saved {
|
|
3976
|
+
color: var(--color-success, #16a34a);
|
|
3977
|
+
animation: cell-saved-pop 240ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
3978
|
+
}
|
|
3979
|
+
.cell-status.saved svg {
|
|
3980
|
+
width: 100%;
|
|
3981
|
+
height: 100%;
|
|
3982
|
+
stroke-width: 2.75;
|
|
3983
|
+
}
|
|
3984
|
+
@keyframes cell-saved-pop {
|
|
3985
|
+
from {
|
|
3986
|
+
transform: scale(0.3);
|
|
3987
|
+
opacity: 0;
|
|
3988
|
+
}
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
/* Boolean cell button — fills the whole cell (it's `position: relative`), so a
|
|
3992
|
+
single click anywhere toggles. Always present (never swapped for an editor),
|
|
3993
|
+
which is what makes the first click register. */
|
|
3994
|
+
.cell-checkbox {
|
|
3995
|
+
position: absolute;
|
|
3996
|
+
inset: 0;
|
|
3997
|
+
display: flex;
|
|
3998
|
+
align-items: center;
|
|
3999
|
+
justify-content: center;
|
|
4000
|
+
border: none;
|
|
4001
|
+
background: transparent;
|
|
4002
|
+
cursor: pointer;
|
|
4003
|
+
-webkit-tap-highlight-color: transparent;
|
|
4004
|
+
perspective: 120px;
|
|
4005
|
+
}
|
|
4006
|
+
.cell-checkbox:focus-visible {
|
|
4007
|
+
outline: none;
|
|
4008
|
+
}
|
|
4009
|
+
/* Press feedback matching the Checkbox component: the indicator presses down and
|
|
4010
|
+
scales in snappily (80ms), then eases back. A click anywhere in the cell
|
|
4011
|
+
triggers it since the button fills the cell. */
|
|
4012
|
+
.cell-checkbox .cell-bool {
|
|
4013
|
+
transition: transform 150ms ease;
|
|
4014
|
+
}
|
|
4015
|
+
.cell-checkbox:active .cell-bool {
|
|
4016
|
+
transform: translateY(3px) scale(0.9);
|
|
4017
|
+
transition: transform 80ms ease;
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
/* Boolean indicator — matches the Checkbox component (and the editor's in-cell
|
|
4021
|
+
toggle): accent-filled box with a drawn checkmark. */
|
|
4022
|
+
.cell-bool {
|
|
4023
|
+
display: inline-flex;
|
|
4024
|
+
flex-shrink: 0;
|
|
4025
|
+
line-height: 0;
|
|
4026
|
+
}
|
|
4027
|
+
.cell-bool .box {
|
|
4028
|
+
stroke: light-dark(
|
|
4029
|
+
var(--color-text-disabled, #999),
|
|
4030
|
+
var(--color-text-disabled, #777)
|
|
4031
|
+
);
|
|
4032
|
+
fill: transparent;
|
|
4033
|
+
transition:
|
|
4034
|
+
stroke 150ms ease,
|
|
4035
|
+
fill 150ms ease;
|
|
4036
|
+
}
|
|
4037
|
+
.cell-bool .check {
|
|
4038
|
+
stroke: transparent;
|
|
4039
|
+
fill: none;
|
|
4040
|
+
stroke-dasharray: 28;
|
|
4041
|
+
stroke-dashoffset: 28;
|
|
4042
|
+
transition:
|
|
4043
|
+
stroke-dashoffset 250ms ease,
|
|
4044
|
+
stroke 150ms ease;
|
|
4045
|
+
}
|
|
4046
|
+
.cell-bool.checked .box {
|
|
4047
|
+
stroke: var(--color-action, #1976d2);
|
|
4048
|
+
fill: var(--color-action, #1976d2);
|
|
4049
|
+
}
|
|
4050
|
+
.cell-bool.checked .check {
|
|
4051
|
+
stroke: var(--color-action-text, #fff);
|
|
4052
|
+
stroke-dashoffset: 0;
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
/* Reorder grip column (only when reorderable + editable). */
|
|
4056
|
+
.grip-cell {
|
|
4057
|
+
justify-content: center;
|
|
4058
|
+
align-items: center;
|
|
4059
|
+
padding-left: 0.25rem !important;
|
|
4060
|
+
padding-right: 0.25rem !important;
|
|
4061
|
+
cursor: grab;
|
|
4062
|
+
touch-action: none;
|
|
4063
|
+
color: light-dark(var(--color-text-muted, #9ca3af), var(--color-text-muted, #6b7280));
|
|
4064
|
+
transition: color 200ms ease;
|
|
4065
|
+
}
|
|
4066
|
+
.grip-cell:hover {
|
|
4067
|
+
color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
|
|
4068
|
+
transition: none;
|
|
4069
|
+
}
|
|
4070
|
+
.grip-cell:active {
|
|
4071
|
+
cursor: grabbing;
|
|
4072
|
+
}
|
|
4073
|
+
.grip-dots {
|
|
4074
|
+
fill: currentColor;
|
|
4075
|
+
display: block;
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4078
|
+
@media (prefers-reduced-motion: reduce) {
|
|
4079
|
+
.cell-status.saved {
|
|
4080
|
+
animation: none;
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
/* ========== Checkbox (mirrors the Checkbox component) ========== */
|
|
4085
|
+
.checkbox-cell {
|
|
4086
|
+
justify-content: center;
|
|
4087
|
+
text-align: center;
|
|
4088
|
+
padding-left: 0.5rem !important;
|
|
4089
|
+
padding-right: 0.25rem !important;
|
|
4090
|
+
}
|
|
4091
|
+
|
|
4092
|
+
/* The header `th` uses `align-items: stretch` (so sortable column buttons fill
|
|
4093
|
+
the cell height). For the select-all checkbox cell that would top-stretch the
|
|
4094
|
+
round check-wrap; center it like the body checkbox cells instead. */
|
|
4095
|
+
th.checkbox-cell {
|
|
4096
|
+
align-items: center;
|
|
4097
|
+
}
|
|
4098
|
+
|
|
4099
|
+
.check-wrap {
|
|
4100
|
+
position: relative;
|
|
4101
|
+
display: inline-flex;
|
|
4102
|
+
align-items: center;
|
|
4103
|
+
justify-content: center;
|
|
4104
|
+
width: 38px;
|
|
4105
|
+
height: 38px;
|
|
4106
|
+
border-radius: 50%;
|
|
4107
|
+
cursor: pointer;
|
|
4108
|
+
flex-shrink: 0;
|
|
4109
|
+
overflow: hidden;
|
|
4110
|
+
outline: none;
|
|
4111
|
+
vertical-align: middle;
|
|
4112
|
+
-webkit-tap-highlight-color: transparent;
|
|
4113
|
+
--hover-tint: color-mix(in srgb, var(--color-text, currentColor) 12%, transparent);
|
|
4114
|
+
transition:
|
|
4115
|
+
background 200ms ease,
|
|
4116
|
+
transform 150ms ease;
|
|
4117
|
+
|
|
4118
|
+
&.checked,
|
|
4119
|
+
&.preview {
|
|
4120
|
+
--hover-tint: color-mix(in srgb, var(--color-action, #1976d2) 18%, transparent);
|
|
4121
|
+
}
|
|
4122
|
+
|
|
4123
|
+
&:hover {
|
|
4124
|
+
background: var(--hover-tint);
|
|
4125
|
+
transition: none;
|
|
4126
|
+
}
|
|
4127
|
+
|
|
4128
|
+
&:active {
|
|
4129
|
+
transform: scale(0.9);
|
|
4130
|
+
transition:
|
|
4131
|
+
transform 80ms ease,
|
|
4132
|
+
background 200ms ease;
|
|
4133
|
+
}
|
|
4134
|
+
|
|
4135
|
+
&:focus-visible {
|
|
4136
|
+
box-shadow:
|
|
4137
|
+
0 0 0 2px light-dark(var(--color-bg, #fff), var(--color-bg, #1a1a1a)),
|
|
4138
|
+
0 0 0 4px var(--color-action, #1976d2);
|
|
4139
|
+
}
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
.check-icon {
|
|
4143
|
+
flex-shrink: 0;
|
|
4144
|
+
|
|
4145
|
+
/* Unchecked outline: a clearly-visible, slightly thicker stroke so the
|
|
4146
|
+
empty box reads well against the row background. */
|
|
4147
|
+
.box {
|
|
4148
|
+
stroke: light-dark(
|
|
4149
|
+
rgb(from var(--color-text, #000) r g b / 0.5),
|
|
4150
|
+
rgb(from var(--color-text, #fff) r g b / 0.55)
|
|
4151
|
+
);
|
|
4152
|
+
stroke-width: 2.4;
|
|
4153
|
+
fill: transparent;
|
|
4154
|
+
transition:
|
|
4155
|
+
stroke 150ms ease,
|
|
4156
|
+
fill 150ms ease;
|
|
4157
|
+
}
|
|
4158
|
+
|
|
4159
|
+
.check {
|
|
4160
|
+
stroke: var(--color-action-text, #fff);
|
|
4161
|
+
fill: none;
|
|
4162
|
+
stroke-dasharray: 24;
|
|
4163
|
+
stroke-dashoffset: 24;
|
|
4164
|
+
transition: stroke-dashoffset 260ms
|
|
4165
|
+
var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
|
|
4166
|
+
}
|
|
4167
|
+
|
|
4168
|
+
.dash {
|
|
4169
|
+
stroke: var(--color-action-text, #fff);
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
&.checked {
|
|
4173
|
+
.box {
|
|
4174
|
+
stroke: var(--color-action, #1976d2);
|
|
4175
|
+
fill: var(--color-action, #1976d2);
|
|
4176
|
+
}
|
|
4177
|
+
.check {
|
|
4178
|
+
stroke-dashoffset: 0;
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
/* Preview (shift-hover): tinted box, half-drawn check */
|
|
4183
|
+
&.preview {
|
|
4184
|
+
.box {
|
|
4185
|
+
stroke: var(--color-action, #1976d2);
|
|
4186
|
+
fill: rgb(from var(--color-action, #1976d2) r g b / 0.35);
|
|
4187
|
+
}
|
|
4188
|
+
.check {
|
|
4189
|
+
stroke: light-dark(var(--color-action, #1976d2), var(--color-action-text, #fff));
|
|
4190
|
+
stroke-dashoffset: 0;
|
|
4191
|
+
opacity: 0.55;
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
}
|
|
4195
|
+
|
|
4196
|
+
/* ========== Expand Cell ========== */
|
|
4197
|
+
.expand-cell {
|
|
4198
|
+
justify-content: center;
|
|
4199
|
+
text-align: center;
|
|
4200
|
+
padding-left: 0.5rem !important;
|
|
4201
|
+
padding-right: 0.25rem !important;
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
.expand-btn {
|
|
4205
|
+
display: inline-flex;
|
|
4206
|
+
align-items: center;
|
|
4207
|
+
justify-content: center;
|
|
4208
|
+
width: 28px;
|
|
4209
|
+
height: 28px;
|
|
4210
|
+
padding: 0;
|
|
4211
|
+
margin: 0;
|
|
4212
|
+
border: none;
|
|
4213
|
+
border-radius: 50%;
|
|
4214
|
+
background: none;
|
|
4215
|
+
color: light-dark(var(--color-text-muted, #6b7280), var(--color-text-muted, #9ca3af));
|
|
4216
|
+
cursor: pointer;
|
|
4217
|
+
transition: background 160ms ease;
|
|
4218
|
+
|
|
4219
|
+
&:hover {
|
|
4220
|
+
background: light-dark(
|
|
4221
|
+
rgb(from var(--color-text, #000) r g b / 0.07),
|
|
4222
|
+
rgb(from var(--color-text, #fff) r g b / 0.1)
|
|
4223
|
+
);
|
|
4224
|
+
/* Snap the tint in on hover; the base rule eases it back out on leave. */
|
|
4225
|
+
transition: none;
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
&:active {
|
|
4229
|
+
transform: scale(0.9);
|
|
4230
|
+
}
|
|
4231
|
+
|
|
4232
|
+
&:focus-visible {
|
|
4233
|
+
outline: 2px solid var(--color-action, #1976d2);
|
|
4234
|
+
outline-offset: 1px;
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
|
|
4238
|
+
.expand-chevron {
|
|
4239
|
+
transition: transform 240ms var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1));
|
|
4240
|
+
}
|
|
4241
|
+
|
|
4242
|
+
.expand-chevron.expanded {
|
|
4243
|
+
transform: rotate(90deg);
|
|
4244
|
+
}
|
|
4245
|
+
|
|
4246
|
+
/* ========== Expanded Row ========== */
|
|
4247
|
+
.expanded-row {
|
|
4248
|
+
background: light-dark(
|
|
4249
|
+
rgb(from var(--color-action, #1976d2) r g b / 0.04),
|
|
4250
|
+
rgb(from var(--color-action, #5c9ce6) r g b / 0.07)
|
|
4251
|
+
);
|
|
4252
|
+
}
|
|
4253
|
+
|
|
4254
|
+
.expanded-row td {
|
|
4255
|
+
display: block;
|
|
4256
|
+
grid-column: 1 / -1;
|
|
4257
|
+
padding: 0;
|
|
4258
|
+
white-space: normal;
|
|
4259
|
+
overflow: visible;
|
|
4260
|
+
text-overflow: clip;
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
.expanded-content {
|
|
4264
|
+
padding: 1rem 1.25rem;
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
.dense .expanded-content {
|
|
4268
|
+
padding: 0.5rem 0.75rem;
|
|
4269
|
+
}
|
|
4270
|
+
|
|
4271
|
+
.comfortable .expanded-content {
|
|
4272
|
+
padding: 1.25rem 1.5rem;
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
/* ========== Group Row ========== */
|
|
4276
|
+
.group-row {
|
|
4277
|
+
background: light-dark(
|
|
4278
|
+
rgb(from var(--color-text, #000) r g b / 0.03),
|
|
4279
|
+
rgb(from var(--color-text, #fff) r g b / 0.05)
|
|
4280
|
+
);
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
.group-row td {
|
|
4284
|
+
display: block;
|
|
4285
|
+
grid-column: 1 / -1;
|
|
4286
|
+
padding: 0;
|
|
4287
|
+
}
|
|
4288
|
+
|
|
4289
|
+
.group-toggle {
|
|
4290
|
+
display: flex;
|
|
4291
|
+
align-items: center;
|
|
4292
|
+
gap: 0.5rem;
|
|
4293
|
+
width: 100%;
|
|
4294
|
+
padding: 0.5rem 1rem;
|
|
4295
|
+
border: none;
|
|
4296
|
+
background: none;
|
|
4297
|
+
color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
|
|
4298
|
+
font: inherit;
|
|
4299
|
+
font-weight: 600;
|
|
4300
|
+
font-size: 0.8125rem;
|
|
4301
|
+
cursor: pointer;
|
|
4302
|
+
text-transform: uppercase;
|
|
4303
|
+
letter-spacing: 0.02em;
|
|
4304
|
+
|
|
4305
|
+
&:hover {
|
|
4306
|
+
background: light-dark(
|
|
4307
|
+
rgb(from var(--color-text, #000) r g b / 0.04),
|
|
4308
|
+
rgb(from var(--color-text, #fff) r g b / 0.06)
|
|
4309
|
+
);
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
&:focus-visible {
|
|
4313
|
+
outline: 2px solid var(--color-action, #1976d2);
|
|
4314
|
+
outline-offset: -2px;
|
|
4315
|
+
border-radius: 2px;
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
.group-chevron {
|
|
4320
|
+
transition: transform 200ms ease;
|
|
4321
|
+
flex-shrink: 0;
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
.group-chevron:not(.group-collapsed) {
|
|
4325
|
+
transform: rotate(90deg);
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
.group-count {
|
|
4329
|
+
color: light-dark(var(--color-text-muted, #6b7280), var(--color-text-muted, #9ca3af));
|
|
4330
|
+
font-weight: 400;
|
|
4331
|
+
font-size: 0.75rem;
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
/* ========== Empty State ========== */
|
|
4335
|
+
.empty-row td {
|
|
4336
|
+
display: block;
|
|
4337
|
+
grid-column: 1 / -1;
|
|
4338
|
+
padding: 0;
|
|
4339
|
+
white-space: normal;
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
.empty {
|
|
4343
|
+
display: flex;
|
|
4344
|
+
flex-direction: column;
|
|
4345
|
+
align-items: center;
|
|
4346
|
+
justify-content: center;
|
|
4347
|
+
padding: 3rem 1rem;
|
|
4348
|
+
color: light-dark(var(--color-text-muted, #6b7280), var(--color-text-muted, #9ca3af));
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
.empty p {
|
|
4352
|
+
margin: 0.75rem 0 0;
|
|
4353
|
+
font-size: 0.875rem;
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
/* ========== Skeleton ========== */
|
|
4357
|
+
.skeleton-row {
|
|
4358
|
+
pointer-events: none;
|
|
4359
|
+
border-bottom: 1px solid
|
|
4360
|
+
light-dark(var(--color-border, #e5e7eb), var(--color-border, #2e2e2e));
|
|
4361
|
+
|
|
4362
|
+
/* Real rows drop the last border (see tr.row:last-child) — mirror it so
|
|
4363
|
+
the skeleton table is exactly as tall as the loaded one. */
|
|
4364
|
+
&:last-child {
|
|
4365
|
+
border-bottom: none;
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
|
|
4369
|
+
/* Text-line bar inside a real `td` (which supplies the real cell padding,
|
|
4370
|
+
incl. dense/comfortable): the bar's margins pad it out to one full text
|
|
4371
|
+
line (1lh), so skeleton rows are exactly as tall as loaded rows. */
|
|
4372
|
+
.skeleton-bar {
|
|
4373
|
+
height: 0.7em;
|
|
4374
|
+
margin-block: calc((1lh - 0.7em) / 2);
|
|
4375
|
+
border-radius: var(--radius-full, 1e5px);
|
|
4376
|
+
background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
|
|
4377
|
+
position: relative;
|
|
4378
|
+
overflow: hidden;
|
|
4379
|
+
|
|
4380
|
+
&::after {
|
|
4381
|
+
content: '';
|
|
4382
|
+
position: absolute;
|
|
4383
|
+
inset: 0;
|
|
4384
|
+
transform: translateX(-100%);
|
|
4385
|
+
background-image: linear-gradient(
|
|
4386
|
+
105deg,
|
|
4387
|
+
transparent 25%,
|
|
4388
|
+
var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
|
|
4389
|
+
transparent 75%
|
|
4390
|
+
);
|
|
4391
|
+
animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
|
|
4392
|
+
infinite;
|
|
4393
|
+
animation-delay: var(--shimmer-delay, 0s);
|
|
4394
|
+
}
|
|
4395
|
+
|
|
4396
|
+
/* Checkbox/expand glyph placeholders size themselves inline (18px squares /
|
|
4397
|
+
discs) — no text-line margins. */
|
|
4398
|
+
&.glyph {
|
|
4399
|
+
margin-block: 0;
|
|
4400
|
+
}
|
|
4401
|
+
}
|
|
4402
|
+
|
|
4403
|
+
@keyframes -global-delight-skeleton-shimmer {
|
|
4404
|
+
0% {
|
|
4405
|
+
transform: translateX(-100%);
|
|
4406
|
+
}
|
|
4407
|
+
55%,
|
|
4408
|
+
100% {
|
|
4409
|
+
transform: translateX(100%);
|
|
4410
|
+
}
|
|
4411
|
+
}
|
|
4412
|
+
|
|
4413
|
+
/* ========== Reorder (drag-to-reorder) ========== */
|
|
4414
|
+
/* A draggable row hints with a grab cursor. We deliberately leave
|
|
4415
|
+
`touch-action` at its default (`auto`) instead of an explicit `pan-y`: a
|
|
4416
|
+
declared pan axis lets the browser drive that scroll on the compositor and
|
|
4417
|
+
makes the `touchmove` non-cancelable, so an armed drag could never stop the
|
|
4418
|
+
page from scrolling. With `auto`, the non-passive `touchmove` guard (see
|
|
4419
|
+
`onDragTouchMove`) can preventDefault once a long-press arms the drag, while
|
|
4420
|
+
an un-armed drag still scrolls the list normally. */
|
|
4421
|
+
.row.reorderable {
|
|
4422
|
+
cursor: grab;
|
|
4423
|
+
/* Suppress the mobile long-press text-selection callout: a hold on a cell's
|
|
4424
|
+
text would otherwise pop the OS selection menu instead of arming the drag.
|
|
4425
|
+
This has to live on the row (not just `.wrapper.reordering`) because the
|
|
4426
|
+
callout fires during the hold, before the drag starts. `touch-callout`
|
|
4427
|
+
covers iOS; Android's `contextmenu` event is handled in JS (see
|
|
4428
|
+
`onDragContextMenu`). A whole-row drag already precludes drag-to-select on
|
|
4429
|
+
desktop, so nothing usable is lost there. */
|
|
4430
|
+
user-select: none;
|
|
4431
|
+
-webkit-user-select: none;
|
|
4432
|
+
-webkit-touch-callout: none;
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
/* While a drag is in flight: kill text selection and show the grabbing cursor
|
|
4436
|
+
everywhere over the table. */
|
|
4437
|
+
.wrapper.reordering {
|
|
4438
|
+
user-select: none;
|
|
4439
|
+
cursor: grabbing;
|
|
4440
|
+
}
|
|
4441
|
+
|
|
4442
|
+
/* While resizing: the col-resize cursor stays put across the whole table even
|
|
4443
|
+
as the pointer drifts off the thin handle, and nothing selects underneath. */
|
|
4444
|
+
.wrapper.resizing-active {
|
|
4445
|
+
cursor: col-resize;
|
|
4446
|
+
user-select: none;
|
|
4447
|
+
-webkit-user-select: none;
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
/* Disable pointer events on the body rows during a drag so the rows beneath
|
|
4451
|
+
the floating overlay don't light up with :hover (the overlay is
|
|
4452
|
+
pointer-events:none and would otherwise let hover bleed through). The drag
|
|
4453
|
+
itself is driven by document-level listeners, so rows don't need events. */
|
|
4454
|
+
.wrapper.reordering tbody tr {
|
|
4455
|
+
pointer-events: none;
|
|
4456
|
+
}
|
|
4457
|
+
|
|
4458
|
+
.wrapper.reordering .row {
|
|
4459
|
+
transition: transform 200ms cubic-bezier(0.2, 0.85, 0.3, 1);
|
|
4460
|
+
will-change: transform;
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4463
|
+
/* The press would fight the drag transform — suppress it mid-reorder. */
|
|
4464
|
+
.wrapper.reordering .row.clickable:active {
|
|
4465
|
+
translate: none;
|
|
4466
|
+
scale: none;
|
|
4467
|
+
}
|
|
4468
|
+
|
|
4469
|
+
/* The lifted originals stay in flow (so scroll height is stable) but are
|
|
4470
|
+
hidden — the floating overlay shows the moving copy. */
|
|
4471
|
+
.row.drag-source {
|
|
4472
|
+
visibility: hidden;
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
.wrapper.reordering .row.drag-source {
|
|
4476
|
+
transition: none;
|
|
4477
|
+
}
|
|
4478
|
+
|
|
4479
|
+
/* Touch "ready to move" feedback: the held row lifts before it can be moved. */
|
|
4480
|
+
.row.drag-armed {
|
|
4481
|
+
z-index: 5;
|
|
4482
|
+
background: light-dark(var(--color-bg, #fff), var(--color-bg, #1a1a1a));
|
|
4483
|
+
animation: arm 180ms var(--ease-spring, cubic-bezier(0.34, 1.56, 0.64, 1)) forwards;
|
|
4484
|
+
}
|
|
4485
|
+
|
|
4486
|
+
@keyframes arm {
|
|
4487
|
+
from {
|
|
4488
|
+
transform: scale(1);
|
|
4489
|
+
box-shadow: 0 0 0 rgb(0 0 0 / 0);
|
|
4490
|
+
}
|
|
4491
|
+
to {
|
|
4492
|
+
transform: scale(1.015);
|
|
4493
|
+
box-shadow:
|
|
4494
|
+
0 10px 24px rgb(0 0 0 / 0.16),
|
|
4495
|
+
0 3px 8px rgb(0 0 0 / 0.12);
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
/* The floating overlay (fixed-position, follows the pointer). The drop-shadow
|
|
4500
|
+
gives the "popped above the rest" lift; position (translate) and lift (scale)
|
|
4501
|
+
are applied imperatively as separate properties so the pointer-follow stays
|
|
4502
|
+
instant while `scale` carries the lift-in and drop-settle transitions. */
|
|
4503
|
+
.drag-overlay {
|
|
4504
|
+
position: fixed;
|
|
4505
|
+
top: 0;
|
|
4506
|
+
left: 0;
|
|
4507
|
+
z-index: 1000;
|
|
4508
|
+
display: grid;
|
|
4509
|
+
pointer-events: none;
|
|
4510
|
+
filter: drop-shadow(0 18px 32px rgb(0 0 0 / 0.22))
|
|
4511
|
+
drop-shadow(0 6px 12px rgb(0 0 0 / 0.16));
|
|
4512
|
+
border-radius: var(--table-radius, 14px);
|
|
4513
|
+
@supports (corner-shape: squircle) {
|
|
4514
|
+
corner-shape: squircle;
|
|
4515
|
+
border-radius: calc(var(--table-radius, 14px) * var(--squircle-ratio, 2));
|
|
4516
|
+
}
|
|
4517
|
+
overflow: hidden;
|
|
4518
|
+
will-change: translate, scale;
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4521
|
+
.drag-overlay.settling {
|
|
4522
|
+
filter: drop-shadow(0 6px 14px rgb(0 0 0 / 0.12));
|
|
4523
|
+
transition: filter 300ms ease;
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
/* Many-row drag: the overlay collapses to a single card with a count badge and
|
|
4527
|
+
a couple of "sheets" peeking behind it (so it reads as a stack), instead of a
|
|
4528
|
+
tall block. The gap in the list still reserves every row. */
|
|
4529
|
+
.drag-overlay.collapsed {
|
|
4530
|
+
overflow: visible;
|
|
4531
|
+
}
|
|
4532
|
+
|
|
4533
|
+
.drag-overlay.collapsed .ghost-row {
|
|
4534
|
+
border-radius: var(--table-radius, 14px);
|
|
4535
|
+
@supports (corner-shape: squircle) {
|
|
4536
|
+
corner-shape: squircle;
|
|
4537
|
+
border-radius: calc(var(--table-radius, 14px) * var(--squircle-ratio, 2));
|
|
4538
|
+
}
|
|
4539
|
+
overflow: hidden;
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4542
|
+
.drag-overlay.collapsed::before,
|
|
4543
|
+
.drag-overlay.collapsed::after {
|
|
4544
|
+
content: '';
|
|
4545
|
+
position: absolute;
|
|
4546
|
+
left: 5px;
|
|
4547
|
+
right: 5px;
|
|
4548
|
+
top: 0;
|
|
4549
|
+
bottom: 0;
|
|
4550
|
+
z-index: -1;
|
|
4551
|
+
border-radius: var(--table-radius, 14px);
|
|
4552
|
+
@supports (corner-shape: squircle) {
|
|
4553
|
+
corner-shape: squircle;
|
|
4554
|
+
border-radius: calc(var(--table-radius, 14px) * var(--squircle-ratio, 2));
|
|
4555
|
+
}
|
|
4556
|
+
background: light-dark(var(--color-bg, #fff), var(--color-bg, #232323));
|
|
4557
|
+
border: 1px solid
|
|
4558
|
+
light-dark(var(--color-border, #e5e7eb), var(--color-border, #3a3a3a));
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
.drag-overlay.collapsed::before {
|
|
4562
|
+
transform: translateY(5px) scale(0.99);
|
|
4563
|
+
}
|
|
4564
|
+
|
|
4565
|
+
.drag-overlay.collapsed::after {
|
|
4566
|
+
left: 10px;
|
|
4567
|
+
right: 10px;
|
|
4568
|
+
transform: translateY(10px) scale(0.985);
|
|
4569
|
+
opacity: 0.85;
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4572
|
+
.drag-count {
|
|
4573
|
+
position: absolute;
|
|
4574
|
+
top: 50%;
|
|
4575
|
+
right: 12px;
|
|
4576
|
+
transform: translateY(-50%);
|
|
4577
|
+
min-width: 1.5rem;
|
|
4578
|
+
height: 1.5rem;
|
|
4579
|
+
padding: 0 0.45rem;
|
|
4580
|
+
display: flex;
|
|
4581
|
+
align-items: center;
|
|
4582
|
+
justify-content: center;
|
|
4583
|
+
border-radius: 999px;
|
|
4584
|
+
background: var(--color-action, #1976d2);
|
|
4585
|
+
color: var(--color-action-text, #fff);
|
|
4586
|
+
font-size: 0.75rem;
|
|
4587
|
+
font-weight: 700;
|
|
4588
|
+
font-variant-numeric: tabular-nums;
|
|
4589
|
+
box-shadow: 0 2px 6px rgb(0 0 0 / 0.25);
|
|
4590
|
+
}
|
|
4591
|
+
|
|
4592
|
+
.ghost-row {
|
|
4593
|
+
display: grid;
|
|
4594
|
+
grid-template-columns: subgrid;
|
|
4595
|
+
grid-column: 1 / -1;
|
|
4596
|
+
background: light-dark(var(--color-bg, #fff), var(--color-bg, #1a1a1a));
|
|
4597
|
+
color: light-dark(var(--color-text, #1a1a1a), var(--color-text, #f5f5f5));
|
|
4598
|
+
font-size: 0.875rem;
|
|
4599
|
+
}
|
|
4600
|
+
|
|
4601
|
+
.ghost-row:not(:last-child) {
|
|
4602
|
+
border-bottom: 1px solid
|
|
4603
|
+
light-dark(var(--color-border, #e5e7eb), var(--color-border, #2e2e2e));
|
|
4604
|
+
}
|
|
4605
|
+
|
|
4606
|
+
.ghost-cell {
|
|
4607
|
+
display: flex;
|
|
4608
|
+
align-items: center;
|
|
4609
|
+
padding: 0.75rem 1rem;
|
|
4610
|
+
min-width: 0;
|
|
4611
|
+
white-space: nowrap;
|
|
4612
|
+
overflow: hidden;
|
|
4613
|
+
text-overflow: ellipsis;
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
.drag-overlay.dense .ghost-cell {
|
|
4617
|
+
padding: 0.375rem 0.75rem;
|
|
4618
|
+
font-size: 0.8125rem;
|
|
4619
|
+
}
|
|
4620
|
+
|
|
4621
|
+
.drag-overlay.comfortable .ghost-cell {
|
|
4622
|
+
padding: 1rem 1.25rem;
|
|
4623
|
+
}
|
|
4624
|
+
|
|
4625
|
+
.ghost-cell.checkbox-cell,
|
|
4626
|
+
.ghost-cell.expand-cell {
|
|
4627
|
+
justify-content: center;
|
|
4628
|
+
padding-left: 0.5rem;
|
|
4629
|
+
padding-right: 0.25rem;
|
|
4630
|
+
}
|
|
4631
|
+
|
|
4632
|
+
@media (prefers-reduced-motion: reduce) {
|
|
4633
|
+
.skeleton-bar::after {
|
|
4634
|
+
animation: none;
|
|
4635
|
+
}
|
|
4636
|
+
.expand-chevron,
|
|
4637
|
+
.group-chevron,
|
|
4638
|
+
.arrow-rot,
|
|
4639
|
+
.arrow,
|
|
4640
|
+
.check-icon .check,
|
|
4641
|
+
.row.clickable,
|
|
4642
|
+
tbody tr.row,
|
|
4643
|
+
tbody tr.row::before,
|
|
4644
|
+
.th-button,
|
|
4645
|
+
.row.drag-armed,
|
|
4646
|
+
.wrapper.reordering .row,
|
|
4647
|
+
.drag-overlay,
|
|
4648
|
+
.drag-overlay.settling,
|
|
4649
|
+
.resize-line {
|
|
4650
|
+
animation: none !important;
|
|
4651
|
+
transition: none !important;
|
|
4652
|
+
}
|
|
4653
|
+
}
|
|
4654
|
+
</style>
|