bo-grid 0.2.0 → 0.8.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.
@@ -18,6 +18,14 @@
18
18
  import { moveIndex } from './reorder';
19
19
  import { parseClipboard, isSingleCell } from './clipboard';
20
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';
21
29
  import type { RowSource } from './source';
22
30
  import { RowSourceController } from './source.svelte';
23
31
  import Cell from './Cell.svelte';
@@ -42,15 +50,23 @@
42
50
  rowSelection = false,
43
51
  onRowSelectionChange,
44
52
  hiddenColumns = [],
53
+ onColumnVisibilityChange,
54
+ columnMenu = false,
55
+ columnsPanel = false,
45
56
  rowClass,
46
57
  getRowId = (r: GridRow) => r.id,
47
58
  onRowClick,
48
59
  sort,
49
60
  onSortChange,
61
+ columnFilters,
62
+ onFilterChange,
50
63
  footer = false,
51
64
  onCellClick,
52
65
  pinnedRows = [],
53
66
  filterRow = false,
67
+ filterMenu = false,
68
+ quickFilter = false,
69
+ fillHandle = false,
54
70
  emptyMessage = 'No matching rows',
55
71
  loading = false,
56
72
  rowMenu,
@@ -83,8 +99,19 @@
83
99
  /** Called with the selected row ids whenever the row-selection set changes. */
84
100
  onRowSelectionChange?: (selectedIds: Array<string | number>) => void;
85
101
  /** Column keys to hide (controlled). Build your own column-picker UI and
86
- 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. */
87
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;
88
115
  /** Return extra CSS class(es) for a data row (e.g. to colour by value).
89
116
  Style them via `:global(.your-class)` since rows live inside the grid. */
90
117
  rowClass?: (row: GridRow) => string | undefined;
@@ -100,6 +127,12 @@
100
127
  sort?: SortState[];
101
128
  /** Called with the new sort order whenever a header is clicked. */
102
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;
103
136
  /** Show a pinned totals row: each column with a `groupAgg` shows that
104
137
  aggregate over all (filtered) rows. In-memory mode only. Default false. */
105
138
  footer?: boolean;
@@ -115,6 +148,20 @@
115
148
  /** Show a per-column filter input row under the header. Rows must match every
116
149
  non-empty column filter (AND). In-memory mode only. Default false. */
117
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;
118
165
  /** Message shown when there are no rows. Default 'No matching rows'. */
119
166
  emptyMessage?: string;
