bo-grid 0.1.0 → 0.7.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/README.md +154 -28
- package/dist/grid/Cell.svelte +173 -11
- package/dist/grid/Cell.svelte.d.ts +22 -1
- package/dist/grid/FilterMenu.svelte +263 -0
- package/dist/grid/FilterMenu.svelte.d.ts +15 -0
- package/dist/grid/Grid.svelte +978 -61
- package/dist/grid/Grid.svelte.d.ts +72 -1
- package/dist/grid/Pager.svelte +59 -0
- package/dist/grid/Pager.svelte.d.ts +9 -0
- package/dist/grid/RowMenu.svelte +66 -0
- package/dist/grid/RowMenu.svelte.d.ts +12 -0
- package/dist/grid/ToolPanel.svelte +117 -0
- package/dist/grid/ToolPanel.svelte.d.ts +15 -0
- package/dist/grid/column.d.ts +33 -5
- package/dist/grid/column.js +8 -6
- package/dist/grid/export.js +1 -1
- package/dist/grid/filtering.d.ts +41 -0
- package/dist/grid/filtering.js +107 -0
- package/dist/grid/grouping.d.ts +2 -0
- package/dist/grid/pin.d.ts +10 -6
- package/dist/grid/pin.js +38 -15
- package/dist/grid/sizing.d.ts +2 -2
- package/dist/grid/sizing.js +4 -3
- package/dist/grid/source.d.ts +3 -0
- package/dist/grid/source.js +5 -0
- package/dist/grid/source.svelte.d.ts +2 -1
- package/dist/grid/source.svelte.js +6 -5
- package/dist/grid/tree.d.ts +12 -0
- package/dist/grid/tree.js +21 -0
- package/dist/index.d.ts +1 -0
- package/package.json +3 -1
package/dist/grid/Grid.svelte
CHANGED
|
@@ -14,14 +14,25 @@
|
|
|
14
14
|
import { Selection } from './selection.svelte';
|
|
15
15
|
import { aggregate, type AggKind, type AggResult } from './aggregate';
|
|
16
16
|
import { buildFlatRows, activeGroupsAt, type VisualRow, type GroupNode } from './grouping';
|
|
17
|
+
import { buildTreeRows } from './tree';
|
|
17
18
|
import { moveIndex } from './reorder';
|
|
18
19
|
import { parseClipboard, isSingleCell } from './clipboard';
|
|
19
20
|
import { applyWidths, clampWidth, isResizable, type WidthMap } from './sizing';
|
|
21
|
+
import {
|
|
22
|
+
passesFilters,
|
|
23
|
+
isFilterActive,
|
|
24
|
+
defaultFilterKind,
|
|
25
|
+
distinctValues,
|
|
26
|
+
type ColumnFilter,
|
|
27
|
+
type FilterKind,
|
|
28
|
+
} from './filtering';
|
|
20
29
|
import type { RowSource } from './source';
|
|
21
30
|
import { RowSourceController } from './source.svelte';
|
|
22
31
|
import Cell from './Cell.svelte';
|
|
23
32
|
import GroupRow from './GroupRow.svelte';
|
|
24
33
|
import AggregationBar from './AggregationBar.svelte';
|
|
34
|
+
import Pager from './Pager.svelte';
|
|
35
|
+
import RowMenu from './RowMenu.svelte';
|
|
25
36
|
|
|
26
37
|
let {
|
|
27
38
|
rows,
|
|
@@ -39,15 +50,36 @@
|
|
|
39
50
|
rowSelection = false,
|
|
40
51
|
onRowSelectionChange,
|
|
41
52
|
hiddenColumns = [],
|
|
53
|
+
onColumnVisibilityChange,
|
|
54
|
+
columnMenu = false,
|
|
55
|
+
columnsPanel = false,
|
|
42
56
|
rowClass,
|
|
43
57
|
getRowId = (r: GridRow) => r.id,
|
|
44
58
|
onRowClick,
|
|
45
59
|
sort,
|
|
46
60
|
onSortChange,
|
|
61
|
+
columnFilters,
|
|
62
|
+
onFilterChange,
|
|
47
63
|
footer = false,
|
|
48
64
|
onCellClick,
|
|
49
65
|
pinnedRows = [],
|
|
50
66
|
filterRow = false,
|
|
67
|
+
filterMenu = false,
|
|
68
|
+
quickFilter = false,
|
|
69
|
+
fillHandle = false,
|
|
70
|
+
emptyMessage = 'No matching rows',
|
|
71
|
+
loading = false,
|
|
72
|
+
rowMenu,
|
|
73
|
+
detail,
|
|
74
|
+
detailHeight = 160,
|
|
75
|
+
getChildren,
|
|
76
|
+
onRowReorder,
|
|
77
|
+
pageSize = 0,
|
|
78
|
+
page,
|
|
79
|
+
onPageChange,
|
|
80
|
+
onColumnReorder,
|
|
81
|
+
onColumnResize,
|
|
82
|
+
ariaLabel,
|
|
51
83
|
cell,
|
|
52
84
|
}: {
|
|
53
85
|
rows: GridRow[];
|
|
@@ -67,8 +99,19 @@
|
|
|
67
99
|
/** Called with the selected row ids whenever the row-selection set changes. */
|
|
68
100
|
onRowSelectionChange?: (selectedIds: Array<string | number>) => void;
|
|
69
101
|
/** Column keys to hide (controlled). Build your own column-picker UI and
|
|
70
|
-
drive this prop — the grid stays presentation-only.
|
|
102
|
+
drive this prop — the grid stays presentation-only. Composed (union) with
|
|
103
|
+
columns the user hides at runtime via the column menu. */
|
|
71
104
|
hiddenColumns?: string[];
|
|
105
|
+
/** Called with all currently-hidden column keys whenever the runtime set
|
|
106
|
+
changes (column menu hide/show). */
|
|
107
|
+
onColumnVisibilityChange?: (hidden: string[]) => void;
|
|
108
|
+
/** Enable a per-column header menu (a ⋮ trigger) with sort, hide and (with
|
|
109
|
+
`filterMenu`) filter actions. Default false. */
|
|
110
|
+
columnMenu?: boolean;
|
|
111
|
+
/** Show a "Columns" button that opens a panel to toggle column visibility
|
|
112
|
+
(the place to restore columns hidden via the menu). Lazy-loaded. Default
|
|
113
|
+
false. */
|
|
114
|
+
columnsPanel?: boolean;
|
|
72
115
|
/** Return extra CSS class(es) for a data row (e.g. to colour by value).
|
|
73
116
|
Style them via `:global(.your-class)` since rows live inside the grid. */
|
|
74
117
|
rowClass?: (row: GridRow) => string | undefined;
|
|
@@ -84,6 +127,12 @@
|
|
|
84
127
|
sort?: SortState[];
|
|
85
128
|
/** Called with the new sort order whenever a header is clicked. */
|
|
86
129
|
onSortChange?: (sort: SortState[]) => void;
|
|
130
|
+
/** Controlled column filters (keyed by column key). When set, the grid
|
|
131
|
+
reflects these and reports changes via `onFilterChange` instead of holding
|
|
132
|
+
its own. Omit for uncontrolled filtering. */
|
|
133
|
+
columnFilters?: Record<string, ColumnFilter>;
|
|
134
|
+
/** Called with the full column-filter map whenever a header filter changes. */
|
|
135
|
+
onFilterChange?: (filters: Record<string, ColumnFilter>) => void;
|
|
87
136
|
/** Show a pinned totals row: each column with a `groupAgg` shows that
|
|
88
137
|
aggregate over all (filtered) rows. In-memory mode only. Default false. */
|
|
89
138
|
footer?: boolean;
|
|
@@ -99,6 +148,54 @@
|
|
|
99
148
|
/** Show a per-column filter input row under the header. Rows must match every
|
|
100
149
|
non-empty column filter (AND). In-memory mode only. Default false. */
|
|
101
150
|
filterRow?: boolean;
|
|
151
|
+
/** Enable a per-column header filter menu (lazy-loaded on first open). Each
|
|
152
|
+
filterable column shows a funnel; the menu's control matches the column
|
|
153
|
+
type (text/number/date). Override or disable per column with `col.filter`.
|
|
154
|
+
Works in source mode too (filters are delegated to the `RowSource`); set
|
|
155
|
+
filters need in-memory data. Default false. */
|
|
156
|
+
filterMenu?: boolean;
|
|
157
|
+
/** Show a built-in quick-filter search box above the grid that matches across
|
|
158
|
+
all column values (ANDed with the `filter` prop). In-memory mode only.
|
|
159
|
+
Default false. */
|
|
160
|
+
quickFilter?: boolean;
|
|
161
|
+
/** Show an Excel-style fill handle at the selection's bottom-right corner;
|
|
162
|
+
drag it to copy the selected value(s) across the extended range (editable
|
|
163
|
+
columns only). In-memory mode only. Default false. */
|
|
164
|
+
fillHandle?: boolean;
|
|
165
|
+
/** Message shown when there are no rows. Default 'No matching rows'. */
|
|
166
|
+
emptyMessage?: string;
|
|
167
|
+
/** Show a loading overlay over the grid (for consumer-driven async work in
|
|
168
|
+
in-memory mode; source mode shows skeleton rows automatically). */
|
|
169
|
+
loading?: boolean;
|
|
170
|
+
/** Right-click row menu. Return the items for a row; an empty array shows no
|
|
171
|
+
menu. Each item runs `onSelect` and closes the menu. */
|
|
172
|
+
rowMenu?: (row: GridRow) => Array<{ label: string; onSelect: () => void }>;
|
|
173
|
+
/** Master-detail: render an expandable detail panel under a row. Adds a
|
|
174
|
+
leading expand-toggle column. In-memory mode only (overrides rowHeight). */
|
|
175
|
+
detail?: Snippet<[{ row: GridRow }]>;
|
|
176
|
+
/** Height (px) of the expanded detail panel. Default 160. */
|
|
177
|
+
detailHeight?: number;
|
|
178
|
+
/** Tree data: return a row's children (undefined/empty = leaf). When set,
|
|
179
|
+
`rows` are the roots; the grid renders an indented, expandable tree.
|
|
180
|
+
In-memory mode; filter/sort/group/paginate are not applied to the tree. */
|
|
181
|
+
getChildren?: (row: GridRow) => GridRow[] | undefined;
|
|
182
|
+
/** Enable drag-to-reorder rows via a handle in the first column. Called with
|
|
183
|
+
the from/to indices (into the visible rows) on drop — reorder your own
|
|
184
|
+
`rows` in here. Flat, unsorted, in-memory lists only. */
|
|
185
|
+
onRowReorder?: (fromIndex: number, toIndex: number) => void;
|
|
186
|
+
/** Rows per page. When > 0 (in-memory mode), shows a pager instead of one
|
|
187
|
+
long scroll; rows still virtualize within a page. Default 0 (off). */
|
|
188
|
+
pageSize?: number;
|
|
189
|
+
/** Controlled current page (0-based). Omit for uncontrolled paging. */
|
|
190
|
+
page?: number;
|
|
191
|
+
/** Called with the new page index when the pager is used. */
|
|
192
|
+
onPageChange?: (page: number) => void;
|
|
193
|
+
/** Called with the new column-key order after a header drag-reorder. */
|
|
194
|
+
onColumnReorder?: (keys: string[]) => void;
|
|
195
|
+
/** Called with a column key + new width after a drag-resize. */
|
|
196
|
+
onColumnResize?: (key: string, width: number) => void;
|
|
197
|
+
/** Accessible name for the grid (`aria-label` on the `role="grid"` root). */
|
|
198
|
+
ariaLabel?: string;
|
|
102
199
|
filter?: string;
|
|
103
200
|
groupBy?: string[];
|
|
104
201
|
aggregations?: AggKind[];
|
|
@@ -134,33 +231,92 @@
|
|
|
134
231
|
|
|
135
232
|
const sel = new Selection();
|
|
136
233
|
let dragging = $state(false);
|
|
234
|
+
// Fill handle: drag the selection's corner to copy its value(s) across.
|
|
235
|
+
let filling = $state(false);
|
|
236
|
+
let fillTo = $state<{ r: number; c: number } | null>(null);
|
|
237
|
+
let fillSource: { r0: number; c0: number; r1: number; c1: number } | null = null;
|
|
137
238
|
let editing = $state<{ r: number; c: number } | null>(null);
|
|
239
|
+
// When editing was opened by typing a character (type-to-edit), the editor
|
|
240
|
+
// seeds its input with it; null means edit the existing value (dblclick/Enter).
|
|
241
|
+
let editSeed = $state<string | null>(null);
|
|
138
242
|
|
|
139
|
-
function startEdit(r: number, c: number) {
|
|
243
|
+
function startEdit(r: number, c: number, seed: string | null = null) {
|
|
140
244
|
if (!isEditable(cols[c]) || !dataAt(r)) return;
|
|
245
|
+
editSeed = seed;
|
|
141
246
|
editing = { r, c };
|
|
142
247
|
}
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
248
|
+
// ---- Edit history (undo / redo) -------------------------------------------
|
|
249
|
+
// The grid is controlled (the consumer owns the data via onCellEdit), so undo
|
|
250
|
+
// re-emits onCellEdit with the previous value. History is keyed by row object
|
|
251
|
+
// reference + column, so it survives sort/filter/reorder. Multi-cell ops
|
|
252
|
+
// (paste, fill) record as one grouped step.
|
|
253
|
+
type EditCell = { row: GridRow; col: ColumnDef; old: string | number; value: string | number };
|
|
254
|
+
const UNDO_LIMIT = 100;
|
|
255
|
+
let undoStack: EditCell[][] = [];
|
|
256
|
+
let redoStack: EditCell[][] = [];
|
|
257
|
+
let currentBatch: EditCell[] | null = null;
|
|
258
|
+
function pushUndo(group: EditCell[]): void {
|
|
259
|
+
undoStack.push(group);
|
|
260
|
+
if (undoStack.length > UNDO_LIMIT) undoStack.shift();
|
|
261
|
+
redoStack = [];
|
|
262
|
+
}
|
|
263
|
+
/** Group every writeCell inside `fn` into a single undo step. */
|
|
264
|
+
function batch(fn: () => void): void {
|
|
265
|
+
const prev = currentBatch;
|
|
266
|
+
currentBatch = [];
|
|
267
|
+
fn();
|
|
268
|
+
const group = currentBatch;
|
|
269
|
+
currentBatch = prev;
|
|
270
|
+
if (group.length) pushUndo(group);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Coerce + validate a raw string for cell (r,c) and emit onCellEdit, recording
|
|
274
|
+
// it for undo. Returns true if written, false if rejected (not editable,
|
|
275
|
+
// missing row, or invalid number). Shared by inline edit, paste and fill.
|
|
146
276
|
function writeCell(r: number, c: number, raw: string): boolean {
|
|
147
277
|
const col = cols[c];
|
|
148
278
|
if (!col || !isEditable(col)) return false;
|
|
149
279
|
const row = dataAt(r);
|
|
150
280
|
if (!row) return false;
|
|
151
281
|
let value: string | number = raw;
|
|
152
|
-
if (
|
|
282
|
+
if (col.type === 'date') {
|
|
283
|
+
// The date editor emits a yyyy-mm-dd string; store the column's ms value.
|
|
284
|
+
const ms = Date.parse(`${raw}T00:00:00Z`);
|
|
285
|
+
if (!Number.isFinite(ms)) return false;
|
|
286
|
+
value = ms;
|
|
287
|
+
} else if (isNumeric(col)) {
|
|
153
288
|
const n = Number(raw);
|
|
154
289
|
if (!Number.isFinite(n)) return false; // reject invalid number, keep old value
|
|
155
290
|
value = n;
|
|
156
291
|
}
|
|
157
292
|
if (col.validate && !col.validate(value, row)) return false; // consumer rejected it
|
|
293
|
+
const old = (row[col.key] ?? '') as string | number;
|
|
158
294
|
onCellEdit?.({ row, column: col, value });
|
|
295
|
+
if (onCellEdit) {
|
|
296
|
+
const entry: EditCell = { row, col, old, value };
|
|
297
|
+
if (currentBatch) currentBatch.push(entry);
|
|
298
|
+
else pushUndo([entry]);
|
|
299
|
+
}
|
|
159
300
|
return true;
|
|
160
301
|
}
|
|
302
|
+
function undo(): void {
|
|
303
|
+
const group = undoStack.pop();
|
|
304
|
+
if (!group) return;
|
|
305
|
+
for (const e of group) onCellEdit?.({ row: e.row, column: e.col, value: e.old });
|
|
306
|
+
redoStack.push(group);
|
|
307
|
+
editing = null;
|
|
308
|
+
}
|
|
309
|
+
function redo(): void {
|
|
310
|
+
const group = redoStack.pop();
|
|
311
|
+
if (!group) return;
|
|
312
|
+
for (const e of group) onCellEdit?.({ row: e.row, column: e.col, value: e.value });
|
|
313
|
+
undoStack.push(group);
|
|
314
|
+
editing = null;
|
|
315
|
+
}
|
|
161
316
|
|
|
162
317
|
function commitEdit(r: number, c: number, raw: string) {
|
|
163
318
|
editing = null;
|
|
319
|
+
editSeed = null;
|
|
164
320
|
writeCell(r, c, raw);
|
|
165
321
|
}
|
|
166
322
|
|
|
@@ -178,12 +334,46 @@
|
|
|
178
334
|
// Per-column filter text (filterRow), keyed by column key.
|
|
179
335
|
let colFilters = $state<Record<string, string>>({});
|
|
180
336
|
|
|
337
|
+
// Structured per-column filters from the header filter menu (v0.3), keyed by
|
|
338
|
+
// column key. Menu filters take precedence over the filterRow text inputs.
|
|
339
|
+
// Controlled by the `columnFilters` prop when provided, else internal state.
|
|
340
|
+
let internalColumnFilters = $state<Record<string, ColumnFilter>>({});
|
|
341
|
+
const activeColumnFilters = $derived(columnFilters ?? internalColumnFilters);
|
|
342
|
+
function setColumnFilters(next: Record<string, ColumnFilter>): void {
|
|
343
|
+
if (columnFilters === undefined) internalColumnFilters = next; // uncontrolled: own it
|
|
344
|
+
onFilterChange?.(next); // always notify
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Built-in quick-filter text (global, matches across all columns).
|
|
348
|
+
let quickText = $state('');
|
|
349
|
+
|
|
181
350
|
// Whole-row selection (opt-in), keyed by row id so it survives sort/filter.
|
|
182
351
|
// Plain Set + a version counter for reactivity (same pattern as `collapsed`).
|
|
183
352
|
const SEL_W = 40; // checkbox column width (px)
|
|
353
|
+
const EXP_W = 30; // master-detail expand column width (px)
|
|
184
354
|
const selectedRows = new Set<string | number>();
|
|
185
355
|
let selRowsVersion = $state(0);
|
|
356
|
+
|
|
357
|
+
// Master-detail expansion (opt-in via `detail`), keyed by row id.
|
|
358
|
+
const expandedRows = new Set<string | number>();
|
|
359
|
+
let expVersion = $state(0);
|
|
360
|
+
const expandable = $derived(!!detail && !source);
|
|
361
|
+
|
|
362
|
+
// Leading fixed columns: expand toggle (if any) then checkbox (if any).
|
|
186
363
|
const selOffset = $derived(rowSelection ? 1 : 0);
|
|
364
|
+
const expOffset = $derived(expandable ? 1 : 0);
|
|
365
|
+
const leadCols = $derived(selOffset + expOffset); // count, for aria indices
|
|
366
|
+
const leadPx = $derived(selOffset * SEL_W + expOffset * EXP_W); // px, for sticky offsets
|
|
367
|
+
|
|
368
|
+
function isExpanded(id: string | number): boolean {
|
|
369
|
+
expVersion; // track
|
|
370
|
+
return expandedRows.has(id);
|
|
371
|
+
}
|
|
372
|
+
function toggleExpand(id: string | number): void {
|
|
373
|
+
if (expandedRows.has(id)) expandedRows.delete(id);
|
|
374
|
+
else expandedRows.add(id);
|
|
375
|
+
expVersion++;
|
|
376
|
+
}
|
|
187
377
|
|
|
188
378
|
function isRowSelected(id: string | number): boolean {
|
|
189
379
|
selRowsVersion; // track
|
|
@@ -197,17 +387,30 @@
|
|
|
197
387
|
}
|
|
198
388
|
|
|
199
389
|
const ordered = $derived(order.length === columns.length ? order.map((i) => columns[i]) : columns);
|
|
200
|
-
//
|
|
201
|
-
//
|
|
390
|
+
// Columns hidden at runtime via the column menu, unioned with the controlled
|
|
391
|
+
// `hiddenColumns` prop.
|
|
392
|
+
let runtimeHidden = $state<string[]>([]);
|
|
393
|
+
const effectiveHidden = $derived(
|
|
394
|
+
runtimeHidden.length ? [...new Set([...hiddenColumns, ...runtimeHidden])] : hiddenColumns,
|
|
395
|
+
);
|
|
396
|
+
// Drop hidden columns. Applied after ordering so `order` stays indexed over the
|
|
397
|
+
// full column set.
|
|
202
398
|
const visible = $derived(
|
|
203
|
-
|
|
399
|
+
effectiveHidden.length ? ordered.filter((c) => !effectiveHidden.includes(c.key)) : ordered,
|
|
204
400
|
);
|
|
205
401
|
// Apply any resize overrides (turns the dragged column fixed-width), then
|
|
206
402
|
// pin-arrange. Both are no-ops by default, so the grid stays fit-to-width.
|
|
207
403
|
const sized = $derived(applyWidths(visible, widths));
|
|
208
|
-
//
|
|
404
|
+
// Runtime pin overrides (column menu) layered on top of static `col.pinned`.
|
|
405
|
+
let pinOverrides = $state<Record<string, 'left' | 'right' | false>>({});
|
|
406
|
+
const pinnedSized = $derived(
|
|
407
|
+
Object.keys(pinOverrides).length
|
|
408
|
+
? sized.map((c) => (c.key in pinOverrides ? { ...c, pinned: pinOverrides[c.key] } : c))
|
|
409
|
+
: sized,
|
|
410
|
+
);
|
|
411
|
+
// Pin-arrangement: pinned columns move to the edges and get sticky offsets.
|
|
209
412
|
// When nothing is pinned this is a no-op and the grid stays fit-to-width.
|
|
210
|
-
const layout = $derived(arrangePinned(
|
|
413
|
+
const layout = $derived(arrangePinned(pinnedSized));
|
|
211
414
|
const cols = $derived(layout.columns);
|
|
212
415
|
const pinned = $derived(layout.anyPinned);
|
|
213
416
|
|
|
@@ -237,24 +440,38 @@
|
|
|
237
440
|
|
|
238
441
|
// Pinned columns sit to the right of the (also-sticky) checkbox column, so
|
|
239
442
|
// their sticky-left offsets shift by SEL_W when row selection is on.
|
|
443
|
+
// Sticky position for a pinned column: left columns offset past the (also
|
|
444
|
+
// sticky) checkbox column; right columns offset from the right edge.
|
|
445
|
+
function pinStick(ci: number): string {
|
|
446
|
+
const inf = layout.info[ci];
|
|
447
|
+
if (inf.side === 'right') return `position:sticky;right:${inf.right}px;`;
|
|
448
|
+
return `position:sticky;left:${inf.left + leadPx}px;`;
|
|
449
|
+
}
|
|
240
450
|
function headStyle(ci: number): string {
|
|
241
451
|
if (!pinned) return colStyle(cols[ci]);
|
|
242
452
|
const inf = layout.info[ci];
|
|
243
453
|
let s = `flex:0 0 ${inf.width}px;width:${inf.width}px;`;
|
|
244
|
-
if (inf.pinned) s +=
|
|
454
|
+
if (inf.pinned) s += `${pinStick(ci)}z-index:5;background:var(--bo-header-bg);`;
|
|
245
455
|
return s;
|
|
246
456
|
}
|
|
247
457
|
function cellWidthStyle(ci: number): string {
|
|
248
458
|
if (!pinned) return colStyle(cols[ci]);
|
|
249
459
|
const inf = layout.info[ci];
|
|
250
460
|
let s = `flex:0 0 ${inf.width}px;width:${inf.width}px;`;
|
|
251
|
-
if (inf.pinned) s +=
|
|
461
|
+
if (inf.pinned) s += `${pinStick(ci)}z-index:1;background:var(--bo-bg);`;
|
|
252
462
|
return s;
|
|
253
463
|
}
|
|
254
|
-
// The leading checkbox column: a fixed-width flex item, sticky-left
|
|
255
|
-
// grid scrolls horizontally (pinned mode).
|
|
464
|
+
// The leading checkbox column: a fixed-width flex item, sticky-left (past the
|
|
465
|
+
// expand column, if any) when the grid scrolls horizontally (pinned mode).
|
|
256
466
|
function selCellStyle(header: boolean): string {
|
|
257
467
|
let s = `flex:0 0 ${SEL_W}px;width:${SEL_W}px;`;
|
|
468
|
+
if (pinned)
|
|
469
|
+
s += `position:sticky;left:${expOffset * EXP_W}px;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
|
|
470
|
+
return s;
|
|
471
|
+
}
|
|
472
|
+
// The leading expand-toggle column (master-detail), sticky at the far left.
|
|
473
|
+
function expandCellStyle(header: boolean): string {
|
|
474
|
+
let s = `flex:0 0 ${EXP_W}px;width:${EXP_W}px;`;
|
|
258
475
|
if (pinned)
|
|
259
476
|
s += `position:sticky;left:0;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
|
|
260
477
|
return s;
|
|
@@ -301,6 +518,7 @@
|
|
|
301
518
|
/* storage unavailable — order still applies this session */
|
|
302
519
|
}
|
|
303
520
|
}
|
|
521
|
+
onColumnReorder?.(next.map((i) => columns[i].key));
|
|
304
522
|
}
|
|
305
523
|
|
|
306
524
|
// ---- Column resizing -------------------------------------------------------
|
|
@@ -331,7 +549,106 @@
|
|
|
331
549
|
});
|
|
332
550
|
});
|
|
333
551
|
|
|
334
|
-
|
|
552
|
+
// ---- Runtime column visibility (column menu) ------------------------------
|
|
553
|
+
function hiddenStorageKey(): string | null {
|
|
554
|
+
return persistKey ? `bo-grid:hidden:${persistKey}` : null;
|
|
555
|
+
}
|
|
556
|
+
$effect(() => {
|
|
557
|
+
persistKey;
|
|
558
|
+
untrack(() => {
|
|
559
|
+
const key = hiddenStorageKey();
|
|
560
|
+
if (!key || typeof localStorage === 'undefined') return;
|
|
561
|
+
try {
|
|
562
|
+
const saved = JSON.parse(localStorage.getItem(key) ?? 'null');
|
|
563
|
+
if (Array.isArray(saved)) runtimeHidden = saved.filter((k) => typeof k === 'string');
|
|
564
|
+
} catch {
|
|
565
|
+
/* corrupt value — ignore */
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
function setRuntimeHidden(next: string[]): void {
|
|
570
|
+
runtimeHidden = next;
|
|
571
|
+
const key = hiddenStorageKey();
|
|
572
|
+
if (key && typeof localStorage !== 'undefined') {
|
|
573
|
+
try {
|
|
574
|
+
localStorage.setItem(key, JSON.stringify(next));
|
|
575
|
+
} catch {
|
|
576
|
+
/* storage unavailable — still applies this session */
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
onColumnVisibilityChange?.(effectiveHidden);
|
|
580
|
+
}
|
|
581
|
+
function hideColumn(key: string): void {
|
|
582
|
+
if (!runtimeHidden.includes(key)) setRuntimeHidden([...runtimeHidden, key]);
|
|
583
|
+
}
|
|
584
|
+
function showColumn(key: string): void {
|
|
585
|
+
if (runtimeHidden.includes(key)) setRuntimeHidden(runtimeHidden.filter((k) => k !== key));
|
|
586
|
+
}
|
|
587
|
+
function toggleColumnVisible(key: string): void {
|
|
588
|
+
if (effectiveHidden.includes(key)) showColumn(key);
|
|
589
|
+
else hideColumn(key);
|
|
590
|
+
}
|
|
591
|
+
function showAllColumns(): void {
|
|
592
|
+
setRuntimeHidden([]);
|
|
593
|
+
}
|
|
594
|
+
// Columns tool panel (lazy-loaded checklist), anchored at the toolbar button.
|
|
595
|
+
let ToolPanelComp = $state<typeof import('./ToolPanel.svelte').default | null>(null);
|
|
596
|
+
let panelXY = $state<{ x: number; y: number } | null>(null);
|
|
597
|
+
async function openToolPanel(e: Event) {
|
|
598
|
+
e.stopPropagation();
|
|
599
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
600
|
+
if (!ToolPanelComp) ToolPanelComp = (await import('./ToolPanel.svelte')).default;
|
|
601
|
+
panelXY = { x: rect.left, y: rect.bottom + 2 };
|
|
602
|
+
}
|
|
603
|
+
$effect(() => {
|
|
604
|
+
if (!panelXY) return;
|
|
605
|
+
const close = () => (panelXY = null);
|
|
606
|
+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && (panelXY = null);
|
|
607
|
+
window.addEventListener('pointerdown', close);
|
|
608
|
+
window.addEventListener('keydown', onKey);
|
|
609
|
+
window.addEventListener('blur', close);
|
|
610
|
+
return () => {
|
|
611
|
+
window.removeEventListener('pointerdown', close);
|
|
612
|
+
window.removeEventListener('keydown', onKey);
|
|
613
|
+
window.removeEventListener('blur', close);
|
|
614
|
+
};
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
// ---- Runtime column pinning (column menu) ---------------------------------
|
|
618
|
+
function pinStorageKey(): string | null {
|
|
619
|
+
return persistKey ? `bo-grid:pin:${persistKey}` : null;
|
|
620
|
+
}
|
|
621
|
+
$effect(() => {
|
|
622
|
+
persistKey;
|
|
623
|
+
untrack(() => {
|
|
624
|
+
const key = pinStorageKey();
|
|
625
|
+
if (!key || typeof localStorage === 'undefined') return;
|
|
626
|
+
try {
|
|
627
|
+
const saved = JSON.parse(localStorage.getItem(key) ?? 'null');
|
|
628
|
+
if (saved && typeof saved === 'object') pinOverrides = saved as typeof pinOverrides;
|
|
629
|
+
} catch {
|
|
630
|
+
/* corrupt value — ignore */
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
function setPinOverride(key: string, side: 'left' | 'right' | false): void {
|
|
635
|
+
pinOverrides = { ...pinOverrides, [key]: side };
|
|
636
|
+
const sk = pinStorageKey();
|
|
637
|
+
if (sk && typeof localStorage !== 'undefined') {
|
|
638
|
+
try {
|
|
639
|
+
localStorage.setItem(sk, JSON.stringify(pinOverrides));
|
|
640
|
+
} catch {
|
|
641
|
+
/* storage unavailable — still applies this session */
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
// Effective pin side for a column key: runtime override, else static config.
|
|
646
|
+
function pinSideOf(col: ColumnDef): 'left' | 'right' | false {
|
|
647
|
+
if (col.key in pinOverrides) return pinOverrides[col.key];
|
|
648
|
+
return col.pinned === true ? 'left' : col.pinned || false;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
let resize: { key: string; startX: number; startW: number; min?: number; max?: number } | null = null;
|
|
335
652
|
let justResized = false;
|
|
336
653
|
|
|
337
654
|
function startResize(ci: number, e: PointerEvent) {
|
|
@@ -340,22 +657,24 @@
|
|
|
340
657
|
e.stopPropagation();
|
|
341
658
|
const headCell = (e.currentTarget as HTMLElement).closest('.h') as HTMLElement | null;
|
|
342
659
|
const startW = headCell ? headCell.getBoundingClientRect().width : layout.info[ci].width;
|
|
343
|
-
resize = { key: cols[ci].key, startX: e.clientX, startW };
|
|
660
|
+
resize = { key: cols[ci].key, startX: e.clientX, startW, min: cols[ci].minWidth, max: cols[ci].maxWidth };
|
|
344
661
|
window.addEventListener('pointermove', onResizeMove);
|
|
345
662
|
window.addEventListener('pointerup', onResizeUp);
|
|
346
663
|
}
|
|
347
664
|
function onResizeMove(e: PointerEvent) {
|
|
348
665
|
if (!resize) return;
|
|
349
|
-
const w = clampWidth(resize.startW + (e.clientX - resize.startX));
|
|
666
|
+
const w = clampWidth(resize.startW + (e.clientX - resize.startX), resize.min, resize.max);
|
|
350
667
|
widths = { ...widths, [resize.key]: w };
|
|
351
668
|
}
|
|
352
669
|
function onResizeUp() {
|
|
353
670
|
if (!resize) return;
|
|
671
|
+
const { key } = resize;
|
|
354
672
|
resize = null;
|
|
355
673
|
justResized = true; // swallow the click that ends this drag (no sort toggle)
|
|
356
674
|
window.removeEventListener('pointermove', onResizeMove);
|
|
357
675
|
window.removeEventListener('pointerup', onResizeUp);
|
|
358
676
|
persistWidths();
|
|
677
|
+
onColumnResize?.(key, widths[key]);
|
|
359
678
|
}
|
|
360
679
|
/** Double-click a resize grip to clear the override and restore the default. */
|
|
361
680
|
function resetWidth(ci: number, e: MouseEvent) {
|
|
@@ -410,29 +729,92 @@
|
|
|
410
729
|
const base = rows;
|
|
411
730
|
const allCols = columns;
|
|
412
731
|
const f = filter.trim().toLowerCase();
|
|
732
|
+
const q = quickText.trim().toLowerCase();
|
|
413
733
|
const s = sorts;
|
|
414
|
-
// Active per-column filters
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
734
|
+
// Active per-column filters: menu-driven structured filters (columnFilters)
|
|
735
|
+
// take precedence; filterRow text inputs (colFilters) fill in the rest as
|
|
736
|
+
// case-insensitive "contains".
|
|
737
|
+
const active: Record<string, ColumnFilter> = { ...activeColumnFilters };
|
|
738
|
+
for (const [k, v] of Object.entries(colFilters)) {
|
|
739
|
+
if (!active[k] && v.trim()) active[k] = { kind: 'text', op: 'contains', q: v };
|
|
740
|
+
}
|
|
741
|
+
const hasColFilters = Object.keys(active).length > 0;
|
|
418
742
|
return untrack(() => {
|
|
419
743
|
let r = base;
|
|
420
744
|
if (f) r = r.filter((row) => allCols.some((c) => String(row[c.key] ?? '').toLowerCase().includes(f)));
|
|
421
|
-
if (
|
|
422
|
-
|
|
745
|
+
if (q) r = r.filter((row) => allCols.some((c) => String(row[c.key] ?? '').toLowerCase().includes(q)));
|
|
746
|
+
if (hasColFilters) {
|
|
747
|
+
r = r.filter((row) => passesFilters(row, active));
|
|
748
|
+
}
|
|
749
|
+
if (s.length > 0) {
|
|
750
|
+
const colOf = (k: string) => allCols.find((c) => c.key === k);
|
|
751
|
+
r = [...r].sort((a, b) => compareBySorts(a, b, s, colOf));
|
|
423
752
|
}
|
|
424
|
-
if (s.length > 0) r = [...r].sort((a, b) => compareBySorts(a, b, s));
|
|
425
753
|
return r;
|
|
426
754
|
});
|
|
427
755
|
});
|
|
428
756
|
|
|
757
|
+
// Pagination (in-memory only): slice the view into pages; rows still
|
|
758
|
+
// virtualize within a page. Off when pageSize <= 0.
|
|
759
|
+
const paged = $derived(pageSize > 0 && !source);
|
|
760
|
+
let internalPage = $state(0);
|
|
761
|
+
const currentPage = $derived(page ?? internalPage); // controlled by `page` prop, else internal
|
|
762
|
+
function setPage(p: number): void {
|
|
763
|
+
if (page === undefined) internalPage = p; // uncontrolled: own the state
|
|
764
|
+
onPageChange?.(p); // always notify
|
|
765
|
+
}
|
|
766
|
+
const pageCount = $derived(paged ? Math.max(1, Math.ceil(view.length / pageSize)) : 1);
|
|
767
|
+
// Keep the page in range when the view shrinks (filter/sort changes).
|
|
768
|
+
$effect(() => {
|
|
769
|
+
const max = pageCount - 1;
|
|
770
|
+
untrack(() => {
|
|
771
|
+
if (currentPage > max) setPage(max);
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
const pageRows = $derived(
|
|
775
|
+
paged ? view.slice(currentPage * pageSize, currentPage * pageSize + pageSize) : view,
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
const treeData = $derived(!!getChildren && !source);
|
|
779
|
+
|
|
780
|
+
// Drag-to-reorder rows (flat, unsorted, in-memory only). The handle lives in
|
|
781
|
+
// the first cell; the dragged/drop indices are tracked in component state.
|
|
782
|
+
const reorderable = $derived(
|
|
783
|
+
!!onRowReorder && !source && !treeData && groupBy.length === 0 && pageSize <= 0,
|
|
784
|
+
);
|
|
785
|
+
let dragRowVr = $state(-1);
|
|
786
|
+
let dropRowVr = $state(-1);
|
|
787
|
+
function onRowDrop() {
|
|
788
|
+
if (dragRowVr >= 0 && dropRowVr >= 0 && dragRowVr !== dropRowVr) {
|
|
789
|
+
onRowReorder?.(dragRowVr, dropRowVr);
|
|
790
|
+
}
|
|
791
|
+
dragRowVr = -1;
|
|
792
|
+
dropRowVr = -1;
|
|
793
|
+
}
|
|
794
|
+
|
|
429
795
|
const flat = $derived.by<VisualRow[]>(() => {
|
|
430
|
-
|
|
796
|
+
if (treeData && getChildren) {
|
|
797
|
+
const roots = rows;
|
|
798
|
+
expVersion; // track expand/collapse
|
|
799
|
+
return untrack(() =>
|
|
800
|
+
buildTreeRows(roots, getChildren, (r) => expandedRows.has(getRowId(r))),
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
const v = pageRows;
|
|
431
804
|
const gb = groupBy;
|
|
432
805
|
collapsedVersion;
|
|
433
806
|
return untrack(() => buildFlatRows(v, gb, collapsed));
|
|
434
807
|
});
|
|
435
808
|
|
|
809
|
+
function goToPage(p: number): void {
|
|
810
|
+
const next = Math.max(0, Math.min(p, pageCount - 1));
|
|
811
|
+
if (next === currentPage) return;
|
|
812
|
+
setPage(next);
|
|
813
|
+
sel.clear();
|
|
814
|
+
editing = null;
|
|
815
|
+
if (viewportEl) viewportEl.scrollTop = 0;
|
|
816
|
+
}
|
|
817
|
+
|
|
436
818
|
// Header select-all state over the in-memory rows (source mode can't enumerate
|
|
437
819
|
// unloaded ids, so the header checkbox is disabled there).
|
|
438
820
|
const selectAll = $derived.by(() => {
|
|
@@ -483,22 +865,32 @@
|
|
|
483
865
|
// a prefix-sum model (in-memory only — source mode can't know unloaded heights).
|
|
484
866
|
const baseH = $derived(typeof rowHeight === 'number' && rowHeight > 0 ? rowHeight : ROW_H);
|
|
485
867
|
const variable = $derived(typeof rowHeight === 'function' && !source);
|
|
868
|
+
// Use a prefix-sum height model when row heights vary: a `rowHeight` function,
|
|
869
|
+
// or master-detail expansion (expanded rows are taller by `detailHeight`).
|
|
870
|
+
const useHeights = $derived(variable || expandable);
|
|
486
871
|
const heights = $derived.by<number[] | null>(() => {
|
|
487
|
-
if (!
|
|
488
|
-
const fn = rowHeight as (row: GridRow, index: number) => number;
|
|
872
|
+
if (!useHeights) return null;
|
|
873
|
+
const fn = variable ? (rowHeight as (row: GridRow, index: number) => number) : null;
|
|
874
|
+
expVersion; // track expansion changes
|
|
489
875
|
const arr = new Array<number>(flat.length);
|
|
490
876
|
let di = 0;
|
|
491
877
|
for (let i = 0; i < flat.length; i++) {
|
|
492
878
|
const it = flat[i];
|
|
493
|
-
|
|
879
|
+
if (it.kind !== 'data') {
|
|
880
|
+
arr[i] = baseH;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
let h = fn ? Math.max(1, fn(it.row, di++)) : baseH;
|
|
884
|
+
if (expandable && expandedRows.has(getRowId(it.row))) h += detailHeight;
|
|
885
|
+
arr[i] = h;
|
|
494
886
|
}
|
|
495
887
|
return arr;
|
|
496
888
|
});
|
|
497
|
-
const hm = $derived(
|
|
889
|
+
const hm = $derived(useHeights && heights ? variableHeights(heights) : uniformHeights(rowCount, baseH));
|
|
498
890
|
|
|
499
891
|
const total = $derived(hm.total);
|
|
500
892
|
const rowWidthStyle = $derived(
|
|
501
|
-
pinned ? `width:${layout.totalWidth +
|
|
893
|
+
pinned ? `width:${layout.totalWidth + leadPx}px;right:auto;` : '',
|
|
502
894
|
);
|
|
503
895
|
const visibleCount = $derived(Math.ceil(height / baseH) + OVERSCAN * 2);
|
|
504
896
|
const start = $derived(Math.max(0, hm.indexAt(scrollTop) - OVERSCAN));
|
|
@@ -510,7 +902,7 @@
|
|
|
510
902
|
|
|
511
903
|
type RenderItem =
|
|
512
904
|
| { vr: number; kind: 'group'; group: GroupNode }
|
|
513
|
-
| { vr: number; kind: 'data'; row: GridRow }
|
|
905
|
+
| { vr: number; kind: 'data'; row: GridRow; depth?: number; hasChildren?: boolean }
|
|
514
906
|
| { vr: number; kind: 'skeleton' };
|
|
515
907
|
|
|
516
908
|
const renderItems = $derived.by<RenderItem[]>(() => {
|
|
@@ -526,7 +918,7 @@
|
|
|
526
918
|
const item = flat[vr];
|
|
527
919
|
if (!item) continue;
|
|
528
920
|
if (item.kind === 'group') out.push({ vr, kind: 'group', group: item.group });
|
|
529
|
-
else out.push({ vr, kind: 'data', row: item.row });
|
|
921
|
+
else out.push({ vr, kind: 'data', row: item.row, depth: item.depth, hasChildren: item.hasChildren });
|
|
530
922
|
}
|
|
531
923
|
}
|
|
532
924
|
return out;
|
|
@@ -543,7 +935,8 @@
|
|
|
543
935
|
const range = { start, end: start + visibleCount };
|
|
544
936
|
const s = sorts;
|
|
545
937
|
const f = filter;
|
|
546
|
-
|
|
938
|
+
const cf = activeColumnFilters;
|
|
939
|
+
void ctrl.fetch(range, s, f, cf);
|
|
547
940
|
});
|
|
548
941
|
|
|
549
942
|
function dataAt(r: number): GridRow | null {
|
|
@@ -595,7 +988,171 @@
|
|
|
595
988
|
}
|
|
596
989
|
|
|
597
990
|
function onCellEnter(r: number, c: number) {
|
|
598
|
-
if (
|
|
991
|
+
if (filling) fillTo = { r, c };
|
|
992
|
+
else if (dragging) sel.extendTo(r, c);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Fill handle: start dragging from the selection's bottom-right corner.
|
|
996
|
+
function onFillStart(): void {
|
|
997
|
+
const b = sel.bounds;
|
|
998
|
+
if (!b) return;
|
|
999
|
+
fillSource = { ...b };
|
|
1000
|
+
fillTo = { r: b.r1, c: b.c1 };
|
|
1001
|
+
filling = true;
|
|
1002
|
+
}
|
|
1003
|
+
// The rectangle the fill drag currently covers (extends down/right only).
|
|
1004
|
+
const fillRange = $derived.by(() => {
|
|
1005
|
+
if (!filling || !fillSource || !fillTo) return null;
|
|
1006
|
+
return {
|
|
1007
|
+
r0: fillSource.r0,
|
|
1008
|
+
c0: fillSource.c0,
|
|
1009
|
+
r1: Math.max(fillSource.r1, fillTo.r),
|
|
1010
|
+
c1: Math.max(fillSource.c1, fillTo.c),
|
|
1011
|
+
};
|
|
1012
|
+
});
|
|
1013
|
+
function inFillPreview(r: number, c: number): boolean {
|
|
1014
|
+
const fr = fillRange;
|
|
1015
|
+
return !!fr && r >= fr.r0 && r <= fr.r1 && c >= fr.c0 && c <= fr.c1 && !sel.contains(r, c);
|
|
1016
|
+
}
|
|
1017
|
+
// Commit the fill: tile the source block's values across the extended range.
|
|
1018
|
+
function commitFill(): void {
|
|
1019
|
+
if (!filling) return;
|
|
1020
|
+
filling = false;
|
|
1021
|
+
const b = fillSource;
|
|
1022
|
+
const ft = fillTo;
|
|
1023
|
+
fillTo = null;
|
|
1024
|
+
fillSource = null;
|
|
1025
|
+
if (!b || !ft) return;
|
|
1026
|
+
const r1 = Math.max(b.r1, ft.r);
|
|
1027
|
+
const c1 = Math.max(b.c1, ft.c);
|
|
1028
|
+
if (r1 === b.r1 && c1 === b.c1) return; // no extension
|
|
1029
|
+
const srcRows = b.r1 - b.r0 + 1;
|
|
1030
|
+
const srcCols = b.c1 - b.c0 + 1;
|
|
1031
|
+
batch(() => {
|
|
1032
|
+
for (let r = b.r0; r <= r1; r++) {
|
|
1033
|
+
for (let c = b.c0; c <= c1; c++) {
|
|
1034
|
+
if (r <= b.r1 && c <= b.c1) continue; // skip the source block
|
|
1035
|
+
const srcRow = dataAt(b.r0 + ((r - b.r0) % srcRows));
|
|
1036
|
+
if (!srcRow) continue;
|
|
1037
|
+
writeCell(r, c, String(srcRow[cols[b.c0 + ((c - b.c0) % srcCols)].key] ?? ''));
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
sel.anchor = { r: b.r0, c: b.c0 };
|
|
1042
|
+
sel.focus = { r: r1, c: c1 };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Right-click row menu (floating).
|
|
1046
|
+
let menu = $state<{ x: number; y: number; items: Array<{ label: string; onSelect: () => void }> } | null>(null);
|
|
1047
|
+
function openRowMenu(row: GridRow, e: MouseEvent) {
|
|
1048
|
+
if (!rowMenu) return;
|
|
1049
|
+
const items = rowMenu(row);
|
|
1050
|
+
if (items.length === 0) return;
|
|
1051
|
+
e.preventDefault();
|
|
1052
|
+
menu = { x: e.clientX, y: e.clientY, items };
|
|
1053
|
+
}
|
|
1054
|
+
$effect(() => {
|
|
1055
|
+
if (!menu) return;
|
|
1056
|
+
const close = () => (menu = null);
|
|
1057
|
+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && (menu = null);
|
|
1058
|
+
window.addEventListener('pointerdown', close);
|
|
1059
|
+
window.addEventListener('keydown', onKey);
|
|
1060
|
+
window.addEventListener('blur', close);
|
|
1061
|
+
return () => {
|
|
1062
|
+
window.removeEventListener('pointerdown', close);
|
|
1063
|
+
window.removeEventListener('keydown', onKey);
|
|
1064
|
+
window.removeEventListener('blur', close);
|
|
1065
|
+
};
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
// Header filter menu (v0.3), lazy-loaded on first open to keep the core lean.
|
|
1069
|
+
let FilterMenuComp = $state<typeof import('./FilterMenu.svelte').default | null>(null);
|
|
1070
|
+
let filterUi = $state<{
|
|
1071
|
+
key: string;
|
|
1072
|
+
kind: FilterKind;
|
|
1073
|
+
header: string;
|
|
1074
|
+
values: string[];
|
|
1075
|
+
x: number;
|
|
1076
|
+
y: number;
|
|
1077
|
+
} | null>(null);
|
|
1078
|
+
function filterKindFor(col: ColumnDef): FilterKind {
|
|
1079
|
+
return typeof col.filter === 'string' ? col.filter : defaultFilterKind(col);
|
|
1080
|
+
}
|
|
1081
|
+
async function openFilterMenu(col: ColumnDef, e: Event) {
|
|
1082
|
+
e.stopPropagation();
|
|
1083
|
+
// Read the anchor rect now — after the await, the event's currentTarget is null.
|
|
1084
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1085
|
+
let kind = filterKindFor(col);
|
|
1086
|
+
// A set filter needs distinct values; in source mode they can't be
|
|
1087
|
+
// enumerated, so fall back to the column's typed filter.
|
|
1088
|
+
if (source && kind === 'set') kind = defaultFilterKind(col);
|
|
1089
|
+
const values = !source && kind === 'set' ? distinctValues(rows, col.key) : [];
|
|
1090
|
+
if (!FilterMenuComp) FilterMenuComp = (await import('./FilterMenu.svelte')).default;
|
|
1091
|
+
filterUi = { key: col.key, kind, header: col.header, values, x: rect.left, y: rect.bottom + 2 };
|
|
1092
|
+
}
|
|
1093
|
+
function applyColumnFilter(key: string, f: ColumnFilter | null): void {
|
|
1094
|
+
const next = { ...activeColumnFilters };
|
|
1095
|
+
if (f) next[key] = f;
|
|
1096
|
+
else delete next[key];
|
|
1097
|
+
setColumnFilters(next);
|
|
1098
|
+
filterUi = null;
|
|
1099
|
+
}
|
|
1100
|
+
$effect(() => {
|
|
1101
|
+
if (!filterUi) return;
|
|
1102
|
+
const close = () => (filterUi = null);
|
|
1103
|
+
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && (filterUi = null);
|
|
1104
|
+
window.addEventListener('pointerdown', close);
|
|
1105
|
+
window.addEventListener('keydown', onKey);
|
|
1106
|
+
window.addEventListener('blur', close);
|
|
1107
|
+
return () => {
|
|
1108
|
+
window.removeEventListener('pointerdown', close);
|
|
1109
|
+
window.removeEventListener('keydown', onKey);
|
|
1110
|
+
window.removeEventListener('blur', close);
|
|
1111
|
+
};
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
// Autosize a column to its content (a character-count heuristic — no DOM
|
|
1115
|
+
// measurement, so it's cheap and deterministic). Sets the same width override
|
|
1116
|
+
// as a manual resize.
|
|
1117
|
+
function autosizeColumn(col: ColumnDef): void {
|
|
1118
|
+
const CHAR_PX = 7.5;
|
|
1119
|
+
const PAD = 32; // cell padding + room for the header icons
|
|
1120
|
+
let maxLen = col.header.length;
|
|
1121
|
+
if (!source) {
|
|
1122
|
+
for (const row of view.slice(0, 500)) {
|
|
1123
|
+
const s = formatCell(col, row[col.key], row);
|
|
1124
|
+
if (s.length > maxLen) maxLen = s.length;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const w = clampWidth(Math.round(maxLen * CHAR_PX) + PAD, col.minWidth, col.maxWidth);
|
|
1128
|
+
widths = { ...widths, [col.key]: w };
|
|
1129
|
+
persistWidths();
|
|
1130
|
+
onColumnResize?.(col.key, w);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Column header menu (⋮): sort / pin / autosize / hide. Reuses the floating
|
|
1134
|
+
// RowMenu (a light action list — no lazy chunk, unlike the filter menu).
|
|
1135
|
+
function columnMenuItems(col: ColumnDef): Array<{ label: string; onSelect: () => void }> {
|
|
1136
|
+
const items: Array<{ label: string; onSelect: () => void }> = [];
|
|
1137
|
+
if (isSortable(col)) {
|
|
1138
|
+
items.push({ label: 'Sort ascending', onSelect: () => setSorts([{ key: col.key, dir: 'asc' }]) });
|
|
1139
|
+
items.push({ label: 'Sort descending', onSelect: () => setSorts([{ key: col.key, dir: 'desc' }]) });
|
|
1140
|
+
if (sortInfo(col.key)) {
|
|
1141
|
+
items.push({ label: 'Clear sort', onSelect: () => setSorts(sorts.filter((s) => s.key !== col.key)) });
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const side = pinSideOf(col);
|
|
1145
|
+
if (side !== 'left') items.push({ label: 'Pin left', onSelect: () => setPinOverride(col.key, 'left') });
|
|
1146
|
+
if (side !== 'right') items.push({ label: 'Pin right', onSelect: () => setPinOverride(col.key, 'right') });
|
|
1147
|
+
if (side) items.push({ label: 'Unpin', onSelect: () => setPinOverride(col.key, false) });
|
|
1148
|
+
if (isResizable(col, resizable)) items.push({ label: 'Autosize', onSelect: () => autosizeColumn(col) });
|
|
1149
|
+
items.push({ label: 'Hide column', onSelect: () => hideColumn(col.key) });
|
|
1150
|
+
return items;
|
|
1151
|
+
}
|
|
1152
|
+
function openColumnMenu(col: ColumnDef, e: Event) {
|
|
1153
|
+
e.stopPropagation();
|
|
1154
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
1155
|
+
menu = { x: rect.left, y: rect.bottom + 2, items: columnMenuItems(col) };
|
|
599
1156
|
}
|
|
600
1157
|
|
|
601
1158
|
function onCellClicked(r: number, c: number, e: MouseEvent) {
|
|
@@ -636,7 +1193,7 @@
|
|
|
636
1193
|
const cells: string[] = [];
|
|
637
1194
|
for (let c = b.c0; c <= cEnd; c++) {
|
|
638
1195
|
const col = cols[c];
|
|
639
|
-
cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, row[col.key]));
|
|
1196
|
+
cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, row[col.key], row));
|
|
640
1197
|
}
|
|
641
1198
|
lines.push(cells.join('\t'));
|
|
642
1199
|
}
|
|
@@ -667,18 +1224,20 @@
|
|
|
667
1224
|
const c0 = anchor.c0;
|
|
668
1225
|
const rSpan = single ? anchor.r1 - anchor.r0 + 1 : grid.length;
|
|
669
1226
|
let wrote = 0;
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1227
|
+
batch(() => {
|
|
1228
|
+
for (let dr = 0; dr < rSpan; dr++) {
|
|
1229
|
+
const r = r0 + dr;
|
|
1230
|
+
if (r > rowCount - 1) break;
|
|
1231
|
+
const srcRow = single ? grid[0] : grid[dr];
|
|
1232
|
+
const cSpan = single ? anchor.c1 - anchor.c0 + 1 : srcRow.length;
|
|
1233
|
+
for (let dc = 0; dc < cSpan; dc++) {
|
|
1234
|
+
const c = c0 + dc;
|
|
1235
|
+
if (c > cols.length - 1) break;
|
|
1236
|
+
const raw = single ? grid[0][0] : (srcRow[dc] ?? '');
|
|
1237
|
+
if (writeCell(r, c, raw)) wrote++;
|
|
1238
|
+
}
|
|
680
1239
|
}
|
|
681
|
-
}
|
|
1240
|
+
});
|
|
682
1241
|
// Surface the pasted region as the new selection so it's visible.
|
|
683
1242
|
if (wrote > 0) {
|
|
684
1243
|
const rEnd = Math.min(r0 + rSpan - 1, rowCount - 1);
|
|
@@ -708,6 +1267,42 @@
|
|
|
708
1267
|
}
|
|
709
1268
|
}
|
|
710
1269
|
}
|
|
1270
|
+
// Space toggles selection of the focused row (when the checkbox column is on),
|
|
1271
|
+
// so keyboard users can tick rows without reaching for the mouse.
|
|
1272
|
+
if (e.key === ' ' && rowSelection && sel.focus && !editing) {
|
|
1273
|
+
const row = dataAt(sel.focus.r);
|
|
1274
|
+
if (row) {
|
|
1275
|
+
e.preventDefault();
|
|
1276
|
+
toggleRow(getRowId(row));
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
// Type-to-edit (Excel-style): a printable key on a focused editable text/number
|
|
1281
|
+
// cell opens the editor seeded with that character. Select-column editors keep
|
|
1282
|
+
// their Enter-to-open behavior.
|
|
1283
|
+
if (!mod && !e.altKey && e.key.length === 1 && e.key !== ' ' && sel.focus && !editing) {
|
|
1284
|
+
const f = sel.focus;
|
|
1285
|
+
const col = cols[f.c];
|
|
1286
|
+
if (isEditable(col) && col.type !== 'date' && !(col.options && col.options.length) && dataAt(f.r)) {
|
|
1287
|
+
e.preventDefault();
|
|
1288
|
+
startEdit(f.r, f.c, e.key);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
|
|
1293
|
+
if (undoStack.length) {
|
|
1294
|
+
e.preventDefault();
|
|
1295
|
+
undo();
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (mod && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z'))) {
|
|
1300
|
+
if (redoStack.length) {
|
|
1301
|
+
e.preventDefault();
|
|
1302
|
+
redo();
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
711
1306
|
if (mod && e.key.toLowerCase() === 'a') {
|
|
712
1307
|
e.preventDefault();
|
|
713
1308
|
sel.selectAll(rowCount, cols.length);
|
|
@@ -727,6 +1322,43 @@
|
|
|
727
1322
|
sel.clear();
|
|
728
1323
|
return;
|
|
729
1324
|
}
|
|
1325
|
+
// Open the row menu from the keyboard (ContextMenu key / Shift+F10).
|
|
1326
|
+
if (rowMenu && sel.focus && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) {
|
|
1327
|
+
const row = dataAt(sel.focus.r);
|
|
1328
|
+
const items = row ? rowMenu(row) : [];
|
|
1329
|
+
if (items.length > 0) {
|
|
1330
|
+
e.preventDefault();
|
|
1331
|
+
const rect = document.getElementById(activeId ?? '')?.getBoundingClientRect();
|
|
1332
|
+
menu = { x: rect?.left ?? 0, y: rect?.bottom ?? 0, items };
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
// Open the column menu from the keyboard (Alt+ArrowDown) at the focused column.
|
|
1337
|
+
if (columnMenu && sel.focus && e.altKey && e.key === 'ArrowDown') {
|
|
1338
|
+
e.preventDefault();
|
|
1339
|
+
const rect = document.getElementById(activeId ?? '')?.getBoundingClientRect();
|
|
1340
|
+
menu = { x: rect?.left ?? 0, y: rect?.bottom ?? 0, items: columnMenuItems(cols[sel.focus.c]) };
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
// Tree nav: ArrowRight expands a collapsed node, ArrowLeft collapses an
|
|
1344
|
+
// expanded one (treegrid pattern); otherwise arrows move normally.
|
|
1345
|
+
if (treeData && sel.focus && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
|
|
1346
|
+
const item = flat[sel.focus.r];
|
|
1347
|
+
if (item?.kind === 'data' && item.hasChildren) {
|
|
1348
|
+
const id = getRowId(item.row);
|
|
1349
|
+
const open = expandedRows.has(id);
|
|
1350
|
+
if (e.key === 'ArrowRight' && !open) {
|
|
1351
|
+
e.preventDefault();
|
|
1352
|
+
toggleExpand(id);
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
if (e.key === 'ArrowLeft' && open) {
|
|
1356
|
+
e.preventDefault();
|
|
1357
|
+
toggleExpand(id);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
730
1362
|
// Home/End (row, or whole grid with Ctrl/⌘); PageUp/PageDown by a viewport page.
|
|
731
1363
|
const f = sel.focus;
|
|
732
1364
|
if (f && (e.key === 'Home' || e.key === 'End' || e.key === 'PageUp' || e.key === 'PageDown')) {
|
|
@@ -753,7 +1385,10 @@
|
|
|
753
1385
|
}
|
|
754
1386
|
|
|
755
1387
|
$effect(() => {
|
|
756
|
-
const up = () =>
|
|
1388
|
+
const up = () => {
|
|
1389
|
+
dragging = false;
|
|
1390
|
+
if (filling) commitFill();
|
|
1391
|
+
};
|
|
757
1392
|
window.addEventListener('pointerup', up);
|
|
758
1393
|
return () => window.removeEventListener('pointerup', up);
|
|
759
1394
|
});
|
|
@@ -777,16 +1412,34 @@
|
|
|
777
1412
|
role="grid"
|
|
778
1413
|
tabindex="0"
|
|
779
1414
|
id={gid}
|
|
1415
|
+
aria-label={ariaLabel}
|
|
780
1416
|
aria-rowcount={rowCount + 1}
|
|
781
|
-
aria-colcount={cols.length +
|
|
1417
|
+
aria-colcount={cols.length + leadCols}
|
|
782
1418
|
aria-multiselectable="true"
|
|
783
1419
|
aria-activedescendant={activeId}
|
|
784
1420
|
style={themeStyle}
|
|
785
1421
|
bind:this={gridEl}
|
|
786
1422
|
onkeydown={onKeydown}
|
|
787
1423
|
>
|
|
1424
|
+
{#if quickFilter || columnsPanel}
|
|
1425
|
+
<div class="bo-toolbar">
|
|
1426
|
+
{#if quickFilter}
|
|
1427
|
+
<input
|
|
1428
|
+
class="bo-quickfilter"
|
|
1429
|
+
type="search"
|
|
1430
|
+
placeholder="Search…"
|
|
1431
|
+
aria-label="Quick filter"
|
|
1432
|
+
bind:value={quickText}
|
|
1433
|
+
/>
|
|
1434
|
+
{/if}
|
|
1435
|
+
{#if columnsPanel}
|
|
1436
|
+
<button class="bo-cols-toggle" type="button" onclick={openToolPanel}>⊟ Columns</button>
|
|
1437
|
+
{/if}
|
|
1438
|
+
</div>
|
|
1439
|
+
{/if}
|
|
788
1440
|
{#if headerGroups}
|
|
789
1441
|
<div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={pinned ? 'overflow:hidden;' : ''}>
|
|
1442
|
+
{#if expandable}<span class="expandcell" style={expandCellStyle(true)}></span>{/if}
|
|
790
1443
|
{#if rowSelection}<span class="selcell" style={selCellStyle(true)}></span>{/if}
|
|
791
1444
|
{#each headerGroups as g, gi (gi)}
|
|
792
1445
|
<span class="hg" class:empty={!g.label} style="flex:0 0 {g.width}px;width:{g.width}px;">{g.label}</span>
|
|
@@ -794,8 +1447,11 @@
|
|
|
794
1447
|
</div>
|
|
795
1448
|
{/if}
|
|
796
1449
|
<div class="head" role="row" aria-rowindex={1} bind:this={headEl} style={pinned ? 'overflow:hidden;' : ''}>
|
|
1450
|
+
{#if expandable}
|
|
1451
|
+
<span class="expandcell selhead" role="columnheader" aria-colindex={1} style={expandCellStyle(true)}></span>
|
|
1452
|
+
{/if}
|
|
797
1453
|
{#if rowSelection}
|
|
798
|
-
<span class="selcell selhead" role="columnheader" aria-colindex={1} style={selCellStyle(true)}>
|
|
1454
|
+
<span class="selcell selhead" role="columnheader" aria-colindex={1 + expOffset} style={selCellStyle(true)}>
|
|
799
1455
|
<input
|
|
800
1456
|
type="checkbox"
|
|
801
1457
|
class="rowcheck"
|
|
@@ -810,7 +1466,7 @@
|
|
|
810
1466
|
{/if}
|
|
811
1467
|
{#each cols as col, ci (ci)}
|
|
812
1468
|
<button
|
|
813
|
-
class="h"
|
|
1469
|
+
class="h {col.headerClass ?? ''}"
|
|
814
1470
|
class:right={isNumeric(col) || col.align === 'right'}
|
|
815
1471
|
class:sortable={isSortable(col)}
|
|
816
1472
|
class:dragging={ci === dragSrc}
|
|
@@ -818,7 +1474,7 @@
|
|
|
818
1474
|
style={headStyle(ci)}
|
|
819
1475
|
type="button"
|
|
820
1476
|
role="columnheader"
|
|
821
|
-
aria-colindex={ci + 1 +
|
|
1477
|
+
aria-colindex={ci + 1 + leadCols}
|
|
822
1478
|
draggable="true"
|
|
823
1479
|
aria-sort={isSortable(col) && sortInfo(col.key)
|
|
824
1480
|
? sortInfo(col.key)?.dir === 'asc'
|
|
@@ -855,6 +1511,40 @@
|
|
|
855
1511
|
{si?.dir === 'asc' ? '▲' : '▼'}{#if sorts.length > 1}<span class="ord">{si?.pos}</span>{/if}
|
|
856
1512
|
</span>
|
|
857
1513
|
{/if}
|
|
1514
|
+
{#if filterMenu && col.type !== 'sparkline' && col.filter !== false}
|
|
1515
|
+
<span
|
|
1516
|
+
class="funnel"
|
|
1517
|
+
class:on={isFilterActive(activeColumnFilters[col.key])}
|
|
1518
|
+
role="button"
|
|
1519
|
+
tabindex="-1"
|
|
1520
|
+
aria-label="Filter {col.header}"
|
|
1521
|
+
title="Filter {col.header}"
|
|
1522
|
+
onclick={(e) => openFilterMenu(col, e)}
|
|
1523
|
+
onkeydown={(e) => {
|
|
1524
|
+
if (e.key === 'Enter' || e.key === ' ') openFilterMenu(col, e);
|
|
1525
|
+
}}
|
|
1526
|
+
ondragstart={(e) => e.preventDefault()}
|
|
1527
|
+
draggable="false"
|
|
1528
|
+
>
|
|
1529
|
+
<svg viewBox="0 0 10 10" width="9" height="9" aria-hidden="true">
|
|
1530
|
+
<path d="M0 1h10L6 6v3L4 8V6z" fill="currentColor" />
|
|
1531
|
+
</svg>
|
|
1532
|
+
</span>
|
|
1533
|
+
{/if}
|
|
1534
|
+
{#if columnMenu}
|
|
1535
|
+
<span
|
|
1536
|
+
class="hmenu"
|
|
1537
|
+
role="button"
|
|
1538
|
+
tabindex="-1"
|
|
1539
|
+
aria-label="{col.header} menu"
|
|
1540
|
+
title="{col.header} menu"
|
|
1541
|
+
onclick={(e) => openColumnMenu(col, e)}
|
|
1542
|
+
onkeydown={(e) => {
|
|
1543
|
+
if (e.key === 'Enter' || e.key === ' ') openColumnMenu(col, e);
|
|
1544
|
+
}}
|
|
1545
|
+
ondragstart={(e) => e.preventDefault()}
|
|
1546
|
+
draggable="false">⋮</span>
|
|
1547
|
+
{/if}
|
|
858
1548
|
{#if isResizable(col, resizable)}
|
|
859
1549
|
<span
|
|
860
1550
|
class="grip"
|
|
@@ -873,6 +1563,7 @@
|
|
|
873
1563
|
|
|
874
1564
|
{#if filterRow && !source}
|
|
875
1565
|
<div class="filter-row" role="row" bind:this={filterRowEl} style={pinned ? 'overflow:hidden;' : ''}>
|
|
1566
|
+
{#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
|
|
876
1567
|
{#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
|
|
877
1568
|
{#each cols as col, ci (ci)}
|
|
878
1569
|
<span class="fr-cell" style={cellWidthStyle(ci)}>
|
|
@@ -901,6 +1592,7 @@
|
|
|
901
1592
|
<div class="pinned-top">
|
|
902
1593
|
{#each pinnedRows as prow, pi (getRowId(prow))}
|
|
903
1594
|
<div class="row pinrow {rowClass?.(prow) ?? ''}" role="row" aria-hidden="true" style="height:{baseH}px;{rowWidthStyle}">
|
|
1595
|
+
{#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
|
|
904
1596
|
{#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
|
|
905
1597
|
{#each cols as col, ci (ci)}
|
|
906
1598
|
<Cell
|
|
@@ -908,11 +1600,12 @@
|
|
|
908
1600
|
row={prow}
|
|
909
1601
|
r={-1 - pi}
|
|
910
1602
|
c={ci}
|
|
911
|
-
colIndex={ci + 1 +
|
|
1603
|
+
colIndex={ci + 1 + leadCols}
|
|
912
1604
|
cellId={`${gid}-pin${pi}-c${ci}`}
|
|
913
1605
|
cellSnippet={cell}
|
|
914
1606
|
pinned={pinned && layout.info[ci].pinned}
|
|
915
|
-
|
|
1607
|
+
pinSide={layout.info[ci].side ?? 'left'}
|
|
1608
|
+
pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
|
|
916
1609
|
width={pinned ? layout.info[ci].width : undefined}
|
|
917
1610
|
/>
|
|
918
1611
|
{/each}
|
|
@@ -921,7 +1614,13 @@
|
|
|
921
1614
|
</div>
|
|
922
1615
|
{/if}
|
|
923
1616
|
{#if rowCount === 0 && !controller?.loading}
|
|
924
|
-
<div class="empty">
|
|
1617
|
+
<div class="empty">{emptyMessage}</div>
|
|
1618
|
+
{/if}
|
|
1619
|
+
{#if loading}
|
|
1620
|
+
<div class="loading-overlay" aria-busy="true" aria-live="polite">
|
|
1621
|
+
<span class="spinner" aria-hidden="true"></span>
|
|
1622
|
+
<span class="loading-label">Loading…</span>
|
|
1623
|
+
</div>
|
|
925
1624
|
{/if}
|
|
926
1625
|
{#if stickyGroups.length > 0}
|
|
927
1626
|
<div class="sticky">
|
|
@@ -932,15 +1631,17 @@
|
|
|
932
1631
|
{/each}
|
|
933
1632
|
</div>
|
|
934
1633
|
{/if}
|
|
935
|
-
<div class="spacer" style="height:{total}px;{pinned ? `width:${layout.totalWidth +
|
|
1634
|
+
<div class="spacer" style="height:{total}px;{pinned ? `width:${layout.totalWidth + leadPx}px;` : ''}">
|
|
936
1635
|
{#each renderItems as item (item.vr)}
|
|
937
1636
|
{#if item.kind === 'group'}
|
|
938
1637
|
<div class="grouprow" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
|
|
1638
|
+
{#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
|
|
939
1639
|
{#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
|
|
940
1640
|
<GroupRow group={item.group} columns={cols} onToggle={toggleGroup} rowIndex={item.vr + 2} />
|
|
941
1641
|
</div>
|
|
942
1642
|
{:else if item.kind === 'skeleton'}
|
|
943
1643
|
<div class="row skeleton" role="row" aria-rowindex={item.vr + 2} aria-hidden="true" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
|
|
1644
|
+
{#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
|
|
944
1645
|
{#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
|
|
945
1646
|
{#each cols as col, ci (ci)}
|
|
946
1647
|
<span class="c" style={cellWidthStyle(ci)}><span class="skelbar"></span></span>
|
|
@@ -949,7 +1650,24 @@
|
|
|
949
1650
|
{:else}
|
|
950
1651
|
<!-- Row activation is keyboard-accessible at the grid level: Enter on the focused cell fires onRowClick (focus is via aria-activedescendant). -->
|
|
951
1652
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
952
|
-
<div class="row {rowClass?.(item.row) ?? ''}" class:alt={item.vr % 2 === 1} class:rowsel={rowSelection && isRowSelected(getRowId(item.row))} class:clickable={!!onRowClick} role="row" tabindex="-1" aria-rowindex={item.vr + 2} aria-selected={rowSelection ? isRowSelected(getRowId(item.row)) : undefined} style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}" onclick={(e) => onRowClick?.(item.row, e)}>
|
|
1653
|
+
<div class="row {rowClass?.(item.row) ?? ''}" class:alt={item.vr % 2 === 1} class:rowsel={rowSelection && isRowSelected(getRowId(item.row))} class:clickable={!!onRowClick} class:droptarget={reorderable && dropRowVr === item.vr && dragRowVr !== item.vr} role="row" tabindex="-1" aria-rowindex={item.vr + 2} aria-selected={rowSelection ? isRowSelected(getRowId(item.row)) : undefined} aria-level={treeData ? (item.depth ?? 0) + 1 : undefined} aria-expanded={treeData && item.hasChildren ? isExpanded(getRowId(item.row)) : undefined} style="top:{hm.offsetOf(item.vr)}px;height:{expandable ? baseH : hm.heightOf(item.vr)}px;{rowWidthStyle}" onclick={(e) => onRowClick?.(item.row, e)} oncontextmenu={(e) => openRowMenu(item.row, e)} ondragover={reorderable ? (e) => { if (dragRowVr < 0) return; e.preventDefault(); dropRowVr = item.vr; } : undefined} ondrop={reorderable ? (e) => { e.preventDefault(); onRowDrop(); } : undefined}>
|
|
1654
|
+
{#if expandable}
|
|
1655
|
+
<span class="expandcell" style={expandCellStyle(false)}>
|
|
1656
|
+
<button
|
|
1657
|
+
class="expand-toggle"
|
|
1658
|
+
type="button"
|
|
1659
|
+
aria-expanded={isExpanded(getRowId(item.row))}
|
|
1660
|
+
aria-label="Toggle detail"
|
|
1661
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
1662
|
+
onclick={(e) => {
|
|
1663
|
+
e.stopPropagation();
|
|
1664
|
+
toggleExpand(getRowId(item.row));
|
|
1665
|
+
}}
|
|
1666
|
+
>
|
|
1667
|
+
{isExpanded(getRowId(item.row)) ? '▾' : '▸'}
|
|
1668
|
+
</button>
|
|
1669
|
+
</span>
|
|
1670
|
+
{/if}
|
|
953
1671
|
{#if rowSelection}
|
|
954
1672
|
<span class="selcell" style={selCellStyle(false)}>
|
|
955
1673
|
<input
|
|
@@ -969,30 +1687,55 @@
|
|
|
969
1687
|
row={item.row}
|
|
970
1688
|
r={item.vr}
|
|
971
1689
|
c={ci}
|
|
972
|
-
colIndex={ci + 1 +
|
|
1690
|
+
colIndex={ci + 1 + leadCols}
|
|
973
1691
|
cellId={`${gid}-r${item.vr}-c${ci}`}
|
|
974
1692
|
cellSnippet={cell}
|
|
975
1693
|
selected={sel.contains(item.vr, ci)}
|
|
976
1694
|
focused={sel.isFocus(item.vr, ci)}
|
|
977
1695
|
pinned={pinned && layout.info[ci].pinned}
|
|
978
|
-
|
|
1696
|
+
pinSide={layout.info[ci].side ?? 'left'}
|
|
1697
|
+
pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
|
|
979
1698
|
width={pinned ? layout.info[ci].width : undefined}
|
|
980
1699
|
alt={item.vr % 2 === 1}
|
|
981
1700
|
editing={editing?.r === item.vr && editing?.c === ci}
|
|
1701
|
+
seed={editing?.r === item.vr && editing?.c === ci ? editSeed : null}
|
|
1702
|
+
fillCorner={fillHandle && !!sel.bounds && item.vr === sel.bounds.r1 && ci === sel.bounds.c1}
|
|
1703
|
+
fillpreview={inFillPreview(item.vr, ci)}
|
|
1704
|
+
{onFillStart}
|
|
1705
|
+
tree={treeData && ci === 0
|
|
1706
|
+
? {
|
|
1707
|
+
depth: item.depth ?? 0,
|
|
1708
|
+
hasChildren: item.hasChildren ?? false,
|
|
1709
|
+
expanded: isExpanded(getRowId(item.row)),
|
|
1710
|
+
onToggle: () => toggleExpand(getRowId(item.row)),
|
|
1711
|
+
}
|
|
1712
|
+
: undefined}
|
|
1713
|
+
dragHandle={reorderable && ci === 0
|
|
1714
|
+
? { onStart: () => (dragRowVr = item.vr), onEnd: () => (dragRowVr = -1) }
|
|
1715
|
+
: undefined}
|
|
982
1716
|
{onCellDown}
|
|
983
1717
|
{onCellEnter}
|
|
984
1718
|
onCellClick={onCellClick ? onCellClicked : undefined}
|
|
985
1719
|
onCellDblClick={startEdit}
|
|
986
1720
|
onEditCommit={(raw) => commitEdit(item.vr, ci, raw)}
|
|
987
|
-
onEditCancel={() =>
|
|
1721
|
+
onEditCancel={() => {
|
|
1722
|
+
editing = null;
|
|
1723
|
+
editSeed = null;
|
|
1724
|
+
}}
|
|
988
1725
|
/>
|
|
989
1726
|
{/each}
|
|
990
1727
|
</div>
|
|
1728
|
+
{#if expandable && detail && isExpanded(getRowId(item.row))}
|
|
1729
|
+
<div class="row-detail" style="top:{hm.offsetOf(item.vr) + baseH}px;height:{detailHeight}px;{rowWidthStyle}">
|
|
1730
|
+
{@render detail({ row: item.row })}
|
|
1731
|
+
</div>
|
|
1732
|
+
{/if}
|
|
991
1733
|
{/if}
|
|
992
1734
|
{/each}
|
|
993
1735
|
</div>
|
|
994
1736
|
{#if footerCells}
|
|
995
|
-
<div class="footer" role="row" style={pinned ? `width:${layout.totalWidth +
|
|
1737
|
+
<div class="footer" role="row" style={pinned ? `width:${layout.totalWidth + leadPx}px;` : ''}>
|
|
1738
|
+
{#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
|
|
996
1739
|
{#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
|
|
997
1740
|
{#each cols as col, ci (ci)}
|
|
998
1741
|
<span class="fcell" class:right={isNumeric(col)} style={cellWidthStyle(ci)}>
|
|
@@ -1004,6 +1747,42 @@
|
|
|
1004
1747
|
</div>
|
|
1005
1748
|
|
|
1006
1749
|
<AggregationBar result={agg} kinds={aggregations} />
|
|
1750
|
+
|
|
1751
|
+
{#if paged}
|
|
1752
|
+
<Pager page={currentPage} {pageCount} total={view.length} onGoto={goToPage} />
|
|
1753
|
+
{/if}
|
|
1754
|
+
|
|
1755
|
+
{#if menu}
|
|
1756
|
+
<RowMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => (menu = null)} />
|
|
1757
|
+
{/if}
|
|
1758
|
+
|
|
1759
|
+
{#if filterUi && FilterMenuComp}
|
|
1760
|
+
{@const Menu = FilterMenuComp}
|
|
1761
|
+
{@const key = filterUi.key}
|
|
1762
|
+
<Menu
|
|
1763
|
+
kind={filterUi.kind}
|
|
1764
|
+
header={filterUi.header}
|
|
1765
|
+
filter={activeColumnFilters[key] ?? null}
|
|
1766
|
+
values={filterUi.values}
|
|
1767
|
+
x={filterUi.x}
|
|
1768
|
+
y={filterUi.y}
|
|
1769
|
+
onApply={(f) => applyColumnFilter(key, f)}
|
|
1770
|
+
onClose={() => (filterUi = null)}
|
|
1771
|
+
/>
|
|
1772
|
+
{/if}
|
|
1773
|
+
|
|
1774
|
+
{#if panelXY && ToolPanelComp}
|
|
1775
|
+
{@const Panel = ToolPanelComp}
|
|
1776
|
+
<Panel
|
|
1777
|
+
columns={ordered.map((c) => ({ key: c.key, header: c.header }))}
|
|
1778
|
+
hidden={effectiveHidden}
|
|
1779
|
+
x={panelXY.x}
|
|
1780
|
+
y={panelXY.y}
|
|
1781
|
+
onToggle={toggleColumnVisible}
|
|
1782
|
+
onShowAll={showAllColumns}
|
|
1783
|
+
onClose={() => (panelXY = null)}
|
|
1784
|
+
/>
|
|
1785
|
+
{/if}
|
|
1007
1786
|
</div>
|
|
1008
1787
|
|
|
1009
1788
|
<style>
|
|
@@ -1038,6 +1817,41 @@
|
|
|
1038
1817
|
.grid:focus-visible {
|
|
1039
1818
|
border-color: var(--bo-sel-border);
|
|
1040
1819
|
}
|
|
1820
|
+
/* Built-in quick-filter toolbar (opt-in via `quickFilter`). */
|
|
1821
|
+
.bo-toolbar {
|
|
1822
|
+
display: flex;
|
|
1823
|
+
padding: 6px;
|
|
1824
|
+
border-bottom: 0.5px solid var(--bo-border);
|
|
1825
|
+
background: var(--bo-header-bg);
|
|
1826
|
+
}
|
|
1827
|
+
.bo-quickfilter {
|
|
1828
|
+
width: 100%;
|
|
1829
|
+
max-width: 260px;
|
|
1830
|
+
padding: 5px 9px;
|
|
1831
|
+
font: inherit;
|
|
1832
|
+
font-size: 12px;
|
|
1833
|
+
color: var(--bo-text);
|
|
1834
|
+
background: var(--bo-bg);
|
|
1835
|
+
border: 0.5px solid var(--bo-border);
|
|
1836
|
+
border-radius: 6px;
|
|
1837
|
+
}
|
|
1838
|
+
.bo-quickfilter::placeholder {
|
|
1839
|
+
color: var(--bo-text-dim);
|
|
1840
|
+
}
|
|
1841
|
+
.bo-cols-toggle {
|
|
1842
|
+
margin-left: auto;
|
|
1843
|
+
padding: 5px 11px;
|
|
1844
|
+
font: inherit;
|
|
1845
|
+
font-size: 12px;
|
|
1846
|
+
color: var(--bo-text);
|
|
1847
|
+
background: var(--bo-bg);
|
|
1848
|
+
border: 0.5px solid var(--bo-border);
|
|
1849
|
+
border-radius: 6px;
|
|
1850
|
+
cursor: pointer;
|
|
1851
|
+
}
|
|
1852
|
+
.bo-cols-toggle:hover {
|
|
1853
|
+
border-color: var(--bo-text-dim);
|
|
1854
|
+
}
|
|
1041
1855
|
|
|
1042
1856
|
/* Spanning header groups (row above the column headers). */
|
|
1043
1857
|
.head-groups {
|
|
@@ -1152,6 +1966,40 @@
|
|
|
1152
1966
|
color: var(--bo-text-dim);
|
|
1153
1967
|
font-variant-numeric: tabular-nums;
|
|
1154
1968
|
}
|
|
1969
|
+
/* Header filter funnel: a click target that opens the (lazy) filter menu. */
|
|
1970
|
+
.h .funnel {
|
|
1971
|
+
display: inline-flex;
|
|
1972
|
+
align-items: center;
|
|
1973
|
+
margin-left: 4px;
|
|
1974
|
+
color: var(--bo-text-dim);
|
|
1975
|
+
opacity: 0.55;
|
|
1976
|
+
cursor: pointer;
|
|
1977
|
+
transition: opacity 120ms, color 120ms;
|
|
1978
|
+
}
|
|
1979
|
+
.h .funnel:hover {
|
|
1980
|
+
opacity: 1;
|
|
1981
|
+
color: var(--bo-text);
|
|
1982
|
+
}
|
|
1983
|
+
.h .funnel.on {
|
|
1984
|
+
opacity: 1;
|
|
1985
|
+
color: var(--bo-up);
|
|
1986
|
+
}
|
|
1987
|
+
/* Header column-menu (⋮) trigger. */
|
|
1988
|
+
.h .hmenu {
|
|
1989
|
+
display: inline-flex;
|
|
1990
|
+
align-items: center;
|
|
1991
|
+
margin-left: 4px;
|
|
1992
|
+
font-size: 14px;
|
|
1993
|
+
line-height: 1;
|
|
1994
|
+
color: var(--bo-text-dim);
|
|
1995
|
+
opacity: 0.55;
|
|
1996
|
+
cursor: pointer;
|
|
1997
|
+
transition: opacity 120ms, color 120ms;
|
|
1998
|
+
}
|
|
1999
|
+
.h .hmenu:hover {
|
|
2000
|
+
opacity: 1;
|
|
2001
|
+
color: var(--bo-text);
|
|
2002
|
+
}
|
|
1155
2003
|
|
|
1156
2004
|
/* Per-column filter input row, under the header. */
|
|
1157
2005
|
.filter-row {
|
|
@@ -1199,6 +2047,40 @@
|
|
|
1199
2047
|
color: var(--bo-text-dim);
|
|
1200
2048
|
font-size: 13px;
|
|
1201
2049
|
}
|
|
2050
|
+
.loading-overlay {
|
|
2051
|
+
position: sticky;
|
|
2052
|
+
top: 0;
|
|
2053
|
+
left: 0;
|
|
2054
|
+
z-index: 7;
|
|
2055
|
+
display: flex;
|
|
2056
|
+
align-items: center;
|
|
2057
|
+
justify-content: center;
|
|
2058
|
+
gap: 10px;
|
|
2059
|
+
height: 100%;
|
|
2060
|
+
margin-bottom: -100%; /* overlay without consuming scroll height */
|
|
2061
|
+
color: var(--bo-text-dim);
|
|
2062
|
+
font-size: 13px;
|
|
2063
|
+
background: color-mix(in srgb, var(--bo-bg) 70%, transparent);
|
|
2064
|
+
backdrop-filter: blur(1px);
|
|
2065
|
+
}
|
|
2066
|
+
.spinner {
|
|
2067
|
+
width: 18px;
|
|
2068
|
+
height: 18px;
|
|
2069
|
+
border: 2px solid var(--bo-border);
|
|
2070
|
+
border-top-color: var(--bo-sel-border);
|
|
2071
|
+
border-radius: 50%;
|
|
2072
|
+
animation: bo-spin 0.7s linear infinite;
|
|
2073
|
+
}
|
|
2074
|
+
@keyframes bo-spin {
|
|
2075
|
+
to {
|
|
2076
|
+
transform: rotate(360deg);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
@media (prefers-reduced-motion: reduce) {
|
|
2080
|
+
.spinner {
|
|
2081
|
+
animation: none;
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
1202
2084
|
.spacer {
|
|
1203
2085
|
position: relative;
|
|
1204
2086
|
width: 100%;
|
|
@@ -1244,6 +2126,9 @@
|
|
|
1244
2126
|
.row.clickable {
|
|
1245
2127
|
cursor: pointer;
|
|
1246
2128
|
}
|
|
2129
|
+
.row.droptarget {
|
|
2130
|
+
box-shadow: inset 0 2px 0 var(--bo-sel-border);
|
|
2131
|
+
}
|
|
1247
2132
|
|
|
1248
2133
|
/* Pinned top rows: stick to the top of the viewport above the scroll. */
|
|
1249
2134
|
.pinned-top {
|
|
@@ -1289,6 +2174,38 @@
|
|
|
1289
2174
|
justify-content: flex-end;
|
|
1290
2175
|
}
|
|
1291
2176
|
|
|
2177
|
+
/* Master-detail: leading expand-toggle column + detail panel. */
|
|
2178
|
+
.expandcell {
|
|
2179
|
+
display: flex;
|
|
2180
|
+
align-items: center;
|
|
2181
|
+
justify-content: center;
|
|
2182
|
+
flex: 0 0 auto;
|
|
2183
|
+
}
|
|
2184
|
+
.expand-toggle {
|
|
2185
|
+
width: 20px;
|
|
2186
|
+
height: 20px;
|
|
2187
|
+
padding: 0;
|
|
2188
|
+
font-size: 11px;
|
|
2189
|
+
line-height: 1;
|
|
2190
|
+
color: var(--bo-text-dim);
|
|
2191
|
+
background: transparent;
|
|
2192
|
+
border: 0;
|
|
2193
|
+
border-radius: 4px;
|
|
2194
|
+
cursor: pointer;
|
|
2195
|
+
}
|
|
2196
|
+
.expand-toggle:hover {
|
|
2197
|
+
color: var(--bo-text);
|
|
2198
|
+
background: var(--bo-row-hover);
|
|
2199
|
+
}
|
|
2200
|
+
.row-detail {
|
|
2201
|
+
position: absolute;
|
|
2202
|
+
left: 0;
|
|
2203
|
+
overflow: auto;
|
|
2204
|
+
background: var(--bo-row-a);
|
|
2205
|
+
border-bottom: 0.5px solid var(--bo-border);
|
|
2206
|
+
box-shadow: inset 0 1px 0 var(--bo-border);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
1292
2209
|
/* Leading checkbox column (row selection). */
|
|
1293
2210
|
.selcell {
|
|
1294
2211
|
display: flex;
|