@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.
@@ -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
- // The component owns parsing, suggesting and keyboard/dropdown/chips all of
11
- // it generic. Everything project-specific is INJECTED:
12
- // `schema` — the field registry (drives autocomplete + the chips).
13
- // Per-field async `provider`s feed value autocomplete; static
14
- // `options` cover enums. No `fetch` lives in here.
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 Icon from '../atoms/Icon.svelte';
22
- import { filters, freeText, type Query } from '../../query/ast';
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
- <div class="fsb" data-tsu="FilterSearchBar">
211
- <div class="fsb__bar">
212
- <span class="fsb__icon"><Icon name="search" label="Search" /></span>
213
- <input
214
- bind:this={el}
215
- bind:value
216
- class="fsb__input"
217
- spellcheck="false"
218
- autocomplete="off"
219
- {placeholder}
220
- {oninput}
221
- onclick={refresh}
222
- onkeyup={(e) => {
223
- if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') refresh();
224
- }}
225
- onfocus={refresh}
226
- onblur={() => setTimeout(() => (open = false), 120)}
227
- {onkeydown}
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
- </div>
284
- {/if}
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;
@@ -1,4 +1,4 @@
1
- import { type Query } from '../../query/ast';
1
+ import type { Query } from '../../query/ast';
2
2
  import { type Schema } from '../../query/schema';
3
3
  type $$ComponentProps = {
4
4
  schema: Schema;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",