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.
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
@@ -15,14 +16,16 @@ matrix, a **Leaderboard** with rank medals and score bars, a **Tree** file
15
16
  explorer, a drag-to-reorder **Tasks** list, and a **1M-row** trade tape windowed
16
17
  from a synthetic source — switch between them with the tabs.
17
18
 
18
- > **Status: v0.2.** Working: config-driven columns, virtual scroll, client
19
- > single/multi/controlled sort + global & per-column filters, multi-cell selection
20
- > + live range aggregation, row grouping (nested, collapsible, sticky headers,
21
- > live subtotals), pivot tables, tree data, master-detail, a server-side
22
- > `RowSource` for huge datasets, CSV/Excel export, drag-to-reorder and
23
- > drag-to-resize columns, pinned columns (left/right), row selection, pagination,
24
- > inline editing with validation, type-to-edit, clipboard copy/paste, sparklines,
25
- > realtime flash, heatmaps, theming, and full keyboard a11y. **SSR/SvelteKit-safe.**
19
+ > **Status: actively developed.** Working: config-driven columns, virtual scroll,
20
+ > sort (single / multi / controlled), filtering (global, per-column row, header
21
+ > filter menus with set / number / date filters, quick search, controlled +
22
+ > server-side), multi-cell selection + live aggregation, grouping (nested, sticky,
23
+ > subtotals), pivot, tree data, master-detail, a server-side `RowSource` for huge
24
+ > datasets, CSV/Excel export, column management (reorder, resize, pin L/R, hide,
25
+ > autosize, tool panel, column menu), spreadsheet editing (inline + typed editors,
26
+ > validation, copy/paste, fill handle, undo/redo), row selection, pagination,
27
+ > sparklines, realtime flash, heatmaps, theming, and full keyboard a11y.
28
+ > **SSR/SvelteKit-safe.**
26
29
  > Unit tests (Vitest), type-check, a headless mount smoke-test, an SSR render
27
30
  > check, and library + demo bundle-size budgets all run in CI. A formal WCAG audit
28
31
  > is the main thing left — see the roadmap.
@@ -145,9 +148,27 @@ to opt out. Sorting is a snapshot — rows hold position while their values upda
145
148
  in place (trading-grid behaviour), so a realtime feed never reshuffles the view.
146
149
 
147
150
  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).
151
+ it from your own search input — or set `quickFilter` for a **built-in search box**
152
+ above the grid. Set `filterRow` to add a row of **per-column filter inputs** under
153
+ the header (rows must match every non-empty column filter; in-memory mode).
154
+
155
+ For richer filtering, set `filterMenu` to add a **funnel to each column header**.
156
+ Clicking it opens a menu whose control matches the column type — text
157
+ (contains / equals / starts / ends), number (`=, ≠, <, ≤, >, ≥, between`), or date
158
+ (before / after / on / between). Set `col.filter: 'set'` for a **set filter** — a
159
+ searchable checkbox list of the column's distinct values (All / None). The menu
160
+ is lazy-loaded on first open, so it costs nothing until used; disable it per
161
+ column with `col.filter: false`:
162
+
163
+ ```svelte
164
+ <Grid {rows} {columns} height={640} filterMenu />
165
+ ```
166
+
167
+ Filtering is uncontrolled by default. To **own the filter state** (persist it, set
168
+ initial filters, sync to the URL), pass a controlled `columnFilters` map and
169
+ handle `onFilterChange` — mirrors controlled `sort`. In **server (`source`) mode**
170
+ the filter menu still works: text/number/date filters are delegated to your
171
+ `RowSource` via `params.columnFilters` (set filters need in-memory data).
151
172
 
152
173
  Sorting is uncontrolled by default. To own it (persist it, set an initial sort,
153
174
  or sync to the URL), pass a controlled `sort` array and handle `onSortChange`:
@@ -171,6 +192,13 @@ values flow through the same validation as inline editing — non-editable colum
171
192
  and invalid numbers are skipped — and each accepted cell emits `onCellEdit`, so
172
193
  paste only does anything when you've wired that callback.
173
194
 
195
+ Set `fillHandle` for an **Excel-style fill handle**: the selection grows a small
196
+ square at its bottom-right corner; drag it down or right to copy the selected
197
+ value(s) across the extended range (editable columns; multi-cell selections tile).
198
+
199
+ Edits, paste and fill are **undoable** with <kbd>Ctrl/⌘</kbd>+<kbd>Z</kbd> (redo
200
+ with <kbd>Ctrl/⌘</kbd>+<kbd>Y</kbd>). A paste or fill undoes as a single step.
201
+
174
202
  When more than one cell is selected, a footer bar shows live **Sum / Avg / Count /
