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 +46 -5
- package/dist/grid/Cell.svelte +50 -2
- package/dist/grid/Cell.svelte.d.ts +5 -0
- package/dist/grid/FilterMenu.svelte +263 -0
- package/dist/grid/FilterMenu.svelte.d.ts +15 -0
- package/dist/grid/Grid.svelte +581 -31
- package/dist/grid/Grid.svelte.d.ts +33 -1
- package/dist/grid/ToolPanel.svelte +117 -0
- package/dist/grid/ToolPanel.svelte.d.ts +15 -0
- package/dist/grid/column.d.ts +5 -0
- package/dist/grid/column.js +1 -1
- package/dist/grid/filtering.d.ts +41 -0
- package/dist/grid/filtering.js +107 -0
- 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/index.d.ts +1 -0
- package/package.json +1 -1
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 —
|
|
149
|
-
to add a row of **per-column filter inputs** under
|
|
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
|
|
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
|
|
package/dist/grid/Cell.svelte
CHANGED
|
@@ -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
|
-
|
|
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;
|