120
167
  /** Show a loading overlay over the grid (for consumer-driven async work in
@@ -184,6 +231,10 @@
184
231
 
185
232
  const sel = new Selection();
186
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;
187
238
  let editing = $state<{ r: number; c: number } | null>(null);
188
239
  // When editing was opened by typing a character (type-to-edit), the editor
189
240
  // seeds its input with it; null means edit the existing value (dblclick/Enter).
@@ -194,24 +245,74 @@
194
245
  editSeed = seed;
195
246
  editing = { r, c };
196
247
  }
197
- // Coerce + validate a raw string for cell (r,c) and emit onCellEdit.
198
- // Returns true if a value was written, false if rejected (not editable,
199
- // missing row, or invalid number). Shared by inline edit and paste.
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.
200
276
  function writeCell(r: number, c: number, raw: string): boolean {
201
277
  const col = cols[c];
202
278
  if (!col || !isEditable(col)) return false;
203
279
  const row = dataAt(r);
204
280
  if (!row) return false;
205
281
  let value: string | number = raw;
206
- if (isNumeric(col)) {
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)) {
207
288
  const n = Number(raw);
208
289
  if (!Number.isFinite(n)) return false; // reject invalid number, keep old value
209
290
  value = n;
210
291
  }
211
292
  if (col.validate && !col.validate(value, row)) return false; // consumer rejected it
293
+ const old = (row[col.key] ?? '') as string | number;
212
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
+ }
213
300
  return true;
214
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
+ }
215
316
 
216
317
  function commitEdit(r: number, c: number, raw: string) {
217
318
  editing = null;
@@ -233,6 +334,19 @@
233
334
  // Per-column filter text (filterRow), keyed by column key.
234
335
  let colFilters = $state<Record<string, string>>({});
235
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
+
236
350
  // Whole-row selection (opt-in), keyed by row id so it survives sort/filter.
237
351
  // Plain Set + a version counter for reactivity (same pattern as `collapsed`).
238
352
  const SEL_W = 40; // checkbox column width (px)
@@ -273,17 +387,30 @@
273
387
  }
274
388
 
275
389
  const ordered = $derived(order.length === columns.length ? order.map((i) => columns[i]) : columns);
276
- // Drop hidden columns (controlled via `hiddenColumns`). Applied after ordering
277
- // so `order` stays indexed over the full column set.
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.
278
398
  const visible = $derived(
279
- hiddenColumns.length ? ordered.filter((c) => !hiddenColumns.includes(c.key)) : ordered,
399
+ effectiveHidden.length ? ordered.filter((c) => !effectiveHidden.includes(c.key)) : ordered,
280
400
  );
281
401
  // Apply any resize overrides (turns the dragged column fixed-width), then
282
402
  // pin-arrange. Both are no-ops by default, so the grid stays fit-to-width.
283
403
  const sized = $derived(applyWidths(visible, widths));
284
- // Pin-arrangement: pinned columns move to the front and get sticky offsets.
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.
285
412
  // When nothing is pinned this is a no-op and the grid stays fit-to-width.
286
- const layout = $derived(arrangePinned(sized));
413
+ const layout = $derived(arrangePinned(pinnedSized));
287
414
  const cols = $derived(layout.columns);
288
415
  const pinned = $derived(layout.anyPinned);
289
416
 
@@ -422,6 +549,105 @@
422
549
  });
423
550
  });
424
551
 
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
+
425
651
  let resize: { key: string; startX: number; startW: number; min?: number; max?: number } | null = null;
426
652
  let justResized = false;
427
653
 
@@ -503,16 +729,22 @@
503
729
  const base = rows;
504
730
  const allCols = columns;
505
731
  const f = filter.trim().toLowerCase();
732
+ const q = quickText.trim().toLowerCase();
506
733
  const s = sorts;
507
- // Active per-column filters (filterRow): [key, lowercased needle] pairs.
508
- const colF = Object.entries(colFilters)
509
- .map(([k, v]) => [k, v.trim().toLowerCase()] as const)
510
- .filter(([, v]) => v.length > 0);
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;
511
742
  return untrack(() => {
512
743
  let r = base;
513
744
  if (f) r = r.filter((row) => allCols.some((c) => String(row[c.key] ?? '').toLowerCase().includes(f)));
514
- if (colF.length > 0) {
515
- r = r.filter((row) => colF.every(([k, v]) => String(row[k] ?? '').toLowerCase().includes(v)));
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));
516
748
  }
517
749
  if (s.length > 0) {
518
750
  const colOf = (k: string) => allCols.find((c) => c.key === k);
@@ -703,7 +935,8 @@
703
935
  const range = { start, end: start + visibleCount };
704
936
  const s = sorts;
705
937
  const f = filter;
706
- void ctrl.fetch(range, s, f);
938
+ const cf = activeColumnFilters;
939
+ void ctrl.fetch(range, s, f, cf);
707
940
  });
708
941
 
709
942
  function dataAt(r: number): GridRow | null {
@@ -755,7 +988,58 @@
755
988
  }
756
989
 
757
990
  function onCellEnter(r: number, c: number) {
758
- if (dragging) sel.extendTo(r, c);
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 };
759
1043
  }
760
1044
 
761
1045
  // Right-click row menu (floating).
@@ -781,6 +1065,96 @@
781
1065
  };
782
1066
  });
783
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) };
1156
+ }
1157
+
784
1158
  function onCellClicked(r: number, c: number, e: MouseEvent) {
785
1159
  if (!onCellClick) return;
786
1160
  const row = dataAt(r);
@@ -850,18 +1224,20 @@
850
1224
  const c0 = anchor.c0;
851
1225
  const rSpan = single ? anchor.r1 - anchor.r0 + 1 : grid.length;
852
1226
  let wrote = 0;
853
- for (let dr = 0; dr < rSpan; dr++) {
854
- const r = r0 + dr;
855
- if (r > rowCount - 1) break;
856
- const srcRow = single ? grid[0] : grid[dr];
857
- const cSpan = single ? anchor.c1 - anchor.c0 + 1 : srcRow.length;
858
- for (let dc = 0; dc < cSpan; dc++) {
859
- const c = c0 + dc;
860
- if (c > cols.length - 1) break;
861
- const raw = single ? grid[0][0] : (srcRow[dc] ?? '');
862
- if (writeCell(r, c, raw)) wrote++;
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
+ }
863
1239
  }
864
- }
1240
+ });
865
1241
  // Surface the pasted region as the new selection so it's visible.
866
1242
  if (wrote > 0) {
867
1243
  const rEnd = Math.min(r0 + rSpan - 1, rowCount - 1);
@@ -907,12 +1283,26 @@
907
1283
  if (!mod && !e.altKey && e.key.length === 1 && e.key !== ' ' && sel.focus && !editing) {
908
1284
  const f = sel.focus;
909
1285
  const col = cols[f.c];
910
- if (isEditable(col) && !(col.options && col.options.length) && dataAt(f.r)) {
1286
+ if (isEditable(col) && col.type !== 'date' && !(col.options && col.options.length) && dataAt(f.r)) {
911
1287
  e.preventDefault();
912
1288
  startEdit(f.r, f.c, e.key);
913
1289
  return;
914
1290
  }
915
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
+ }
916
1306
  if (mod && e.key.toLowerCase() === 'a') {
917
1307
  e.preventDefault();
918
1308
  sel.selectAll(rowCount, cols.length);
@@ -943,6 +1333,13 @@
943
1333
  return;
944
1334
  }
945
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
+ }
946
1343
  // Tree nav: ArrowRight expands a collapsed node, ArrowLeft collapses an
947
1344
  // expanded one (treegrid pattern); otherwise arrows move normally.
948
1345
  if (treeData && sel.focus && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
@@ -988,7 +1385,10 @@
988
1385
  }
989
1386
 
990
1387
  $effect(() => {
991
- const up = () => (dragging = false);
1388
+ const up = () => {
1389
+ dragging = false;
1390
+ if (filling) commitFill();
1391
+ };
992
1392
  window.addEventListener('pointerup', up);
993
1393
  return () => window.removeEventListener('pointerup', up);
994
1394
  });
@@ -1021,6 +1421,22 @@
1021
1421
  bind:this={gridEl}
1022
1422
  onkeydown={onKeydown}
1023
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}
1024
1440
  {#if headerGroups}
1025
1441
  <div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={pinned ? 'overflow:hidden;' : ''}>
1026
1442
  {#if expandable}<span class="expandcell" style={expandCellStyle(true)}></span>{/if}
@@ -1095,6 +1511,40 @@
1095
1511
  {si?.dir === 'asc' ? '▲' : '▼'}{#if sorts.length > 1}<span class="ord">{si?.pos}</span>{/if}
1096
1512
  </span>
1097
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}
1098
1548
  {#if isResizable(col, resizable)}
1099
1549
  <span
1100
1550
  class="grip"
@@ -1249,6 +1699,9 @@
1249
1699
  alt={item.vr % 2 === 1}
1250
1700
  editing={editing?.r === item.vr && editing?.c === ci}
1251
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}
1252
1705
  tree={treeData && ci === 0
1253
1706
  ? {
1254
1707
  depth: item.depth ?? 0,
@@ -1302,6 +1755,34 @@
1302
1755
  {#if menu}
1303
1756
  <RowMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => (menu = null)} />
1304
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}
1305
1786
  </div>
1306
1787
 
1307
1788
  <style>
@@ -1322,6 +1803,15 @@
1322
1803
  --bo-header-h: var(--bo-grid-header-h, 28px);
1323
1804
  --bo-mono: var(--bo-grid-mono, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace);
1324
1805
  --bo-sans: var(--bo-grid-sans, Inter, "Segoe UI", system-ui, sans-serif);
1806
+ /* Layout/density tokens (override via --bo-grid-* for a custom look). */
1807
+ --bo-radius: var(--bo-grid-radius, 8px);
1808
+ --bo-font-size: var(--bo-grid-font-size, 13px);
1809
+ --bo-cell-pad: var(--bo-grid-cell-pad, 8px);
1810
+
1811
+ /* Theme native controls (checkboxes, date pickers, number spinners, search
1812
+ clear buttons, scrollbars) and accent fills to match the grid theme. */
1813
+ color-scheme: var(--bo-grid-scheme, dark);
1814
+ accent-color: var(--bo-up);
1325
1815
 
