@dorsk/tsumikit 0.11.1 → 0.12.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/dist/components/molecules/FilterInput.svelte +426 -0
- package/dist/components/molecules/FilterInput.svelte.d.ts +57 -0
- package/dist/components/organisms/FilterSearchBar.svelte +27 -341
- package/dist/components/organisms/FilterSearchBar.svelte.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
import type { FilterNode, Query } from '../../query/ast';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The reactive parse context handed to the `inline` and `below` snippets so a
|
|
6
|
+
* host can render arbitrary Svelte nodes (badges, custom components, chips)
|
|
7
|
+
* either inline inside the bar or below it — all derived from the same value.
|
|
8
|
+
*/
|
|
9
|
+
export interface FilterInputContext {
|
|
10
|
+
/** The parsed AST for the current value. */
|
|
11
|
+
ast: Query;
|
|
12
|
+
/** The parsed filter clauses (field/op/value + source span). */
|
|
13
|
+
filters: FilterNode[];
|
|
14
|
+
/** The free-text remainder (non-field terms). */
|
|
15
|
+
text: string;
|
|
16
|
+
/** The raw textual query. */
|
|
17
|
+
value: string;
|
|
18
|
+
/** Splice a clause out by its source span and re-fire onsubmit. */
|
|
19
|
+
remove: (span: [number, number]) => void;
|
|
20
|
+
/** Focus the underlying input. */
|
|
21
|
+
focus: () => void;
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<script lang="ts">
|
|
26
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
27
|
+
// FilterInput primitive — the headless base of the structured search bar.
|
|
28
|
+
//
|
|
29
|
+
// It owns the input element, parse/suggest, the context-aware suggestion
|
|
30
|
+
// dropdown, keyboard + dropdown navigation and the caret-aware editing
|
|
31
|
+
// helpers (autoQuoteEdit / backspaceEmptyQuotes / closingQuoteExit). It does
|
|
32
|
+
// NOT render below-bar chips — that composition lives in FilterSearchBar. A
|
|
33
|
+
// host can render arbitrary inline nodes via the `inline` snippet (single
|
|
34
|
+
// structured field with badges) and/or below-bar content via `below`.
|
|
35
|
+
//
|
|
36
|
+
// Everything project-specific is INJECTED via `schema` (with per-field async
|
|
37
|
+
// `ValueProvider`s). No `fetch` lives in here; the bar owns the QUERY string.
|
|
38
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
39
|
+
import type { Snippet } from 'svelte';
|
|
40
|
+
import Icon, { type IconName } from '../atoms/Icon.svelte';
|
|
41
|
+
import { filters, freeText } from '../../query/ast';
|
|
42
|
+
import { autoQuoteEdit, backspaceEmptyQuotes, closingQuoteExit } from '../../query/edit';
|
|
43
|
+
import { parse } from '../../query/parser';
|
|
44
|
+
import { type Schema } from '../../query/schema';
|
|
45
|
+
import { suggest, type SuggestState } from '../../query/suggest';
|
|
46
|
+
|
|
47
|
+
let {
|
|
48
|
+
schema,
|
|
49
|
+
value = $bindable(''),
|
|
50
|
+
placeholder = 'artist:"Daft Punk" AND year>=2000',
|
|
51
|
+
autoQuote = true,
|
|
52
|
+
showClear = true,
|
|
53
|
+
icon = 'search',
|
|
54
|
+
onchange,
|
|
55
|
+
onsubmit,
|
|
56
|
+
inline,
|
|
57
|
+
below
|
|
58
|
+
}: {
|
|
59
|
+
schema: Schema;
|
|
60
|
+
/** The raw textual query (two-way bindable). */
|
|
61
|
+
value?: string;
|
|
62
|
+
placeholder?: string;
|
|
63
|
+
/**
|
|
64
|
+
* When a string field's value step opens (`title:`), auto-insert a `""`
|
|
65
|
+
* pair with the caret inside so multi-word values stay together; Tab exits
|
|
66
|
+
* the quotes. Set false for bare typing where spaces split the value.
|
|
67
|
+
*/
|
|
68
|
+
autoQuote?: boolean;
|
|
69
|
+
/** Show the trailing clear (✕) button when the field is non-empty. */
|
|
70
|
+
showClear?: boolean;
|
|
71
|
+
/** Leading icon; pass `null` to hide it. */
|
|
72
|
+
icon?: IconName | null;
|
|
73
|
+
/** Fires the parsed AST on every change — feed this to your backend. */
|
|
74
|
+
onchange?: (query: Query, raw: string) => void;
|
|
75
|
+
/** Fires on Enter / clear / chip-remove with the raw query string. */
|
|
76
|
+
onsubmit?: (value: string) => void;
|
|
77
|
+
/**
|
|
78
|
+
* Custom inline content rendered inside the bar, before the input — the
|
|
79
|
+
* hook for a single structured field that shows badges / custom nodes
|
|
80
|
+
* inline (no below-bar chips). Receives the reactive parse context.
|
|
81
|
+
*/
|
|
82
|
+
inline?: Snippet<[FilterInputContext]>;
|
|
83
|
+
/**
|
|
84
|
+
* Content rendered below the bar (e.g. removable chips). FilterSearchBar
|
|
85
|
+
* passes its chip row here; single-field hosts simply omit it.
|
|
86
|
+
*/
|
|
87
|
+
below?: Snippet<[FilterInputContext]>;
|
|
88
|
+
} = $props();
|
|
89
|
+
|
|
90
|
+
let el = $state<HTMLInputElement | null>(null);
|
|
91
|
+
let menuEl = $state<HTMLDivElement | null>(null);
|
|
92
|
+
let menu = $state<SuggestState | null>(null);
|
|
93
|
+
let active = $state(0);
|
|
94
|
+
let open = $state(false);
|
|
95
|
+
let keyboardNavigation = $state(false);
|
|
96
|
+
let reqId = 0;
|
|
97
|
+
|
|
98
|
+
// Parsed view (drives the onchange AST + the snippet context).
|
|
99
|
+
const ast = $derived(parse(value, schema));
|
|
100
|
+
const chips = $derived(filters(ast));
|
|
101
|
+
const text = $derived(freeText(ast));
|
|
102
|
+
|
|
103
|
+
const ctx = $derived<FilterInputContext>({
|
|
104
|
+
ast,
|
|
105
|
+
filters: chips,
|
|
106
|
+
text,
|
|
107
|
+
value,
|
|
108
|
+
remove: removeChip,
|
|
109
|
+
focus: () => el?.focus()
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Emit the AST whenever the parse result changes.
|
|
113
|
+
$effect(() => {
|
|
114
|
+
onchange?.(ast, value);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Keep keyboard navigation visible without moving the menu under the pointer
|
|
118
|
+
// when the active option changes because of hover.
|
|
119
|
+
$effect(() => {
|
|
120
|
+
active;
|
|
121
|
+
if (!keyboardNavigation || !open || !menuEl) return;
|
|
122
|
+
menuEl.querySelector<HTMLElement>('.fi__opt.is-active')?.scrollIntoView({ block: 'nearest' });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function setCaret(pos: number) {
|
|
126
|
+
queueMicrotask(() => {
|
|
127
|
+
if (!el) return;
|
|
128
|
+
el.focus();
|
|
129
|
+
el.setSelectionRange(pos, pos);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function oninput(e: Event) {
|
|
134
|
+
if (autoQuote && el && (e as InputEvent).inputType?.startsWith('insert')) {
|
|
135
|
+
const pos = el.selectionStart ?? value.length;
|
|
136
|
+
const edit = autoQuoteEdit(schema, value, pos);
|
|
137
|
+
if (edit) {
|
|
138
|
+
value = edit.value;
|
|
139
|
+
setCaret(edit.pos);
|
|
140
|
+
queueMicrotask(refresh);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
refresh();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function refresh() {
|
|
148
|
+
if (!el) return;
|
|
149
|
+
const pos = el.selectionStart ?? value.length;
|
|
150
|
+
const id = ++reqId;
|
|
151
|
+
const next = await suggest(schema, value, pos);
|
|
152
|
+
if (id !== reqId) return; // a newer keystroke won
|
|
153
|
+
menu = next;
|
|
154
|
+
active = 0;
|
|
155
|
+
keyboardNavigation = false;
|
|
156
|
+
open = !!next && next.items.length > 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function accept(i: number) {
|
|
160
|
+
if (!menu || !el) return;
|
|
161
|
+
const s = menu.items[i];
|
|
162
|
+
if (!s) return;
|
|
163
|
+
const [a, b] = menu.span;
|
|
164
|
+
value = value.slice(0, a) + s.insert + value.slice(b);
|
|
165
|
+
let caret = s.caret;
|
|
166
|
+
// A field/operator pick that opens a string field's value step gets the
|
|
167
|
+
// same auto-quotes as manual typing.
|
|
168
|
+
if (autoQuote && s.advance) {
|
|
169
|
+
const edit = autoQuoteEdit(schema, value, caret);
|
|
170
|
+
if (edit) {
|
|
171
|
+
value = edit.value;
|
|
172
|
+
caret = edit.pos;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Restore caret after the inserted text, then re-run suggestions if the
|
|
176
|
+
// step advanced (field → operator → value) so the flow keeps going.
|
|
177
|
+
queueMicrotask(() => {
|
|
178
|
+
if (!el) return;
|
|
179
|
+
el.focus();
|
|
180
|
+
el.setSelectionRange(caret, caret);
|
|
181
|
+
if (s.advance) refresh();
|
|
182
|
+
else open = false;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function onkeydown(e: KeyboardEvent) {
|
|
187
|
+
if (autoQuote && el) {
|
|
188
|
+
const pos = el.selectionStart ?? value.length;
|
|
189
|
+
if (e.key === 'Tab') {
|
|
190
|
+
// Inside auto-quotes Tab exits them (takes priority over accepting a
|
|
191
|
+
// dropdown item); elsewhere it falls through to accept.
|
|
192
|
+
const exit = closingQuoteExit(value, pos);
|
|
193
|
+
if (exit !== null) {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
open = false;
|
|
196
|
+
setCaret(exit);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
} else if (e.key === 'Backspace') {
|
|
200
|
+
const edit = backspaceEmptyQuotes(value, pos);
|
|
201
|
+
if (edit) {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
value = edit.value;
|
|
204
|
+
setCaret(edit.pos);
|
|
205
|
+
queueMicrotask(refresh);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!open || !menu) {
|
|
211
|
+
if (e.key === 'Enter') submit();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
switch (e.key) {
|
|
215
|
+
case 'ArrowDown':
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
keyboardNavigation = true;
|
|
218
|
+
active = (active + 1) % menu.items.length;
|
|
219
|
+
break;
|
|
220
|
+
case 'ArrowUp':
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
keyboardNavigation = true;
|
|
223
|
+
active = (active - 1 + menu.items.length) % menu.items.length;
|
|
224
|
+
break;
|
|
225
|
+
case 'Enter':
|
|
226
|
+
case 'Tab':
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
accept(active);
|
|
229
|
+
break;
|
|
230
|
+
case 'Escape':
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
open = false;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function submit() {
|
|
238
|
+
open = false;
|
|
239
|
+
onsubmit?.(value);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function removeChip(span: [number, number]) {
|
|
243
|
+
// Splice the clause out, plus trailing spaces to avoid doubles.
|
|
244
|
+
const [a, b] = span;
|
|
245
|
+
let end = b;
|
|
246
|
+
while (value[end] === ' ') end++;
|
|
247
|
+
value = (value.slice(0, a) + value.slice(end)).replace(/\s{2,}/g, ' ').trim();
|
|
248
|
+
onsubmit?.(value);
|
|
249
|
+
queueMicrotask(() => el?.focus());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const kindLabel: Record<string, string> = {
|
|
253
|
+
field: 'Fields',
|
|
254
|
+
operator: 'Operators',
|
|
255
|
+
value: 'Values'
|
|
256
|
+
};
|
|
257
|
+
</script>
|
|
258
|
+
|
|
259
|
+
<div class="fi" data-tsu="FilterInput">
|
|
260
|
+
<div class="fi__bar">
|
|
261
|
+
{#if icon}<span class="fi__icon"><Icon name={icon} label="Search" /></span>{/if}
|
|
262
|
+
{#if inline}{@render inline(ctx)}{/if}
|
|
263
|
+
<input
|
|
264
|
+
bind:this={el}
|
|
265
|
+
bind:value
|
|
266
|
+
class="fi__input"
|
|
267
|
+
spellcheck="false"
|
|
268
|
+
autocomplete="off"
|
|
269
|
+
{placeholder}
|
|
270
|
+
{oninput}
|
|
271
|
+
onclick={refresh}
|
|
272
|
+
onkeyup={(e) => {
|
|
273
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') refresh();
|
|
274
|
+
}}
|
|
275
|
+
onfocus={refresh}
|
|
276
|
+
onblur={() => setTimeout(() => (open = false), 120)}
|
|
277
|
+
{onkeydown}
|
|
278
|
+
/>
|
|
279
|
+
{#if showClear && value}
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
class="fi__clear"
|
|
283
|
+
title="Clear"
|
|
284
|
+
onclick={() => {
|
|
285
|
+
value = '';
|
|
286
|
+
onsubmit?.('');
|
|
287
|
+
el?.focus();
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
<Icon name="x" label="Clear" />
|
|
291
|
+
</button>
|
|
292
|
+
{/if}
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
{#if open && menu}
|
|
296
|
+
<div bind:this={menuEl} class="fi__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
|
|
297
|
+
<div class="fi__menu-head">{kindLabel[menu.kind]}</div>
|
|
298
|
+
{#each menu.items as item, i (item.insert + i)}
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
class="fi__opt"
|
|
302
|
+
class:is-active={i === active}
|
|
303
|
+
role="option"
|
|
304
|
+
aria-selected={i === active}
|
|
305
|
+
onmousedown={(e) => e.preventDefault()}
|
|
306
|
+
onmouseenter={() => {
|
|
307
|
+
keyboardNavigation = false;
|
|
308
|
+
active = i;
|
|
309
|
+
}}
|
|
310
|
+
onclick={() => accept(i)}
|
|
311
|
+
>
|
|
312
|
+
<span class="fi__opt-label">{item.label}</span>
|
|
313
|
+
{#if item.hint}<span class="fi__opt-hint">{item.hint}</span>{/if}
|
|
314
|
+
{#if item.code}<span class="fi__opt-code">{item.code}</span>{/if}
|
|
315
|
+
</button>
|
|
316
|
+
{/each}
|
|
317
|
+
</div>
|
|
318
|
+
{/if}
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{#if below}{@render below(ctx)}{/if}
|
|
322
|
+
|
|
323
|
+
<style>
|
|
324
|
+
.fi {
|
|
325
|
+
position: relative;
|
|
326
|
+
}
|
|
327
|
+
.fi__bar {
|
|
328
|
+
display: flex;
|
|
329
|
+
align-items: center;
|
|
330
|
+
gap: var(--sp-2);
|
|
331
|
+
padding: 0 var(--sp-3);
|
|
332
|
+
min-height: 44px;
|
|
333
|
+
background: var(--bg-elevated);
|
|
334
|
+
border: 1px solid var(--border-strong);
|
|
335
|
+
border-radius: var(--r-md);
|
|
336
|
+
}
|
|
337
|
+
.fi__bar:focus-within {
|
|
338
|
+
border-color: var(--accent);
|
|
339
|
+
box-shadow: 0 0 0 3px var(--accent-dim);
|
|
340
|
+
}
|
|
341
|
+
.fi__icon {
|
|
342
|
+
display: flex;
|
|
343
|
+
color: var(--text-faint);
|
|
344
|
+
}
|
|
345
|
+
.fi__input {
|
|
346
|
+
flex: 1;
|
|
347
|
+
border: 0;
|
|
348
|
+
background: transparent;
|
|
349
|
+
color: var(--text);
|
|
350
|
+
font-family: var(--font-mono);
|
|
351
|
+
font-size: var(--fs-md);
|
|
352
|
+
outline: none;
|
|
353
|
+
min-width: 0;
|
|
354
|
+
}
|
|
355
|
+
.fi__input::placeholder {
|
|
356
|
+
color: var(--text-faint);
|
|
357
|
+
}
|
|
358
|
+
.fi__clear {
|
|
359
|
+
display: flex;
|
|
360
|
+
border: 0;
|
|
361
|
+
background: transparent;
|
|
362
|
+
color: var(--text-faint);
|
|
363
|
+
cursor: pointer;
|
|
364
|
+
padding: var(--sp-1);
|
|
365
|
+
border-radius: var(--r-sm);
|
|
366
|
+
}
|
|
367
|
+
.fi__clear:hover {
|
|
368
|
+
color: var(--text);
|
|
369
|
+
background: var(--bg-elevated-2);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.fi__menu {
|
|
373
|
+
position: absolute;
|
|
374
|
+
top: calc(100% + 4px);
|
|
375
|
+
left: 0;
|
|
376
|
+
right: 0;
|
|
377
|
+
z-index: var(--z-drawer);
|
|
378
|
+
background: var(--bg-elevated);
|
|
379
|
+
border: 1px solid var(--border-strong);
|
|
380
|
+
border-radius: var(--r-md);
|
|
381
|
+
box-shadow: var(--shadow-lg);
|
|
382
|
+
padding: var(--sp-1);
|
|
383
|
+
max-height: 320px;
|
|
384
|
+
overflow-y: auto;
|
|
385
|
+
}
|
|
386
|
+
.fi__menu-head {
|
|
387
|
+
font-size: var(--fs-xs);
|
|
388
|
+
text-transform: uppercase;
|
|
389
|
+
letter-spacing: 0.06em;
|
|
390
|
+
color: var(--text-faint);
|
|
391
|
+
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
|
392
|
+
}
|
|
393
|
+
.fi__opt {
|
|
394
|
+
display: flex;
|
|
395
|
+
align-items: baseline;
|
|
396
|
+
gap: var(--sp-2);
|
|
397
|
+
width: 100%;
|
|
398
|
+
text-align: left;
|
|
399
|
+
border: 0;
|
|
400
|
+
background: transparent;
|
|
401
|
+
color: var(--text);
|
|
402
|
+
padding: var(--sp-2);
|
|
403
|
+
border-radius: var(--r-sm);
|
|
404
|
+
cursor: pointer;
|
|
405
|
+
font-size: var(--fs-sm);
|
|
406
|
+
}
|
|
407
|
+
.fi__opt.is-active {
|
|
408
|
+
background: var(--accent-dim);
|
|
409
|
+
}
|
|
410
|
+
.fi__opt-label {
|
|
411
|
+
font-weight: var(--fw-medium);
|
|
412
|
+
}
|
|
413
|
+
.fi__opt-hint {
|
|
414
|
+
color: var(--text-faint);
|
|
415
|
+
font-size: var(--fs-xs);
|
|
416
|
+
}
|
|
417
|
+
.fi__opt-code {
|
|
418
|
+
margin-left: auto;
|
|
419
|
+
font-family: var(--font-mono);
|
|
420
|
+
font-size: var(--fs-xs);
|
|
421
|
+
color: var(--text-muted);
|
|
422
|
+
background: var(--bg-elevated-2);
|
|
423
|
+
padding: 0 var(--sp-1);
|
|
424
|
+
border-radius: var(--r-sm);
|
|
425
|
+
}
|
|
426
|
+
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { FilterNode, Query } from '../../query/ast';
|
|
2
|
+
/**
|
|
3
|
+
* The reactive parse context handed to the `inline` and `below` snippets so a
|
|
4
|
+
* host can render arbitrary Svelte nodes (badges, custom components, chips)
|
|
5
|
+
* either inline inside the bar or below it — all derived from the same value.
|
|
6
|
+
*/
|
|
7
|
+
export interface FilterInputContext {
|
|
8
|
+
/** The parsed AST for the current value. */
|
|
9
|
+
ast: Query;
|
|
10
|
+
/** The parsed filter clauses (field/op/value + source span). */
|
|
11
|
+
filters: FilterNode[];
|
|
12
|
+
/** The free-text remainder (non-field terms). */
|
|
13
|
+
text: string;
|
|
14
|
+
/** The raw textual query. */
|
|
15
|
+
value: string;
|
|
16
|
+
/** Splice a clause out by its source span and re-fire onsubmit. */
|
|
17
|
+
remove: (span: [number, number]) => void;
|
|
18
|
+
/** Focus the underlying input. */
|
|
19
|
+
focus: () => void;
|
|
20
|
+
}
|
|
21
|
+
import type { Snippet } from 'svelte';
|
|
22
|
+
import { type IconName } from '../atoms/Icon.svelte';
|
|
23
|
+
import { type Schema } from '../../query/schema';
|
|
24
|
+
type $$ComponentProps = {
|
|
25
|
+
schema: Schema;
|
|
26
|
+
/** The raw textual query (two-way bindable). */
|
|
27
|
+
value?: string;
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
/**
|
|
30
|
+
* When a string field's value step opens (`title:`), auto-insert a `""`
|
|
31
|
+
* pair with the caret inside so multi-word values stay together; Tab exits
|
|
32
|
+
* the quotes. Set false for bare typing where spaces split the value.
|
|
33
|
+
*/
|
|
34
|
+
autoQuote?: boolean;
|
|
35
|
+
/** Show the trailing clear (✕) button when the field is non-empty. */
|
|
36
|
+
showClear?: boolean;
|
|
37
|
+
/** Leading icon; pass `null` to hide it. */
|
|
38
|
+
icon?: IconName | null;
|
|
39
|
+
/** Fires the parsed AST on every change — feed this to your backend. */
|
|
40
|
+
onchange?: (query: Query, raw: string) => void;
|
|
41
|
+
/** Fires on Enter / clear / chip-remove with the raw query string. */
|
|
42
|
+
onsubmit?: (value: string) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Custom inline content rendered inside the bar, before the input — the
|
|
45
|
+
* hook for a single structured field that shows badges / custom nodes
|
|
46
|
+
* inline (no below-bar chips). Receives the reactive parse context.
|
|
47
|
+
*/
|
|
48
|
+
inline?: Snippet<[FilterInputContext]>;
|
|
49
|
+
/**
|
|
50
|
+
* Content rendered below the bar (e.g. removable chips). FilterSearchBar
|
|
51
|
+
* passes its chip row here; single-field hosts simply omit it.
|
|
52
|
+
*/
|
|
53
|
+
below?: Snippet<[FilterInputContext]>;
|
|
54
|
+
};
|
|
55
|
+
declare const FilterInput: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
56
|
+
type FilterInput = ReturnType<typeof FilterInput>;
|
|
57
|
+
export default FilterInput;
|
|
@@ -7,27 +7,16 @@
|
|
|
7
7
|
// (pure-code mode) OR build it entirely from the context-aware dropdown
|
|
8
8
|
// (visual helper mode). Both edit the same string.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// • `onchange(query)` — fires the parsed AST whenever it changes; the host
|
|
16
|
-
// runs it against its real backend and renders results.
|
|
17
|
-
//
|
|
18
|
-
// The bar owns the QUERY; the host owns the DISPLAY of results.
|
|
10
|
+
// This is now a THIN composition over the headless `FilterInput` primitive:
|
|
11
|
+
// FilterInput owns the input, parse/suggest, the dropdown and the caret edit
|
|
12
|
+
// helpers; FilterSearchBar adds the below-bar removable chips (`filters(ast)`)
|
|
13
|
+
// and forwards onchange/onsubmit. Everything project-specific is INJECTED via
|
|
14
|
+
// `schema` (with per-field async `ValueProvider`s).
|
|
19
15
|
// ───────────────────────────────────────────────────────────────────────
|
|
20
16
|
import Badge from '../atoms/Badge.svelte';
|
|
21
|
-
import
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
autoQuoteEdit,
|
|
25
|
-
backspaceEmptyQuotes,
|
|
26
|
-
closingQuoteExit
|
|
27
|
-
} from '../../query/edit';
|
|
17
|
+
import FilterInput from '../molecules/FilterInput.svelte';
|
|
18
|
+
import type { Query } from '../../query/ast';
|
|
28
19
|
import { findField, operatorById, type Schema } from '../../query/schema';
|
|
29
|
-
import { parse } from '../../query/parser';
|
|
30
|
-
import { suggest, type SuggestState } from '../../query/suggest';
|
|
31
20
|
|
|
32
21
|
let {
|
|
33
22
|
schema,
|
|
@@ -56,337 +45,34 @@
|
|
|
56
45
|
onsubmit?: (value: string) => void;
|
|
57
46
|
} = $props();
|
|
58
47
|
|
|
59
|
-
let el = $state<HTMLInputElement | null>(null);
|
|
60
|
-
let menu = $state<SuggestState | null>(null);
|
|
61
|
-
let active = $state(0);
|
|
62
|
-
let open = $state(false);
|
|
63
|
-
let reqId = 0;
|
|
64
|
-
|
|
65
|
-
// Parsed view (drives the removable chips + the onchange AST).
|
|
66
|
-
const ast = $derived(parse(value, schema));
|
|
67
|
-
const chips = $derived(filters(ast));
|
|
68
|
-
const text = $derived(freeText(ast));
|
|
69
|
-
|
|
70
|
-
// Emit the AST whenever the parse result changes.
|
|
71
|
-
$effect(() => {
|
|
72
|
-
onchange?.(ast, value);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
48
|
function labelFor(fieldName: string): string {
|
|
76
49
|
return findField(schema, fieldName)?.label ?? fieldName;
|
|
77
50
|
}
|
|
78
|
-
|
|
79
|
-
function setCaret(pos: number) {
|
|
80
|
-
queueMicrotask(() => {
|
|
81
|
-
if (!el) return;
|
|
82
|
-
el.focus();
|
|
83
|
-
el.setSelectionRange(pos, pos);
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function oninput(e: Event) {
|
|
88
|
-
if (autoQuote && el && (e as InputEvent).inputType?.startsWith('insert')) {
|
|
89
|
-
const pos = el.selectionStart ?? value.length;
|
|
90
|
-
const edit = autoQuoteEdit(schema, value, pos);
|
|
91
|
-
if (edit) {
|
|
92
|
-
value = edit.value;
|
|
93
|
-
setCaret(edit.pos);
|
|
94
|
-
queueMicrotask(refresh);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
refresh();
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function refresh() {
|
|
102
|
-
if (!el) return;
|
|
103
|
-
const pos = el.selectionStart ?? value.length;
|
|
104
|
-
const id = ++reqId;
|
|
105
|
-
const next = await suggest(schema, value, pos);
|
|
106
|
-
if (id !== reqId) return; // a newer keystroke won
|
|
107
|
-
menu = next;
|
|
108
|
-
active = 0;
|
|
109
|
-
open = !!next && next.items.length > 0;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function accept(i: number) {
|
|
113
|
-
if (!menu || !el) return;
|
|
114
|
-
const s = menu.items[i];
|
|
115
|
-
if (!s) return;
|
|
116
|
-
const [a, b] = menu.span;
|
|
117
|
-
value = value.slice(0, a) + s.insert + value.slice(b);
|
|
118
|
-
let caret = s.caret;
|
|
119
|
-
// A field/operator pick that opens a string field's value step gets the
|
|
120
|
-
// same auto-quotes as manual typing.
|
|
121
|
-
if (autoQuote && s.advance) {
|
|
122
|
-
const edit = autoQuoteEdit(schema, value, caret);
|
|
123
|
-
if (edit) {
|
|
124
|
-
value = edit.value;
|
|
125
|
-
caret = edit.pos;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
// Restore caret after the inserted text, then re-run suggestions if the
|
|
129
|
-
// step advanced (field → operator → value) so the flow keeps going.
|
|
130
|
-
queueMicrotask(() => {
|
|
131
|
-
if (!el) return;
|
|
132
|
-
el.focus();
|
|
133
|
-
el.setSelectionRange(caret, caret);
|
|
134
|
-
if (s.advance) refresh();
|
|
135
|
-
else open = false;
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function onkeydown(e: KeyboardEvent) {
|
|
140
|
-
if (autoQuote && el) {
|
|
141
|
-
const pos = el.selectionStart ?? value.length;
|
|
142
|
-
if (e.key === 'Tab') {
|
|
143
|
-
// Inside auto-quotes Tab exits them (takes priority over accepting a
|
|
144
|
-
// dropdown item); elsewhere it falls through to accept.
|
|
145
|
-
const exit = closingQuoteExit(value, pos);
|
|
146
|
-
if (exit !== null) {
|
|
147
|
-
e.preventDefault();
|
|
148
|
-
open = false;
|
|
149
|
-
setCaret(exit);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
} else if (e.key === 'Backspace') {
|
|
153
|
-
const edit = backspaceEmptyQuotes(value, pos);
|
|
154
|
-
if (edit) {
|
|
155
|
-
e.preventDefault();
|
|
156
|
-
value = edit.value;
|
|
157
|
-
setCaret(edit.pos);
|
|
158
|
-
queueMicrotask(refresh);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (!open || !menu) {
|
|
164
|
-
if (e.key === 'Enter') submit();
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
switch (e.key) {
|
|
168
|
-
case 'ArrowDown':
|
|
169
|
-
e.preventDefault();
|
|
170
|
-
active = (active + 1) % menu.items.length;
|
|
171
|
-
break;
|
|
172
|
-
case 'ArrowUp':
|
|
173
|
-
e.preventDefault();
|
|
174
|
-
active = (active - 1 + menu.items.length) % menu.items.length;
|
|
175
|
-
break;
|
|
176
|
-
case 'Enter':
|
|
177
|
-
case 'Tab':
|
|
178
|
-
e.preventDefault();
|
|
179
|
-
accept(active);
|
|
180
|
-
break;
|
|
181
|
-
case 'Escape':
|
|
182
|
-
e.preventDefault();
|
|
183
|
-
open = false;
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function submit() {
|
|
189
|
-
open = false;
|
|
190
|
-
onsubmit?.(value);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function removeChip(span: [number, number]) {
|
|
194
|
-
// Splice the clause out, plus trailing spaces to avoid doubles.
|
|
195
|
-
const [a, b] = span;
|
|
196
|
-
let end = b;
|
|
197
|
-
while (value[end] === ' ') end++;
|
|
198
|
-
value = (value.slice(0, a) + value.slice(end)).replace(/\s{2,}/g, ' ').trim();
|
|
199
|
-
onsubmit?.(value);
|
|
200
|
-
queueMicrotask(() => el?.focus());
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const kindLabel: Record<string, string> = {
|
|
204
|
-
field: 'Fields',
|
|
205
|
-
operator: 'Operators',
|
|
206
|
-
value: 'Values'
|
|
207
|
-
};
|
|
208
51
|
</script>
|
|
209
52
|
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
/>
|
|
229
|
-
{#if value}
|
|
230
|
-
<button
|
|
231
|
-
type="button"
|
|
232
|
-
class="fsb__clear"
|
|
233
|
-
title="Clear"
|
|
234
|
-
onclick={() => {
|
|
235
|
-
value = '';
|
|
236
|
-
onsubmit?.('');
|
|
237
|
-
el?.focus();
|
|
238
|
-
}}
|
|
239
|
-
>
|
|
240
|
-
<Icon name="x" label="Clear" />
|
|
241
|
-
</button>
|
|
242
|
-
{/if}
|
|
243
|
-
</div>
|
|
244
|
-
|
|
245
|
-
{#if open && menu}
|
|
246
|
-
<div class="fsb__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
|
|
247
|
-
<div class="fsb__menu-head">{kindLabel[menu.kind]}</div>
|
|
248
|
-
{#each menu.items as item, i (item.insert + i)}
|
|
249
|
-
<button
|
|
250
|
-
type="button"
|
|
251
|
-
class="fsb__opt"
|
|
252
|
-
class:is-active={i === active}
|
|
253
|
-
role="option"
|
|
254
|
-
aria-selected={i === active}
|
|
255
|
-
onmousedown={(e) => e.preventDefault()}
|
|
256
|
-
onmouseenter={() => (active = i)}
|
|
257
|
-
onclick={() => accept(i)}
|
|
258
|
-
>
|
|
259
|
-
<span class="fsb__opt-label">{item.label}</span>
|
|
260
|
-
{#if item.hint}<span class="fsb__opt-hint">{item.hint}</span>{/if}
|
|
261
|
-
{#if item.code}<span class="fsb__opt-code">{item.code}</span>{/if}
|
|
262
|
-
</button>
|
|
263
|
-
{/each}
|
|
264
|
-
</div>
|
|
265
|
-
{/if}
|
|
266
|
-
</div>
|
|
267
|
-
|
|
268
|
-
{#if showChips && (chips.length || text)}
|
|
269
|
-
<div class="fsb__chips">
|
|
270
|
-
{#each chips as f (f.span[0])}
|
|
271
|
-
<Badge tone="info" removable onremove={() => removeChip(f.span)}>
|
|
272
|
-
<span class="fsb__chip-field">{labelFor(f.field)}</span>
|
|
273
|
-
<span class="fsb__chip-op">{operatorById(f.op)?.label}</span>
|
|
274
|
-
<span class="fsb__chip-val">{f.values.join(', ') || '∅'}</span>
|
|
275
|
-
</Badge>
|
|
276
|
-
{/each}
|
|
277
|
-
{#if text}
|
|
278
|
-
<Badge tone="neutral">
|
|
279
|
-
<span class="fsb__chip-op">text</span>
|
|
280
|
-
<span class="fsb__chip-val">{text}</span>
|
|
281
|
-
</Badge>
|
|
53
|
+
<FilterInput {schema} bind:value {placeholder} {autoQuote} {onchange} {onsubmit}>
|
|
54
|
+
{#snippet below({ filters: chips, text, remove })}
|
|
55
|
+
{#if showChips && (chips.length || text)}
|
|
56
|
+
<div class="fsb__chips">
|
|
57
|
+
{#each chips as f (f.span[0])}
|
|
58
|
+
<Badge tone="info" removable onremove={() => remove(f.span)}>
|
|
59
|
+
<span class="fsb__chip-field">{labelFor(f.field)}</span>
|
|
60
|
+
<span class="fsb__chip-op">{operatorById(f.op)?.label}</span>
|
|
61
|
+
<span class="fsb__chip-val">{f.values.join(', ') || '∅'}</span>
|
|
62
|
+
</Badge>
|
|
63
|
+
{/each}
|
|
64
|
+
{#if text}
|
|
65
|
+
<Badge tone="neutral">
|
|
66
|
+
<span class="fsb__chip-op">text</span>
|
|
67
|
+
<span class="fsb__chip-val">{text}</span>
|
|
68
|
+
</Badge>
|
|
69
|
+
{/if}
|
|
70
|
+
</div>
|
|
282
71
|
{/if}
|
|
283
|
-
|
|
284
|
-
|
|
72
|
+
{/snippet}
|
|
73
|
+
</FilterInput>
|
|
285
74
|
|
|
286
75
|
<style>
|
|
287
|
-
.fsb {
|
|
288
|
-
position: relative;
|
|
289
|
-
}
|
|
290
|
-
.fsb__bar {
|
|
291
|
-
display: flex;
|
|
292
|
-
align-items: center;
|
|
293
|
-
gap: var(--sp-2);
|
|
294
|
-
padding: 0 var(--sp-3);
|
|
295
|
-
height: 44px;
|
|
296
|
-
background: var(--bg-elevated);
|
|
297
|
-
border: 1px solid var(--border-strong);
|
|
298
|
-
border-radius: var(--r-md);
|
|
299
|
-
}
|
|
300
|
-
.fsb__bar:focus-within {
|
|
301
|
-
border-color: var(--accent);
|
|
302
|
-
box-shadow: 0 0 0 3px var(--accent-dim);
|
|
303
|
-
}
|
|
304
|
-
.fsb__icon {
|
|
305
|
-
display: flex;
|
|
306
|
-
color: var(--text-faint);
|
|
307
|
-
}
|
|
308
|
-
.fsb__input {
|
|
309
|
-
flex: 1;
|
|
310
|
-
border: 0;
|
|
311
|
-
background: transparent;
|
|
312
|
-
color: var(--text);
|
|
313
|
-
font-family: var(--font-mono);
|
|
314
|
-
font-size: var(--fs-md);
|
|
315
|
-
outline: none;
|
|
316
|
-
min-width: 0;
|
|
317
|
-
}
|
|
318
|
-
.fsb__input::placeholder {
|
|
319
|
-
color: var(--text-faint);
|
|
320
|
-
}
|
|
321
|
-
.fsb__clear {
|
|
322
|
-
display: flex;
|
|
323
|
-
border: 0;
|
|
324
|
-
background: transparent;
|
|
325
|
-
color: var(--text-faint);
|
|
326
|
-
cursor: pointer;
|
|
327
|
-
padding: var(--sp-1);
|
|
328
|
-
border-radius: var(--r-sm);
|
|
329
|
-
}
|
|
330
|
-
.fsb__clear:hover {
|
|
331
|
-
color: var(--text);
|
|
332
|
-
background: var(--bg-elevated-2);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
.fsb__menu {
|
|
336
|
-
position: absolute;
|
|
337
|
-
top: calc(100% + 4px);
|
|
338
|
-
left: 0;
|
|
339
|
-
right: 0;
|
|
340
|
-
z-index: var(--z-drawer);
|
|
341
|
-
background: var(--bg-elevated);
|
|
342
|
-
border: 1px solid var(--border-strong);
|
|
343
|
-
border-radius: var(--r-md);
|
|
344
|
-
box-shadow: var(--shadow-lg);
|
|
345
|
-
padding: var(--sp-1);
|
|
346
|
-
max-height: 320px;
|
|
347
|
-
overflow-y: auto;
|
|
348
|
-
}
|
|
349
|
-
.fsb__menu-head {
|
|
350
|
-
font-size: var(--fs-xs);
|
|
351
|
-
text-transform: uppercase;
|
|
352
|
-
letter-spacing: 0.06em;
|
|
353
|
-
color: var(--text-faint);
|
|
354
|
-
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
|
355
|
-
}
|
|
356
|
-
.fsb__opt {
|
|
357
|
-
display: flex;
|
|
358
|
-
align-items: baseline;
|
|
359
|
-
gap: var(--sp-2);
|
|
360
|
-
width: 100%;
|
|
361
|
-
text-align: left;
|
|
362
|
-
border: 0;
|
|
363
|
-
background: transparent;
|
|
364
|
-
color: var(--text);
|
|
365
|
-
padding: var(--sp-2);
|
|
366
|
-
border-radius: var(--r-sm);
|
|
367
|
-
cursor: pointer;
|
|
368
|
-
font-size: var(--fs-sm);
|
|
369
|
-
}
|
|
370
|
-
.fsb__opt.is-active {
|
|
371
|
-
background: var(--accent-dim);
|
|
372
|
-
}
|
|
373
|
-
.fsb__opt-label {
|
|
374
|
-
font-weight: var(--fw-medium);
|
|
375
|
-
}
|
|
376
|
-
.fsb__opt-hint {
|
|
377
|
-
color: var(--text-faint);
|
|
378
|
-
font-size: var(--fs-xs);
|
|
379
|
-
}
|
|
380
|
-
.fsb__opt-code {
|
|
381
|
-
margin-left: auto;
|
|
382
|
-
font-family: var(--font-mono);
|
|
383
|
-
font-size: var(--fs-xs);
|
|
384
|
-
color: var(--text-muted);
|
|
385
|
-
background: var(--bg-elevated-2);
|
|
386
|
-
padding: 0 var(--sp-1);
|
|
387
|
-
border-radius: var(--r-sm);
|
|
388
|
-
}
|
|
389
|
-
|
|
390
76
|
.fsb__chips {
|
|
391
77
|
display: flex;
|
|
392
78
|
flex-wrap: wrap;
|
package/dist/index.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export { default as Dropzone } from './components/molecules/Dropzone.svelte';
|
|
|
31
31
|
export { default as EmptyState } from './components/molecules/EmptyState.svelte';
|
|
32
32
|
export { default as Field } from './components/molecules/Field.svelte';
|
|
33
33
|
export { default as FileButton } from './components/molecules/FileButton.svelte';
|
|
34
|
+
export { default as FilterInput, type FilterInputContext, } from './components/molecules/FilterInput.svelte';
|
|
34
35
|
export { default as FontScalePicker } from './components/molecules/FontScalePicker.svelte';
|
|
35
36
|
export { default as IconButton } from './components/molecules/IconButton.svelte';
|
|
36
37
|
export { default as Menu, type MenuItem } from './components/molecules/Menu.svelte';
|
package/dist/index.js
CHANGED
|
@@ -39,6 +39,7 @@ export { default as EmptyState } from './components/molecules/EmptyState.svelte'
|
|
|
39
39
|
// ---- molecules ----
|
|
40
40
|
export { default as Field } from './components/molecules/Field.svelte';
|
|
41
41
|
export { default as FileButton } from './components/molecules/FileButton.svelte';
|
|
42
|
+
export { default as FilterInput, } from './components/molecules/FilterInput.svelte';
|
|
42
43
|
export { default as FontScalePicker } from './components/molecules/FontScalePicker.svelte';
|
|
43
44
|
export { default as IconButton } from './components/molecules/IconButton.svelte';
|
|
44
45
|
export { default as Menu } from './components/molecules/Menu.svelte';
|
package/package.json
CHANGED