bo-grid 0.1.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;