1326
1816
  display: flex;
1327
1817
  flex-direction: column;
@@ -1329,13 +1819,48 @@
1329
1819
  font-family: var(--bo-sans);
1330
1820
  background: var(--bo-bg);
1331
1821
  border: 0.5px solid var(--bo-border);
1332
- border-radius: 8px;
1822
+ border-radius: var(--bo-radius);
1333
1823
  overflow: hidden;
1334
1824
  outline: none;
1335
1825
  }
1336
1826
  .grid:focus-visible {
1337
1827
  border-color: var(--bo-sel-border);
1338
1828
  }
1829
+ /* Built-in quick-filter toolbar (opt-in via `quickFilter`). */
1830
+ .bo-toolbar {
1831
+ display: flex;
1832
+ padding: 6px;
1833
+ border-bottom: 0.5px solid var(--bo-border);
1834
+ background: var(--bo-header-bg);
1835
+ }
1836
+ .bo-quickfilter {
1837
+ width: 100%;
1838
+ max-width: 260px;
1839
+ padding: 5px 9px;
1840
+ font: inherit;
1841
+ font-size: 12px;
1842
+ color: var(--bo-text);
1843
+ background: var(--bo-bg);
1844
+ border: 0.5px solid var(--bo-border);
1845
+ border-radius: 6px;
1846
+ }
1847
+ .bo-quickfilter::placeholder {
1848
+ color: var(--bo-text-dim);
1849
+ }
1850
+ .bo-cols-toggle {
1851
+ margin-left: auto;
1852
+ padding: 5px 11px;
1853
+ font: inherit;
1854
+ font-size: 12px;
1855
+ color: var(--bo-text);
1856
+ background: var(--bo-bg);
1857
+ border: 0.5px solid var(--bo-border);
1858
+ border-radius: 6px;
1859
+ cursor: pointer;
1860
+ }
1861
+ .bo-cols-toggle:hover {
1862
+ border-color: var(--bo-text-dim);
1863
+ }
1339
1864
 