175
203
  Min / Max** over the numeric cells in the range — and it keeps updating as a
176
204
  realtime feed ticks. Choose which stats to show:
@@ -264,6 +292,30 @@ these:
264
292
  }
265
293
  ```
266
294
 
295
+ Native form controls (checkboxes, date pickers, number spinners, search inputs,
296
+ scrollbars) follow the theme automatically via `color-scheme` + `accent-color`.
297
+ A custom theme defaults to dark; set `scheme: 'light'` (or `--bo-grid-scheme:
298
+ light`) for a light one.
299
+
300
+ **Tokens** cover colour, typography (`mono`/`sans`/`fontSize`), shape (`radius`),
301
+ and density (`cellPad`, plus the `rowHeight` prop) — so the whole look is yours.
302
+ Numeric columns use tabular figures so digits line up. A few looks:
303
+
304
+ ```svelte
305
+ <!-- Compact / dense -->
306
+ <Grid {rows} {columns} rowHeight={28}
307
+ theme={{ fontSize: '12px', cellPad: '6px' }} height={640} />
308
+
309
+ <!-- Roomy & rounded -->
310
+ <Grid {rows} {columns} rowHeight={44}
311
+ theme={{ radius: '16px', cellPad: '16px', fontSize: '14px' }} height={640} />
312
+
313
+ <!-- Branded -->
314
+ <Grid {rows} {columns}
315
+ theme={{ bg: '#0b1020', headerBg: '#0d1226', up: '#22d3ee', down: '#fb7185',
316
+ selBorder: '#22d3ee', radius: '12px' }} height={640} />
317
+ ```
318
+
267
319
  ## Server-side / large datasets
268
320
 
269
321
  Instead of an in-memory `rows` array, back the grid with a **`RowSource`** — the
@@ -327,6 +379,19 @@ your own column-picker UI and drive the prop; the grid stays presentation-only:
327
379
  <Grid {rows} {columns} hiddenColumns={['bonus', 'rating']} height={640} />
328
380
  ```
329
381
 
382
+ Or set `columnMenu` for a **per-column header menu** (a ⋮ trigger, or
383
+ <kbd>Alt</kbd>+<kbd>↓</kbd> on the focused column) with **sort**, **pin**
384
+ (left/right/unpin), **Autosize** (fit to content), and **Hide column** actions,
385
+ and `columnsPanel` for a **"Columns" button** that opens a checklist to toggle
386
+ visibility (and restore hidden columns). Runtime hide/pin compose with
387
+ `hiddenColumns` / `col.pinned`, persist via `persistKey`, and hide reports through
388
+ `onColumnVisibilityChange`:
389
+
390
+ ```svelte
391
+ <Grid {rows} {columns} columnMenu height={640}
392
+ onColumnVisibilityChange={(hidden) => (myHidden = hidden)} />
393
+ ```
394
+
330
395
  ## Header groups
331
396
 
332
397
  Give columns a `group` label to render a spanning parent header over consecutive
@@ -407,7 +472,9 @@ is keyboard-accessible: the <kbd>ContextMenu</kbd> key (or
407
472
  Mark a column `editable: true`. Double-click a cell (or press <kbd>Enter</kbd> on
408
473
  the focused cell) to edit; <kbd>Enter</kbd>/blur commits, <kbd>Esc</kbd> cancels.
409
474
  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
475
+ editor seeded with that character (Excel-style type-to-edit). The editor matches
476
+ the column type: numeric columns get a number input, `date` columns a native date
477
+ picker, `options` columns a `<select>`, everything else a text input. The grid is
411
478
  controlled, so it reports the change via `onCellEdit` — update your own row data
412
479
  there:
413
480
 
@@ -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>
@@ -219,9 +251,9 @@
219
251
  position: relative;
220
252
  display: flex;
221
253
  align-items: center;
222
- padding: 0 8px;
254
+ padding: 0 var(--bo-cell-pad, 8px);
223
255
  height: 100%;
224
- font-size: 13px;
256
+ font-size: var(--bo-font-size, 13px);
225
257
  line-height: 1.4;
226
258
  overflow: hidden;
227
259
  white-space: nowrap;
@@ -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;