bo-grid 0.2.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 CHANGED
@@ -6,7 +6,8 @@ A free alternative to the heavyweight grids that paywall these features.
6
6
 
7
7
  **[Live demo](https://bonguynvan.github.io/bo-grid/)** ·
8
8
  **[API reference](https://bonguynvan.github.io/bo-grid/api.html)** ·
9
- **[Benchmarks](./BENCHMARKS.md)**
9
+ **[Benchmarks](./BENCHMARKS.md)** ·
10
+ **[Roadmap](./ROADMAP.md)**
10
11
 
11
12
  The demo is a small gallery of nine grid types — a realtime **Trading desk**, a
12
13
  grouped **Portfolio** with subtotals and pivot, a general-purpose editable
@@ -145,9 +146,27 @@ to opt out. Sorting is a snapshot — rows hold position while their values upda
145
146
  in place (trading-grid behaviour), so a realtime feed never reshuffles the view.
146
147
 
147
148
  Pass a `filter` string to quick-filter rows (matches across column values). Drive
148
- it from your own search input — the grid stays presentation-only. Set `filterRow`
149
- to add a row of **per-column filter inputs** under the header (rows must match
150
- every non-empty column filter; in-memory mode).
149
+ it from your own search input — or set `quickFilter` for a **built-in search box**
150
+ above the grid. Set `filterRow` to add a row of **per-column filter inputs** under
151
+ the header (rows must match every non-empty column filter; in-memory mode).
152
+
153
+ For richer filtering, set `filterMenu` to add a **funnel to each column header**.
154
+ Clicking it opens a menu whose control matches the column type — text
155
+ (contains / equals / starts / ends), number (`=, ≠, <, ≤, >, ≥, between`), or date
156
+ (before / after / on / between). Set `col.filter: 'set'` for a **set filter** — a
157
+ searchable checkbox list of the column's distinct values (All / None). The menu
158
+ is lazy-loaded on first open, so it costs nothing until used; disable it per
159
+ column with `col.filter: false`:
160
+
161
+ ```svelte
162
+ <Grid {rows} {columns} height={640} filterMenu />
163
+ ```
164
+
165
+ Filtering is uncontrolled by default. To **own the filter state** (persist it, set
166
+ initial filters, sync to the URL), pass a controlled `columnFilters` map and
167
+ handle `onFilterChange` — mirrors controlled `sort`. In **server (`source`) mode**
168
+ the filter menu still works: text/number/date filters are delegated to your
169
+ `RowSource` via `params.columnFilters` (set filters need in-memory data).
151
170
 
152
171
  Sorting is uncontrolled by default. To own it (persist it, set an initial sort,
153
172
  or sync to the URL), pass a controlled `sort` array and handle `onSortChange`:
@@ -171,6 +190,13 @@ values flow through the same validation as inline editing — non-editable colum
171
190
  and invalid numbers are skipped — and each accepted cell emits `onCellEdit`, so
172
191
  paste only does anything when you've wired that callback.
173
192
 
193
+ Set `fillHandle` for an **Excel-style fill handle**: the selection grows a small
194
+ square at its bottom-right corner; drag it down or right to copy the selected
195
+ value(s) across the extended range (editable columns; multi-cell selections tile).
196
+
197
+ Edits, paste and fill are **undoable** with <kbd>Ctrl/⌘</kbd>+<kbd>Z</kbd> (redo
198
+ with <kbd>Ctrl/⌘</kbd>+<kbd>Y</kbd>). A paste or fill undoes as a single step.
199
+
174
200
  When more than one cell is selected, a footer bar shows live **Sum / Avg / Count /
175
201
  Min / Max** over the numeric cells in the range — and it keeps updating as a
176
202
  realtime feed ticks. Choose which stats to show:
@@ -327,6 +353,19 @@ your own column-picker UI and drive the prop; the grid stays presentation-only:
327
353
  <Grid {rows} {columns} hiddenColumns={['bonus', 'rating']} height={640} />
328
354
  ```
329
355
 
356
+ Or set `columnMenu` for a **per-column header menu** (a ⋮ trigger, or
357
+ <kbd>Alt</kbd>+<kbd>↓</kbd> on the focused column) with **sort**, **pin**
358
+ (left/right/unpin), **Autosize** (fit to content), and **Hide column** actions,
359
+ and `columnsPanel` for a **"Columns" button** that opens a checklist to toggle
360
+ visibility (and restore hidden columns). Runtime hide/pin compose with
361
+ `hiddenColumns` / `col.pinned`, persist via `persistKey`, and hide reports through
362
+ `onColumnVisibilityChange`:
363
+
364
+ ```svelte
365
+ <Grid {rows} {columns} columnMenu height={640}
366
+ onColumnVisibilityChange={(hidden) => (myHidden = hidden)} />
367
+ ```
368
+
330
369
  ## Header groups
331
370
 
332
371
  Give columns a `group` label to render a spanning parent header over consecutive
@@ -407,7 +446,9 @@ is keyboard-accessible: the <kbd>ContextMenu</kbd> key (or
407
446
  Mark a column `editable: true`. Double-click a cell (or press <kbd>Enter</kbd> on
408
447
  the focused cell) to edit; <kbd>Enter</kbd>/blur commits, <kbd>Esc</kbd> cancels.
409
448
  Or just start typing — a printable key on a focused editable cell opens the
410
- editor seeded with that character (Excel-style type-to-edit). The grid is
449
+ editor seeded with that character (Excel-style type-to-edit). The editor matches
450
+ the column type: numeric columns get a number input, `date` columns a native date
451
+ picker, `options` columns a `<select>`, everything else a text input. The grid is
411
452
  controlled, so it reports the change via `onCellEdit` — update your own row data
412
453
  there:
413
454
 
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { ColumnDef, GridRow } from './column';
4
- import { formatCell, colStyle, candlesOf } from './column';
4
+ import { formatCell, colStyle, candlesOf, isNumeric } from './column';
5
5
  import { heatColor } from './heatmap';
6
6
  import Sparkline from '../sparkline/Sparkline.svelte';
7
7
 
@@ -19,6 +19,8 @@
19
19
  alt = false,
20
20
  editing = false,
21
21
  seed = null,
22
+ fillCorner = false,
23
+ fillpreview = false,
22
24
  colIndex,
23
25
  cellId,
24
26
  cellSnippet,
@@ -30,6 +32,7 @@
30
32
  onCellDblClick,
31
33
  onEditCommit,
32
34
  onEditCancel,
35
+ onFillStart,
33
36
  }: {
34
37
  col: ColumnDef;
35
38
  row: GridRow;
@@ -47,6 +50,10 @@
47
50
  /** Type-to-edit seed: when set, the editor opens pre-filled with this string
48
51
  (the character that triggered the edit) instead of the current value. */
49
52
  seed?: string | null;
53
+ /** Show the fill handle (this cell is the selection's bottom-right corner). */
54
+ fillCorner?: boolean;
55
+ /** This cell is inside the in-progress fill drag's preview range. */
56
+ fillpreview?: boolean;
50
57
  colIndex?: number;
51
58
  cellId?: string;
52
59
  cellSnippet?: Snippet<[{ row: GridRow; column: ColumnDef; value: unknown }]>;
@@ -60,11 +67,14 @@
60
67
  onCellDblClick?: (r: number, c: number) => void;
61
68
  onEditCommit?: (raw: string) => void;
62
69
  onEditCancel?: () => void;
70
+ onFillStart?: () => void;
63
71
  } = $props();
64
72
 
65
73
  let cancelled = false;
66
74
  function focusSelect(node: HTMLInputElement) {
67
75
  node.focus();
76
+ // Only text/search inputs support text selection; number/date inputs throw.
77
+ if (node.type !== 'text' && node.type !== 'search') return;
68
78
  // Type-to-edit: keep the seeded character and put the caret after it;
69
79
  // otherwise select the whole value so the next keystroke replaces it.
70
80
  if (seed != null) node.setSelectionRange(node.value.length, node.value.length);
@@ -95,6 +105,14 @@
95
105
  // goes through the $state getter — fine-grained reactivity is preserved even
96
106
  // though the key is only known at runtime.
97
107
  const value = $derived(row[col.key]);
108
+ // Typed inline editor: date columns edit with a date picker, numeric columns
109
+ // with a numeric input; everything else stays a text input.
110
+ const editorType = $derived(col.type === 'date' ? 'date' : isNumeric(col) ? 'number' : 'text');
111
+ const editorValue = $derived(
112
+ col.type === 'date' && Number.isFinite(Number(value))
113
+ ? new Date(Number(value)).toISOString().slice(0, 10)
114
+ : String(value ?? ''),
115
+ );
98
116
  const kind = $derived(col.type === 'text' ? 'text' : col.type === 'sparkline' ? 'spark' : 'num');
99
117
  // Optional per-column cell class (static string or value/row function).
100
118
  const extraClass = $derived(
@@ -129,6 +147,7 @@
129
147
  class:neg={col.type === 'percent' && Number(value) < 0}
130
148
  class:sel={selected}
131
149
  class:focus={focused}
150
+ class:fillpreview={fillpreview}
132
151
  style={cellStyle()}
133
152
  role="gridcell"
134
153
  tabindex="-1"
@@ -191,7 +210,8 @@
191
210
  {:else if editing}
192
211
  <input
193
212
  class="bo-edit"
194
- value={seed ?? String(value ?? '')}
213
+ type={editorType}
214
+ value={seed ?? editorValue}
195
215
  use:focusSelect
196
216
  onkeydown={onEditKey}
197
217
  onblur={onEditBlur}
@@ -212,6 +232,18 @@
212
232
  {:else}
213
233
  {formatCell(col, value, row)}
214
234
  {/if}
235
+ {#if fillCorner}
236
+ <span
237
+ class="fill-handle"
238
+ role="button"
239
+ tabindex="-1"
240
+ aria-label="Fill"
241
+ onpointerdown={(e) => {
242
+ e.stopPropagation();
243
+ onFillStart?.();
244
+ }}
245
+ ></span>
246
+ {/if}
215
247
  </span>
216
248
 
217
249
  <style>
@@ -318,6 +350,22 @@
318
350
  inset 0 0 0 1000px var(--bo-sel-fill),
319
351
  inset 0 0 0 1px var(--bo-sel-border);
320
352
  }
353
+ /* Fill: a draggable square at the selection's corner + the drag preview. */
354
+ .fill-handle {
355
+ position: absolute;
356
+ right: -3px;
357
+ bottom: -3px;
358
+ width: 7px;
359
+ height: 7px;
360
+ background: var(--bo-sel-border);
361
+ border: 1px solid var(--bo-bg);
362
+ cursor: crosshair;
363
+ z-index: 4;
364
+ touch-action: none;
365
+ }
366
+ .c.fillpreview {
367
+ box-shadow: inset 0 0 0 1px var(--bo-sel-border);
368
+ }
321
369
 
322
370
  .flash {
323
371
  animation: flash 0.3s linear;
@@ -17,6 +17,10 @@ type $$ComponentProps = {
17
17
  /** Type-to-edit seed: when set, the editor opens pre-filled with this string
18
18
  (the character that triggered the edit) instead of the current value. */
19
19
  seed?: string | null;
20
+ /** Show the fill handle (this cell is the selection's bottom-right corner). */
21
+ fillCorner?: boolean;
22
+ /** This cell is inside the in-progress fill drag's preview range. */
23
+ fillpreview?: boolean;
20
24
  colIndex?: number;
21
25
  cellId?: string;
22
26
  cellSnippet?: Snippet<[{
@@ -42,6 +46,7 @@ type $$ComponentProps = {
42
46
  onCellDblClick?: (r: number, c: number) => void;
43
47
  onEditCommit?: (raw: string) => void;
44
48
  onEditCancel?: () => void;
49
+ onFillStart?: () => void;
45
50
  };
46
51
  declare const Cell: import("svelte").Component<$$ComponentProps, {}, "">;
47
52
  type Cell = ReturnType<typeof Cell>;
@@ -0,0 +1,263 @@
1
+ <script lang="ts">
2
+ // Floating header filter menu. Lazy-loaded by Grid (dynamic import) so it
3
+ // stays out of the core bundle until a filter is opened. Presentation-only:
4
+ // the parent owns open/close + position and applies the resulting filter.
5
+ import { untrack } from 'svelte';
6
+ import {
7
+ isFilterActive,
8
+ type ColumnFilter,
9
+ type FilterKind,
10
+ type TextOp,
11
+ type NumberOp,
12
+ type DateOp,
13
+ } from './filtering';
14
+
15
+ let {
16
+ kind,
17
+ header,
18
+ filter,
19
+ values = [],
20
+ x,
21
+ y,
22
+ onApply,
23
+ onClose,
24
+ }: {
25
+ kind: FilterKind;
26
+ header: string;
27
+ filter: ColumnFilter | null;
28
+ /** Distinct column values for a set filter's checklist. */
29
+ values?: string[];
30
+ x: number;
31
+ y: number;
32
+ onApply: (f: ColumnFilter | null) => void;
33
+ onClose: () => void;
34
+ } = $props();
35
+
36
+ const TEXT_OPS: Array<{ op: TextOp; label: string }> = [
37
+ { op: 'contains', label: 'Contains' },
38
+ { op: 'notContains', label: 'Not contains' },
39
+ { op: 'equals', label: 'Equals' },
40
+ { op: 'starts', label: 'Starts with' },
41
+ { op: 'ends', label: 'Ends with' },
42
+ ];
43
+ const NUMBER_OPS: Array<{ op: NumberOp; label: string }> = [
44
+ { op: 'eq', label: '=' },
45
+ { op: 'ne', label: '≠' },
46
+ { op: 'lt', label: '<' },
47
+ { op: 'le', label: '≤' },
48
+ { op: 'gt', label: '>' },
49
+ { op: 'ge', label: '≥' },
50
+ { op: 'between', label: 'Between' },
51
+ ];
52
+ const DATE_OPS: Array<{ op: DateOp; label: string }> = [
53
+ { op: 'on', label: 'On' },
54
+ { op: 'before', label: 'Before' },
55
+ { op: 'after', label: 'After' },
56
+ { op: 'between', label: 'Between' },
57
+ ];
58
+
59
+ const toMs = (s: string): number => (s ? Date.parse(`${s}T00:00:00Z`) : NaN);
60
+ const toDateInput = (ms: number): string =>
61
+ Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : '';
62
+
63
+ // Local draft, seeded once from the active filter. The menu is recreated each
64
+ // time it opens, so capturing the initial prop value (not tracking it) is what
65
+ // we want.
66
+ const init = untrack(() => filter);
67
+ let textOp = $state<TextOp>(init?.kind === 'text' ? init.op : 'contains');
68
+ let textQ = $state(init?.kind === 'text' ? init.q : '');
69
+ let numOp = $state<NumberOp>(init?.kind === 'number' ? init.op : 'eq');
70
+ let numA = $state<number | null>(init?.kind === 'number' && Number.isFinite(init.a) ? init.a : null);
71
+ let numB = $state<number | null>(
72
+ init?.kind === 'number' && init.b != null && Number.isFinite(init.b) ? init.b : null,
73
+ );
74
+ let dateOp = $state<DateOp>(init?.kind === 'date' ? init.op : 'on');
75
+ let dateA = $state(init?.kind === 'date' ? toDateInput(init.a) : '');
76
+ let dateB = $state(init?.kind === 'date' && init.b != null ? toDateInput(init.b) : '');
77
+ // Set filter: track the *excluded* values (unchecked boxes).
78
+ let excluded = $state(new Set<string>(init?.kind === 'set' ? init.excluded : []));
79
+ let search = $state('');
80
+ const shown = $derived(values.filter((v) => v.toLowerCase().includes(search.trim().toLowerCase())));
81
+ function toggleVal(v: string) {
82
+ const n = new Set(excluded);
83
+ if (n.has(v)) n.delete(v);
84
+ else n.add(v);
85
+ excluded = n;
86
+ }
87
+
88
+ function build(): ColumnFilter | null {
89
+ let f: ColumnFilter;
90
+ if (kind === 'number') {
91
+ f = { kind: 'number', op: numOp, a: numA ?? NaN, b: numB ?? undefined };
92
+ } else if (kind === 'date') {
93
+ f = { kind: 'date', op: dateOp, a: toMs(dateA), b: dateB ? toMs(dateB) : undefined };
94
+ } else if (kind === 'set') {
95
+ f = { kind: 'set', excluded: [...excluded] };
96
+ } else {
97
+ f = { kind: 'text', op: textOp, q: textQ };
98
+ }
99
+ return isFilterActive(f) ? f : null;
100
+ }
101
+
102
+ function apply() {
103
+ onApply(build());
104
+ }
105
+ function clear() {
106
+ onApply(null);
107
+ }
108
+ function onKey(e: KeyboardEvent) {
109
+ if (e.key === 'Enter') apply();
110
+ else if (e.key === 'Escape') onClose();
111
+ }
112
+ </script>
113
+
114
+ <div
115
+ class="bo-filtermenu"
116
+ role="dialog"
117
+ tabindex="-1"
118
+ aria-label="Filter {header}"
119
+ style="left:{x}px;top:{y}px;"
120
+ onpointerdown={(e) => e.stopPropagation()}
121
+ onkeydown={onKey}
122
+ >
123
+ <div class="bo-fm-head">{header}</div>
124
+
125
+ {#if kind === 'number'}
126
+ <select class="bo-fm-op" bind:value={numOp} aria-label="Operator">
127
+ {#each NUMBER_OPS as o (o.op)}<option value={o.op}>{o.label}</option>{/each}
128
+ </select>
129
+ <input class="bo-fm-in" type="number" bind:value={numA} placeholder="value" aria-label="Value" />
130
+ {#if numOp === 'between'}
131
+ <input class="bo-fm-in" type="number" bind:value={numB} placeholder="and" aria-label="Upper value" />
132
+ {/if}
133
+ {:else if kind === 'date'}
134
+ <select class="bo-fm-op" bind:value={dateOp} aria-label="Operator">
135
+ {#each DATE_OPS as o (o.op)}<option value={o.op}>{o.label}</option>{/each}
136
+ </select>
137
+ <input class="bo-fm-in" type="date" bind:value={dateA} aria-label="Date" />
138
+ {#if dateOp === 'between'}
139
+ <input class="bo-fm-in" type="date" bind:value={dateB} aria-label="End date" />
140
+ {/if}
141
+ {:else if kind === 'set'}
142
+ <input class="bo-fm-in" type="search" bind:value={search} placeholder="search…" aria-label="Search values" />
143
+ <div class="bo-fm-setbar">
144
+ <button type="button" class="bo-fm-link" onclick={() => (excluded = new Set())}>All</button>
145
+ <button type="button" class="bo-fm-link" onclick={() => (excluded = new Set(values))}>None</button>
146
+ </div>
147
+ <div class="bo-fm-list">
148
+ {#each shown as v (v)}
149
+ <label class="bo-fm-opt">
150
+ <input type="checkbox" checked={!excluded.has(v)} onchange={() => toggleVal(v)} />
151
+ <span>{v === '' ? '(blank)' : v}</span>
152
+ </label>
153
+ {/each}
154
+ </div>
155
+ {:else}
156
+ <select class="bo-fm-op" bind:value={textOp} aria-label="Operator">
157
+ {#each TEXT_OPS as o (o.op)}<option value={o.op}>{o.label}</option>{/each}
158
+ </select>
159
+ <!-- svelte-ignore a11y_autofocus -->
160
+ <input class="bo-fm-in" type="text" bind:value={textQ} placeholder="filter…" aria-label="Value" autofocus />
161
+ {/if}
162
+
163
+ <div class="bo-fm-actions">
164
+ <button class="bo-fm-btn" type="button" onclick={clear}>Clear</button>
165
+ <button class="bo-fm-btn bo-fm-apply" type="button" onclick={apply}>Apply</button>
166
+ </div>
167
+ </div>
168
+
169
+ <style>
170
+ .bo-filtermenu {
171
+ position: fixed;
172
+ z-index: 30;
173
+ display: flex;
174
+ flex-direction: column;
175
+ gap: 6px;
176
+ width: 200px;
177
+ padding: 10px;
178
+ background: var(--bo-header-bg);
179
+ border: 0.5px solid var(--bo-border);
180
+ border-radius: 8px;
181
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
182
+ font-size: 12px;
183
+ color: var(--bo-text);
184
+ }
185
+ .bo-fm-head {
186
+ font-weight: 600;
187
+ color: var(--bo-text-dim);
188
+ padding-bottom: 2px;
189
+ }
190
+ .bo-fm-op,
191
+ .bo-fm-in {
192
+ width: 100%;
193
+ padding: 5px 7px;
194
+ font: inherit;
195
+ color: var(--bo-text);
196
+ background: var(--bo-bg);
197
+ border: 0.5px solid var(--bo-border);
198
+ border-radius: 5px;
199
+ }
200
+ .bo-fm-setbar {
201
+ display: flex;
202
+ gap: 12px;
203
+ }
204
+ .bo-fm-link {
205
+ padding: 0;
206
+ font: inherit;
207
+ font-size: 11px;
208
+ color: var(--bo-up);
209
+ background: none;
210
+ border: 0;
211
+ cursor: pointer;
212
+ }
213
+ .bo-fm-link:hover {
214
+ text-decoration: underline;
215
+ }
216
+ .bo-fm-list {
217
+ display: flex;
218
+ flex-direction: column;
219
+ max-height: 180px;
220
+ overflow-y: auto;
221
+ border: 0.5px solid var(--bo-border);
222
+ border-radius: 5px;
223
+ }
224
+ .bo-fm-opt {
225
+ display: flex;
226
+ align-items: center;
227
+ gap: 7px;
228
+ padding: 4px 7px;
229
+ cursor: pointer;
230
+ white-space: nowrap;
231
+ }
232
+ .bo-fm-opt:hover {
233
+ background: var(--bo-row-hover);
234
+ }
235
+ .bo-fm-opt span {
236
+ overflow: hidden;
237
+ text-overflow: ellipsis;
238
+ }
239
+ .bo-fm-actions {
240
+ display: flex;
241
+ justify-content: flex-end;
242
+ gap: 6px;
243
+ margin-top: 2px;
244
+ }
245
+ .bo-fm-btn {
246
+ padding: 5px 12px;
247
+ font: inherit;
248
+ font-size: 11px;
249
+ color: var(--bo-text-dim);
250
+ background: transparent;
251
+ border: 0.5px solid var(--bo-border);
252
+ border-radius: 5px;
253
+ cursor: pointer;
254
+ }
255
+ .bo-fm-btn:hover {
256
+ color: var(--bo-text);
257
+ }
258
+ .bo-fm-apply {
259
+ color: #0a0a0a;
260
+ background: var(--bo-up);
261
+ border-color: var(--bo-up);
262
+ }
263
+ </style>
@@ -0,0 +1,15 @@
1
+ import { type ColumnFilter, type FilterKind } from './filtering';
2
+ type $$ComponentProps = {
3
+ kind: FilterKind;
4
+ header: string;
5
+ filter: ColumnFilter | null;
6
+ /** Distinct column values for a set filter's checklist. */
7
+ values?: string[];
8
+ x: number;
9
+ y: number;
10
+ onApply: (f: ColumnFilter | null) => void;
11
+ onClose: () => void;
12
+ };
13
+ declare const FilterMenu: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type FilterMenu = ReturnType<typeof FilterMenu>;
15
+ export default FilterMenu;