1340
1865
  /* Spanning header groups (row above the column headers). */
1341
1866
  .head-groups {
@@ -1377,7 +1902,7 @@
1377
1902
  display: flex;
1378
1903
  align-items: center;
1379
1904
  gap: 4px;
1380
- padding: 0 8px;
1905
+ padding: 0 var(--bo-cell-pad, 8px);
1381
1906
  min-width: 0;
1382
1907
  font: inherit;
1383
1908
  font-size: 11px;
@@ -1450,6 +1975,40 @@
1450
1975
  color: var(--bo-text-dim);
1451
1976
  font-variant-numeric: tabular-nums;
1452
1977
  }
1978
+ /* Header filter funnel: a click target that opens the (lazy) filter menu. */
1979
+ .h .funnel {
1980
+ display: inline-flex;
1981
+ align-items: center;
1982
+ margin-left: 4px;
1983
+ color: var(--bo-text-dim);
1984
+ opacity: 0.55;
1985
+ cursor: pointer;
1986
+ transition: opacity 120ms, color 120ms;
1987
+ }
1988
+ .h .funnel:hover {
1989
+ opacity: 1;
1990
+ color: var(--bo-text);
1991
+ }
1992
+ .h .funnel.on {
1993
+ opacity: 1;
1994
+ color: var(--bo-up);
1995
+ }
1996
+ /* Header column-menu (⋮) trigger. */
1997
+ .h .hmenu {
1998
+ display: inline-flex;
1999
+ align-items: center;
2000
+ margin-left: 4px;
2001
+ font-size: 14px;
2002
+ line-height: 1;
2003
+ color: var(--bo-text-dim);
2004
+ opacity: 0.55;
2005
+ cursor: pointer;
2006
+ transition: opacity 120ms, color 120ms;
2007
+ }
2008
+ .h .hmenu:hover {
2009
+ opacity: 1;
2010
+ color: var(--bo-text);
2011
+ }
1453
2012
 
1454
2013
  /* Per-column filter input row, under the header. */
1455
2014
  .filter-row {
@@ -1487,6 +2046,26 @@
1487
2046
  overflow-y: auto;
1488
2047
  overflow-x: hidden;
1489
2048
  user-select: none;
2049
+ /* Thin, themed scrollbars (Firefox) — Chromium/Safari below. */
2050
+ scrollbar-width: thin;
2051
+ scrollbar-color: color-mix(in srgb, var(--bo-text-dim) 55%, transparent) transparent;
2052
+ }
2053
+ .viewport::-webkit-scrollbar {
2054
+ width: 10px;
2055
+ height: 10px;
2056
+ }
2057
+ .viewport::-webkit-scrollbar-thumb {
2058
+ background: color-mix(in srgb, var(--bo-text-dim) 45%, transparent);
2059
+ border: 2px solid transparent;
2060
+ background-clip: padding-box;
2061
+ border-radius: 6px;
2062
+ }
2063
+ .viewport::-webkit-scrollbar-thumb:hover {
2064
+ background: color-mix(in srgb, var(--bo-text-dim) 70%, transparent);
2065
+ background-clip: padding-box;
2066
+ }
2067
+ .viewport::-webkit-scrollbar-corner {
2068
+ background: transparent;
1490
2069
  }
1491
2070
  .empty {
1492
2071
  position: absolute;