@dorsk/tsumikit 0.8.2 → 0.9.1

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,340 @@
1
+ <script lang="ts">
2
+ // ───────────────────────────────────────────────────────────────────────
3
+ // FilterSearchBar organism — a YouTrack-style structured search bar.
4
+ //
5
+ // The textual query IS the single source of truth: the bar is a small query
6
+ // language, so a user can type `artist:"Daft Punk" AND year>=2000` by hand
7
+ // (pure-code mode) OR build it entirely from the context-aware dropdown
8
+ // (visual helper mode). Both edit the same string.
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.
19
+ // ───────────────────────────────────────────────────────────────────────
20
+ import Badge from '../atoms/Badge.svelte';
21
+ import Icon from '../atoms/Icon.svelte';
22
+ import { filters, freeText, type Query } from '../../query/ast';
23
+ import { findField, operatorById, type Schema } from '../../query/schema';
24
+ import { parse } from '../../query/parser';
25
+ import { suggest, type SuggestState } from '../../query/suggest';
26
+
27
+ let {
28
+ schema,
29
+ value = $bindable(''),
30
+ placeholder = 'artist:"Daft Punk" AND year>=2000',
31
+ showChips = true,
32
+ onchange,
33
+ onsubmit
34
+ }: {
35
+ schema: Schema;
36
+ /** The raw textual query (two-way bindable). */
37
+ value?: string;
38
+ placeholder?: string;
39
+ /** Render the parsed, removable filter chips under the bar. */
40
+ showChips?: boolean;
41
+ /** Fires the parsed AST on every change — feed this to your backend. */
42
+ onchange?: (query: Query, raw: string) => void;
43
+ /** Fires on Enter / clear / chip-remove with the raw query string. */
44
+ onsubmit?: (value: string) => void;
45
+ } = $props();
46
+
47
+ let el = $state<HTMLInputElement | null>(null);
48
+ let menu = $state<SuggestState | null>(null);
49
+ let active = $state(0);
50
+ let open = $state(false);
51
+ let reqId = 0;
52
+
53
+ // Parsed view (drives the removable chips + the onchange AST).
54
+ const ast = $derived(parse(value, schema));
55
+ const chips = $derived(filters(ast));
56
+ const text = $derived(freeText(ast));
57
+
58
+ // Emit the AST whenever the parse result changes.
59
+ $effect(() => {
60
+ onchange?.(ast, value);
61
+ });
62
+
63
+ function labelFor(fieldName: string): string {
64
+ return findField(schema, fieldName)?.label ?? fieldName;
65
+ }
66
+
67
+ async function refresh() {
68
+ if (!el) return;
69
+ const pos = el.selectionStart ?? value.length;
70
+ const id = ++reqId;
71
+ const next = await suggest(schema, value, pos);
72
+ if (id !== reqId) return; // a newer keystroke won
73
+ menu = next;
74
+ active = 0;
75
+ open = !!next && next.items.length > 0;
76
+ }
77
+
78
+ function accept(i: number) {
79
+ if (!menu || !el) return;
80
+ const s = menu.items[i];
81
+ if (!s) return;
82
+ const [a, b] = menu.span;
83
+ value = value.slice(0, a) + s.insert + value.slice(b);
84
+ // Restore caret after the inserted text, then re-run suggestions if the
85
+ // step advanced (field → operator → value) so the flow keeps going.
86
+ queueMicrotask(() => {
87
+ if (!el) return;
88
+ el.focus();
89
+ el.setSelectionRange(s.caret, s.caret);
90
+ if (s.advance) refresh();
91
+ else open = false;
92
+ });
93
+ }
94
+
95
+ function onkeydown(e: KeyboardEvent) {
96
+ if (!open || !menu) {
97
+ if (e.key === 'Enter') submit();
98
+ return;
99
+ }
100
+ switch (e.key) {
101
+ case 'ArrowDown':
102
+ e.preventDefault();
103
+ active = (active + 1) % menu.items.length;
104
+ break;
105
+ case 'ArrowUp':
106
+ e.preventDefault();
107
+ active = (active - 1 + menu.items.length) % menu.items.length;
108
+ break;
109
+ case 'Enter':
110
+ case 'Tab':
111
+ e.preventDefault();
112
+ accept(active);
113
+ break;
114
+ case 'Escape':
115
+ e.preventDefault();
116
+ open = false;
117
+ break;
118
+ }
119
+ }
120
+
121
+ function submit() {
122
+ open = false;
123
+ onsubmit?.(value);
124
+ }
125
+
126
+ function removeChip(span: [number, number]) {
127
+ // Splice the clause out, plus trailing spaces to avoid doubles.
128
+ const [a, b] = span;
129
+ let end = b;
130
+ while (value[end] === ' ') end++;
131
+ value = (value.slice(0, a) + value.slice(end)).replace(/\s{2,}/g, ' ').trim();
132
+ onsubmit?.(value);
133
+ queueMicrotask(() => el?.focus());
134
+ }
135
+
136
+ const kindLabel: Record<string, string> = {
137
+ field: 'Fields',
138
+ operator: 'Operators',
139
+ value: 'Values'
140
+ };
141
+ </script>
142
+
143
+ <div class="fsb" data-tsu="FilterSearchBar">
144
+ <div class="fsb__bar">
145
+ <span class="fsb__icon"><Icon name="search" label="Search" /></span>
146
+ <input
147
+ bind:this={el}
148
+ bind:value
149
+ class="fsb__input"
150
+ spellcheck="false"
151
+ autocomplete="off"
152
+ {placeholder}
153
+ oninput={refresh}
154
+ onclick={refresh}
155
+ onkeyup={(e) => {
156
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') refresh();
157
+ }}
158
+ onfocus={refresh}
159
+ onblur={() => setTimeout(() => (open = false), 120)}
160
+ {onkeydown}
161
+ />
162
+ {#if value}
163
+ <button
164
+ type="button"
165
+ class="fsb__clear"
166
+ title="Clear"
167
+ onclick={() => {
168
+ value = '';
169
+ onsubmit?.('');
170
+ el?.focus();
171
+ }}
172
+ >
173
+ <Icon name="x" label="Clear" />
174
+ </button>
175
+ {/if}
176
+ </div>
177
+
178
+ {#if open && menu}
179
+ <div class="fsb__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
180
+ <div class="fsb__menu-head">{kindLabel[menu.kind]}</div>
181
+ {#each menu.items as item, i (item.insert + i)}
182
+ <button
183
+ type="button"
184
+ class="fsb__opt"
185
+ class:is-active={i === active}
186
+ role="option"
187
+ aria-selected={i === active}
188
+ onmousedown={(e) => e.preventDefault()}
189
+ onmouseenter={() => (active = i)}
190
+ onclick={() => accept(i)}
191
+ >
192
+ <span class="fsb__opt-label">{item.label}</span>
193
+ {#if item.hint}<span class="fsb__opt-hint">{item.hint}</span>{/if}
194
+ {#if item.code}<span class="fsb__opt-code">{item.code}</span>{/if}
195
+ </button>
196
+ {/each}
197
+ </div>
198
+ {/if}
199
+ </div>
200
+
201
+ {#if showChips && (chips.length || text)}
202
+ <div class="fsb__chips">
203
+ {#each chips as f (f.span[0])}
204
+ <Badge tone="info" removable onremove={() => removeChip(f.span)}>
205
+ <span class="fsb__chip-field">{labelFor(f.field)}</span>
206
+ <span class="fsb__chip-op">{operatorById(f.op)?.label}</span>
207
+ <span class="fsb__chip-val">{f.values.join(', ') || '∅'}</span>
208
+ </Badge>
209
+ {/each}
210
+ {#if text}
211
+ <Badge tone="neutral">
212
+ <span class="fsb__chip-op">text</span>
213
+ <span class="fsb__chip-val">{text}</span>
214
+ </Badge>
215
+ {/if}
216
+ </div>
217
+ {/if}
218
+
219
+ <style>
220
+ .fsb {
221
+ position: relative;
222
+ }
223
+ .fsb__bar {
224
+ display: flex;
225
+ align-items: center;
226
+ gap: var(--sp-2);
227
+ padding: 0 var(--sp-3);
228
+ height: 44px;
229
+ background: var(--bg-elevated);
230
+ border: 1px solid var(--border-strong);
231
+ border-radius: var(--r-md);
232
+ }
233
+ .fsb__bar:focus-within {
234
+ border-color: var(--accent);
235
+ box-shadow: 0 0 0 3px var(--accent-dim);
236
+ }
237
+ .fsb__icon {
238
+ display: flex;
239
+ color: var(--text-faint);
240
+ }
241
+ .fsb__input {
242
+ flex: 1;
243
+ border: 0;
244
+ background: transparent;
245
+ color: var(--text);
246
+ font-family: var(--font-mono);
247
+ font-size: var(--fs-md);
248
+ outline: none;
249
+ min-width: 0;
250
+ }
251
+ .fsb__input::placeholder {
252
+ color: var(--text-faint);
253
+ }
254
+ .fsb__clear {
255
+ display: flex;
256
+ border: 0;
257
+ background: transparent;
258
+ color: var(--text-faint);
259
+ cursor: pointer;
260
+ padding: var(--sp-1);
261
+ border-radius: var(--r-sm);
262
+ }
263
+ .fsb__clear:hover {
264
+ color: var(--text);
265
+ background: var(--bg-elevated-2);
266
+ }
267
+
268
+ .fsb__menu {
269
+ position: absolute;
270
+ top: calc(100% + 4px);
271
+ left: 0;
272
+ right: 0;
273
+ z-index: var(--z-drawer);
274
+ background: var(--bg-elevated);
275
+ border: 1px solid var(--border-strong);
276
+ border-radius: var(--r-md);
277
+ box-shadow: var(--shadow-lg);
278
+ padding: var(--sp-1);
279
+ max-height: 320px;
280
+ overflow-y: auto;
281
+ }
282
+ .fsb__menu-head {
283
+ font-size: var(--fs-xs);
284
+ text-transform: uppercase;
285
+ letter-spacing: 0.06em;
286
+ color: var(--text-faint);
287
+ padding: var(--sp-2) var(--sp-2) var(--sp-1);
288
+ }
289
+ .fsb__opt {
290
+ display: flex;
291
+ align-items: baseline;
292
+ gap: var(--sp-2);
293
+ width: 100%;
294
+ text-align: left;
295
+ border: 0;
296
+ background: transparent;
297
+ color: var(--text);
298
+ padding: var(--sp-2);
299
+ border-radius: var(--r-sm);
300
+ cursor: pointer;
301
+ font-size: var(--fs-sm);
302
+ }
303
+ .fsb__opt.is-active {
304
+ background: var(--accent-dim);
305
+ }
306
+ .fsb__opt-label {
307
+ font-weight: var(--fw-medium);
308
+ }
309
+ .fsb__opt-hint {
310
+ color: var(--text-faint);
311
+ font-size: var(--fs-xs);
312
+ }
313
+ .fsb__opt-code {
314
+ margin-left: auto;
315
+ font-family: var(--font-mono);
316
+ font-size: var(--fs-xs);
317
+ color: var(--text-muted);
318
+ background: var(--bg-elevated-2);
319
+ padding: 0 var(--sp-1);
320
+ border-radius: var(--r-sm);
321
+ }
322
+
323
+ .fsb__chips {
324
+ display: flex;
325
+ flex-wrap: wrap;
326
+ gap: var(--sp-2);
327
+ margin-top: var(--sp-3);
328
+ }
329
+ .fsb__chip-field {
330
+ font-weight: var(--fw-semibold);
331
+ }
332
+ .fsb__chip-op {
333
+ opacity: 0.7;
334
+ font-style: italic;
335
+ margin: 0 var(--sp-1);
336
+ }
337
+ .fsb__chip-val {
338
+ font-family: var(--font-mono);
339
+ }
340
+ </style>
@@ -0,0 +1,17 @@
1
+ import { type Query } from '../../query/ast';
2
+ import { type Schema } from '../../query/schema';
3
+ type $$ComponentProps = {
4
+ schema: Schema;
5
+ /** The raw textual query (two-way bindable). */
6
+ value?: string;
7
+ placeholder?: string;
8
+ /** Render the parsed, removable filter chips under the bar. */
9
+ showChips?: boolean;
10
+ /** Fires the parsed AST on every change — feed this to your backend. */
11
+ onchange?: (query: Query, raw: string) => void;
12
+ /** Fires on Enter / clear / chip-remove with the raw query string. */
13
+ onsubmit?: (value: string) => void;
14
+ };
15
+ declare const FilterSearchBar: import("svelte").Component<$$ComponentProps, {}, "value">;
16
+ type FilterSearchBar = ReturnType<typeof FilterSearchBar>;
17
+ export default FilterSearchBar;
package/dist/index.d.ts CHANGED
@@ -48,6 +48,8 @@ export { default as Toggle } from './components/molecules/Toggle.svelte';
48
48
  export { default as Tooltip } from './components/molecules/Tooltip.svelte';
49
49
  export { default as Truncate } from './components/molecules/Truncate.svelte';
50
50
  export { type Column, default as DataTable } from './components/organisms/DataTable.svelte';
51
+ export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
52
+ export { activeToken, compilePredicate, defaultOperator, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, OPERATORS, type Operator, type OperatorId, operatorByCode, operatorById, operatorsFor, parse, type Query, type QueryNode, resolveValues, type Schema, type Suggestion, type SuggestKind, type SuggestState, serialize, serializeFilter, suggest, type TextNode, toSql, type ValueOption, type ValueProvider, } from './query';
51
53
  export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
52
54
  export { type Mode, THEMES, theme } from './stores/theme.svelte';
53
55
  export { type Toast, type ToastTone, toasts } from './stores/toast.svelte';
package/dist/index.js CHANGED
@@ -59,6 +59,9 @@ export { default as Tooltip } from './components/molecules/Tooltip.svelte';
59
59
  export { default as Truncate } from './components/molecules/Truncate.svelte';
60
60
  // ---- organisms ----
61
61
  export { default as DataTable } from './components/organisms/DataTable.svelte';
62
+ export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
63
+ // ---- query core (headless: schema / parser / AST / suggest / compilers) ----
64
+ export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, suggest, toSql, } from './query';
62
65
  export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
63
66
  // ---- stores / actions ----
64
67
  export { THEMES, theme } from './stores/theme.svelte';
@@ -0,0 +1,24 @@
1
+ import type { OperatorId } from './schema';
2
+ /** A single `field op value(s)` clause. */
3
+ export interface FilterNode {
4
+ kind: 'filter';
5
+ field: string;
6
+ op: OperatorId;
7
+ /** One value for most ops; two for range; many for `in`. */
8
+ values: string[];
9
+ /** Source span [start, end) in the raw string — used by the UI for editing. */
10
+ span: [number, number];
11
+ }
12
+ /** Leftover free-text term (full-text / fuzzy search on the backend). */
13
+ export interface TextNode {
14
+ kind: 'text';
15
+ value: string;
16
+ span: [number, number];
17
+ }
18
+ export type QueryNode = FilterNode | TextNode;
19
+ export interface Query {
20
+ /** Clauses are AND-combined (YouTrack semantics). */
21
+ nodes: QueryNode[];
22
+ }
23
+ export declare function filters(q: Query): FilterNode[];
24
+ export declare function freeText(q: Query): string;
@@ -0,0 +1,16 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // The AST — the serialisable contract that the parser produces and that BOTH
3
+ // the autocomplete UI and the backend compiler consume. The frontend never
4
+ // asks the server to re-parse free text; it ships this tree (or the server
5
+ // runs the same parser and gets the same tree).
6
+ // ─────────────────────────────────────────────────────────────────────────
7
+ export function filters(q) {
8
+ return q.nodes.filter((n) => n.kind === 'filter');
9
+ }
10
+ export function freeText(q) {
11
+ return q.nodes
12
+ .filter((n) => n.kind === 'text')
13
+ .map((n) => n.value)
14
+ .join(' ')
15
+ .trim();
16
+ }
@@ -0,0 +1,8 @@
1
+ export type { FilterNode, Query, QueryNode, TextNode } from './ast';
2
+ export { filters, freeText } from './ast';
3
+ export { parse } from './parser';
4
+ export { compilePredicate, serialize, serializeFilter, toSql } from './query';
5
+ export type { FieldDef, FieldType, Operator, OperatorId, Schema, ValueOption, ValueProvider, } from './schema';
6
+ export { defaultOperator, findField, OPERATORS, operatorByCode, operatorById, operatorsFor, resolveValues, } from './schema';
7
+ export type { Suggestion, SuggestKind, SuggestState } from './suggest';
8
+ export { activeToken, suggest } from './suggest';
@@ -0,0 +1,9 @@
1
+ // Headless query core for FilterSearchBar — schema registry, tolerant parser,
2
+ // serialisable AST, autocomplete engine and AST compilers. Framework-agnostic:
3
+ // no Svelte, no DOM. A host app supplies a `Schema` (optionally with async
4
+ // `ValueProvider`s) and consumes the `Query` AST against its own backend.
5
+ export { filters, freeText } from './ast';
6
+ export { parse } from './parser';
7
+ export { compilePredicate, serialize, serializeFilter, toSql } from './query';
8
+ export { defaultOperator, findField, OPERATORS, operatorByCode, operatorById, operatorsFor, resolveValues, } from './schema';
9
+ export { activeToken, suggest } from './suggest';
@@ -0,0 +1,3 @@
1
+ import type { Query } from './ast';
2
+ import { type Schema } from './schema';
3
+ export declare function parse(input: string, schema: Schema): Query;
@@ -0,0 +1,181 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Layer 2 — a tolerant, error-recovering parser.
3
+ //
4
+ // Hand-written scanner (not a strict grammar generator) precisely BECAUSE the
5
+ // operators are "free for all": we must parse partial input (`artist:` with no
6
+ // value, for autocomplete) and slightly-wrong input (a malformed clause must
7
+ // degrade to free text, never throw). Output is the serialisable AST in ast.ts.
8
+ //
9
+ // Textual forms understood:
10
+ // artist:"Daft Punk" → contains
11
+ // artist!:bootleg → not_contains
12
+ // year>=2021 released<2020 → numeric/date comparisons
13
+ // status=released by!=me → is / is not
14
+ // genre in (house, techno) → any of
15
+ // year..2021..2024 → range (field..a..b) also field:a..b
16
+ // free floating words → TextNode (full-text)
17
+ // AND / and → ignored separator (clauses are implicitly AND)
18
+ // ─────────────────────────────────────────────────────────────────────────
19
+ import { findField, operatorByCode, operatorsFor } from './schema';
20
+ const IDENT = /[A-Za-z_][\w-]*/y;
21
+ // Longest operator codes first so `>=` wins over `>`.
22
+ const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
23
+ function skipWs(sc) {
24
+ while (sc.i < sc.s.length && /\s/.test(sc.s[sc.i]))
25
+ sc.i++;
26
+ }
27
+ /** Read a value token: a "quoted string", or a bare run up to whitespace. */
28
+ function readValue(sc) {
29
+ skipWsInline(sc);
30
+ if (sc.s[sc.i] === '"' || sc.s[sc.i] === "'") {
31
+ const quote = sc.s[sc.i];
32
+ const start = ++sc.i;
33
+ while (sc.i < sc.s.length && sc.s[sc.i] !== quote)
34
+ sc.i++;
35
+ const value = sc.s.slice(start, sc.i);
36
+ if (sc.s[sc.i] === quote)
37
+ sc.i++; // consume closing quote
38
+ return { value, end: sc.i };
39
+ }
40
+ const start = sc.i;
41
+ while (sc.i < sc.s.length &&
42
+ !/\s/.test(sc.s[sc.i]) &&
43
+ sc.s[sc.i] !== ',' &&
44
+ sc.s[sc.i] !== ')' &&
45
+ // stop at a `..` range separator so `a..b` splits into two values
46
+ !sc.s.startsWith('..', sc.i))
47
+ sc.i++;
48
+ return { value: sc.s.slice(start, sc.i), end: sc.i };
49
+ }
50
+ // Only skip spaces/tabs, never cross to a new token boundary semantics; used so
51
+ // `artist: foo` (space after colon) still attaches `foo` as the value.
52
+ function skipWsInline(sc) {
53
+ while (sc.i < sc.s.length && (sc.s[sc.i] === ' ' || sc.s[sc.i] === '\t'))
54
+ sc.i++;
55
+ }
56
+ function matchOp(s, i) {
57
+ for (const code of OP_CODES) {
58
+ if (s.startsWith(code, i))
59
+ return { code, len: code.length };
60
+ }
61
+ return null;
62
+ }
63
+ export function parse(input, schema) {
64
+ const sc = { s: input, i: 0 };
65
+ const nodes = [];
66
+ while (sc.i < sc.s.length) {
67
+ skipWs(sc);
68
+ if (sc.i >= sc.s.length)
69
+ break;
70
+ const clauseStart = sc.i;
71
+ // Try to read an identifier (potential field name).
72
+ IDENT.lastIndex = sc.i;
73
+ const m = IDENT.exec(sc.s);
74
+ if (m && m.index === sc.i) {
75
+ const ident = m[0];
76
+ const afterIdent = sc.i + ident.length;
77
+ // `AND` / `and` — implicit-AND separator, skip it.
78
+ if (ident.toUpperCase() === 'AND') {
79
+ sc.i = afterIdent;
80
+ continue;
81
+ }
82
+ const field = findField(schema, ident);
83
+ // Look for an operator right after the identifier (allow inline spaces).
84
+ let j = afterIdent;
85
+ while (sc.s[j] === ' ')
86
+ j++;
87
+ const inKeyword = sc.s.slice(j, j + 3).toLowerCase() === 'in ' || sc.s.slice(j) === 'in';
88
+ const opMatch = matchOp(sc.s, j);
89
+ if (field && (opMatch || inKeyword)) {
90
+ const node = readFilter(sc, field, clauseStart, j, opMatch, inKeyword);
91
+ if (node) {
92
+ nodes.push(node);
93
+ continue;
94
+ }
95
+ }
96
+ }
97
+ // Fallback: consume one whitespace-delimited word as free text.
98
+ const wStart = sc.i;
99
+ while (sc.i < sc.s.length && !/\s/.test(sc.s[sc.i]))
100
+ sc.i++;
101
+ const word = sc.s.slice(wStart, sc.i);
102
+ if (word) {
103
+ const last = nodes[nodes.length - 1];
104
+ if (last && last.kind === 'text') {
105
+ last.value += ` ${word}`;
106
+ last.span[1] = sc.i;
107
+ }
108
+ else {
109
+ nodes.push({ kind: 'text', value: word, span: [wStart, sc.i] });
110
+ }
111
+ }
112
+ }
113
+ return { nodes };
114
+ }
115
+ function readFilter(sc, field, clauseStart, opPos, opMatch, inKeyword) {
116
+ const legal = operatorsFor(field);
117
+ if (inKeyword) {
118
+ sc.i = opPos + 2; // past `in`
119
+ skipWsInline(sc);
120
+ if (sc.s[sc.i] === '(')
121
+ sc.i++;
122
+ const values = [];
123
+ while (sc.i < sc.s.length && sc.s[sc.i] !== ')') {
124
+ skipWsInline(sc);
125
+ if (sc.s[sc.i] === ',') {
126
+ sc.i++;
127
+ continue;
128
+ }
129
+ if (sc.s[sc.i] === ')')
130
+ break;
131
+ const { value } = readValue(sc);
132
+ if (value)
133
+ values.push(value);
134
+ else
135
+ break;
136
+ }
137
+ if (sc.s[sc.i] === ')')
138
+ sc.i++;
139
+ const op = legal.find((o) => o.id === 'in');
140
+ if (!op)
141
+ return null;
142
+ return { kind: 'filter', field: field.name, op: 'in', values, span: [clauseStart, sc.i] };
143
+ }
144
+ if (!opMatch)
145
+ return null;
146
+ const opCode = opMatch.code;
147
+ let op = operatorByCode(opCode);
148
+ // `:` means contains for strings but `is` (eq) for non-string fields.
149
+ if (op && !legal.some((o) => o.id === op?.id)) {
150
+ if (opCode === ':')
151
+ op = legal.find((o) => o.id === 'eq') ?? op;
152
+ }
153
+ if (!op || !legal.some((o) => o.id === op?.id)) {
154
+ // Operator not legal for this field → degrade: don't consume, treat as text.
155
+ return null;
156
+ }
157
+ sc.i = opPos + opMatch.len;
158
+ const first = readValue(sc);
159
+ // Range form: a..b
160
+ if (sc.s.startsWith('..', sc.i)) {
161
+ sc.i += 2;
162
+ const second = readValue(sc);
163
+ const rangeOp = operatorsFor(field).find((o) => o.id === 'range');
164
+ if (rangeOp) {
165
+ return {
166
+ kind: 'filter',
167
+ field: field.name,
168
+ op: 'range',
169
+ values: [first.value, second.value],
170
+ span: [clauseStart, sc.i],
171
+ };
172
+ }
173
+ }
174
+ return {
175
+ kind: 'filter',
176
+ field: field.name,
177
+ op: op.id,
178
+ values: [first.value],
179
+ span: [clauseStart, sc.i],
180
+ };
181
+ }
@@ -0,0 +1,8 @@
1
+ import { type FilterNode, type Query } from './ast';
2
+ /** AST → canonical textual query (round-trips through the parser). */
3
+ export declare function serialize(q: Query): string;
4
+ export declare function serializeFilter(f: FilterNode): string;
5
+ export declare function toSql(q: Query, table?: string): string;
6
+ type Row = Record<string, unknown>;
7
+ export declare function compilePredicate(q: Query): (row: Row) => boolean;
8
+ export {};
@@ -0,0 +1,128 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // AST consumers: serialise back to the textual query (for the code editor and
3
+ // the URL), compile to a human-readable SQL-ish WHERE (to prove the filter
4
+ // "properly propagates to backend & database"), and compile to a JS predicate
5
+ // (our fake in-memory backend).
6
+ // ─────────────────────────────────────────────────────────────────────────
7
+ import { filters, freeText } from './ast';
8
+ import { operatorById } from './schema';
9
+ function quoteIfNeeded(v) {
10
+ return /[\s,()]/.test(v) || v === '' ? `"${v}"` : v;
11
+ }
12
+ /** AST → canonical textual query (round-trips through the parser). */
13
+ export function serialize(q) {
14
+ const parts = [];
15
+ for (const f of filters(q))
16
+ parts.push(serializeFilter(f));
17
+ const text = freeText(q);
18
+ if (text)
19
+ parts.push(text);
20
+ return parts.join(' ');
21
+ }
22
+ export function serializeFilter(f) {
23
+ const op = operatorById(f.op);
24
+ if (!op)
25
+ return '';
26
+ if (f.op === 'in')
27
+ return `${f.field} in (${f.values.map(quoteIfNeeded).join(', ')})`;
28
+ if (f.op === 'range')
29
+ return `${f.field}:${quoteIfNeeded(f.values[0])}..${quoteIfNeeded(f.values[1])}`;
30
+ return `${f.field}${op.code}${quoteIfNeeded(f.values[0] ?? '')}`;
31
+ }
32
+ // ── SQL-ish compiler (illustrative — what the server would emit) ────────────
33
+ const SQL_OP = {
34
+ eq: '=',
35
+ ne: '<>',
36
+ gt: '>',
37
+ gte: '>=',
38
+ lt: '<',
39
+ lte: '<=',
40
+ };
41
+ export function toSql(q, table = 'tracks') {
42
+ const clauses = [];
43
+ for (const f of filters(q)) {
44
+ const col = f.field;
45
+ const v = (s) => `'${s.replace(/'/g, "''")}'`;
46
+ switch (f.op) {
47
+ case 'contains':
48
+ clauses.push(`${col} ILIKE '%${f.values[0]}%'`);
49
+ break;
50
+ case 'not_contains':
51
+ clauses.push(`${col} NOT ILIKE '%${f.values[0]}%'`);
52
+ break;
53
+ case 'in':
54
+ clauses.push(`${col} IN (${f.values.map(v).join(', ')})`);
55
+ break;
56
+ case 'range':
57
+ clauses.push(`${col} BETWEEN ${v(f.values[0])} AND ${v(f.values[1])}`);
58
+ break;
59
+ default:
60
+ clauses.push(`${col} ${SQL_OP[f.op]} ${v(f.values[0] ?? '')}`);
61
+ }
62
+ }
63
+ const text = freeText(q);
64
+ if (text)
65
+ clauses.push(`to_tsvector(title) @@ plainto_tsquery(${`'${text.replace(/'/g, "''")}'`})`);
66
+ const where = clauses.length ? clauses.join('\n AND ') : 'TRUE';
67
+ return `SELECT * FROM ${table}\nWHERE ${where};`;
68
+ }
69
+ function asNumber(v) {
70
+ if (typeof v === 'number')
71
+ return v;
72
+ const s = String(v).trim();
73
+ // Plain integer/decimal (a year "2000", a play count) — take it literally.
74
+ // Guard against Date.parse swallowing "2000" as a year-timestamp.
75
+ if (/^-?\d+(\.\d+)?$/.test(s))
76
+ return Number(s);
77
+ const d = Date.parse(s); // real dates like "2021-02-01" compare by epoch ms
78
+ if (!Number.isNaN(d))
79
+ return d;
80
+ return Number(s);
81
+ }
82
+ function matchFilter(f, row) {
83
+ const cell = row[f.field];
84
+ const target = f.values[0] ?? '';
85
+ const cellStr = String(cell ?? '').toLowerCase();
86
+ switch (f.op) {
87
+ case 'contains':
88
+ return cellStr.includes(target.toLowerCase());
89
+ case 'not_contains':
90
+ return !cellStr.includes(target.toLowerCase());
91
+ case 'eq':
92
+ return cellStr === target.toLowerCase();
93
+ case 'ne':
94
+ return cellStr !== target.toLowerCase();
95
+ case 'in':
96
+ return f.values.map((x) => x.toLowerCase()).includes(cellStr);
97
+ case 'range':
98
+ return asNumber(cell) >= asNumber(f.values[0]) && asNumber(cell) <= asNumber(f.values[1]);
99
+ case 'gt':
100
+ return asNumber(cell) > asNumber(target);
101
+ case 'gte':
102
+ return asNumber(cell) >= asNumber(target);
103
+ case 'lt':
104
+ return asNumber(cell) < asNumber(target);
105
+ case 'lte':
106
+ return asNumber(cell) <= asNumber(target);
107
+ default:
108
+ return true;
109
+ }
110
+ }
111
+ export function compilePredicate(q) {
112
+ const fs = filters(q);
113
+ const text = freeText(q).toLowerCase();
114
+ return (row) => {
115
+ for (const f of fs) {
116
+ if (f.values.every((v) => v === ''))
117
+ continue; // ignore incomplete clauses
118
+ if (!matchFilter(f, row))
119
+ return false;
120
+ }
121
+ if (text) {
122
+ const hay = Object.values(row).join(' ').toLowerCase();
123
+ if (!hay.includes(text))
124
+ return false;
125
+ }
126
+ return true;
127
+ };
128
+ }
@@ -0,0 +1,60 @@
1
+ export type FieldType = 'string' | 'enum' | 'id' | 'date' | 'number' | 'bool';
2
+ /** A comparison operator. `code` is how it serialises in the textual query. */
3
+ export type OperatorId = 'contains' | 'not_contains' | 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'range';
4
+ export interface Operator {
5
+ id: OperatorId;
6
+ /** Human label shown in the operator dropdown (YouTrack "一致する" etc.). */
7
+ label: string;
8
+ /** Textual form used by the code parser/serialiser, e.g. `>=`, `:`, `!:`. */
9
+ code: string;
10
+ /** Types this operator can apply to. */
11
+ types: FieldType[];
12
+ /** range/in take two / many values. */
13
+ arity?: 'one' | 'two' | 'many';
14
+ }
15
+ export declare const OPERATORS: Operator[];
16
+ export interface ValueOption {
17
+ value: string;
18
+ label: string;
19
+ /** Optional secondary line (e.g. an email under a username). */
20
+ hint?: string;
21
+ }
22
+ /** Resolves candidate values for a field given the user's partial input. */
23
+ export type ValueProvider = (query: string) => ValueOption[] | Promise<ValueOption[]>;
24
+ export interface FieldDef {
25
+ /** Canonical name, used in the serialised query (e.g. `artist`). */
26
+ name: string;
27
+ /** Display label (e.g. `Artist`). */
28
+ label: string;
29
+ type: FieldType;
30
+ /** Alternate spellings accepted by the parser (e.g. `by` → assignee). */
31
+ aliases?: string[];
32
+ /** Static options, or omit and supply `provider` for async lookups. */
33
+ options?: ValueOption[];
34
+ provider?: ValueProvider;
35
+ /**
36
+ * Restrict the operators offered for this field to exactly this set (in
37
+ * catalogue order). When omitted, all operators legal for the field's `type`
38
+ * are offered. Use this to mirror a backend registry's per-field whitelist so
39
+ * the dropdown — and the parser's legal-op check — match what the server
40
+ * actually accepts.
41
+ */
42
+ operators?: OperatorId[];
43
+ /** Placeholder shown in the value step of the dropdown. */
44
+ valuePlaceholder?: string;
45
+ }
46
+ export interface Schema {
47
+ fields: FieldDef[];
48
+ }
49
+ /** Operators legal for a given field, in catalogue order. When the field
50
+ * declares an explicit `operators` whitelist that takes precedence over the
51
+ * type-derived set (the whitelist is authoritative); otherwise every operator
52
+ * legal for the field's `type` is returned. */
53
+ export declare function operatorsFor(field: FieldDef): Operator[];
54
+ /** The default operator picked when a user selects a field (YouTrack-like). */
55
+ export declare function defaultOperator(field: FieldDef): Operator;
56
+ export declare function findField(schema: Schema, token: string): FieldDef | undefined;
57
+ export declare function operatorByCode(code: string): Operator | undefined;
58
+ export declare function operatorById(id: OperatorId): Operator | undefined;
59
+ /** Resolve a field's value options (sync wrapper that always returns a promise). */
60
+ export declare function resolveValues(field: FieldDef, query: string): Promise<ValueOption[]>;
@@ -0,0 +1,54 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Layer 1 — the schema / field registry.
3
+ //
4
+ // The single source of truth that drives BOTH the autocomplete UI and the
5
+ // backend compiler. For each searchable field we declare its type, aliases,
6
+ // the operators it allows and a value provider (static enum or async lookup).
7
+ // Nothing here is UI: it's plain data a host app supplies.
8
+ // ─────────────────────────────────────────────────────────────────────────
9
+ // The full operator catalogue. The order here is the order shown in dropdowns.
10
+ export const OPERATORS = [
11
+ { id: 'contains', label: 'includes', code: ':', types: ['string'] },
12
+ { id: 'not_contains', label: 'does not include', code: '!:', types: ['string'] },
13
+ { id: 'eq', label: 'is', code: '=', types: ['enum', 'id', 'number', 'bool', 'date'] },
14
+ { id: 'ne', label: 'is not', code: '!=', types: ['enum', 'id', 'number', 'bool', 'date'] },
15
+ { id: 'gt', label: 'greater than', code: '>', types: ['number', 'date'] },
16
+ { id: 'gte', label: 'greater or equal', code: '>=', types: ['number', 'date'] },
17
+ { id: 'lt', label: 'less than', code: '<', types: ['number', 'date'] },
18
+ { id: 'lte', label: 'less or equal', code: '<=', types: ['number', 'date'] },
19
+ { id: 'in', label: 'any of', code: 'in', types: ['enum', 'id'], arity: 'many' },
20
+ { id: 'range', label: 'in range', code: '..', types: ['date', 'number'], arity: 'two' },
21
+ ];
22
+ /** Operators legal for a given field, in catalogue order. When the field
23
+ * declares an explicit `operators` whitelist that takes precedence over the
24
+ * type-derived set (the whitelist is authoritative); otherwise every operator
25
+ * legal for the field's `type` is returned. */
26
+ export function operatorsFor(field) {
27
+ if (field.operators) {
28
+ const allow = new Set(field.operators);
29
+ return OPERATORS.filter((op) => allow.has(op.id));
30
+ }
31
+ return OPERATORS.filter((op) => op.types.includes(field.type));
32
+ }
33
+ /** The default operator picked when a user selects a field (YouTrack-like). */
34
+ export function defaultOperator(field) {
35
+ return operatorsFor(field)[0];
36
+ }
37
+ export function findField(schema, token) {
38
+ const t = token.toLowerCase();
39
+ return schema.fields.find((f) => f.name.toLowerCase() === t || f.aliases?.some((a) => a.toLowerCase() === t));
40
+ }
41
+ export function operatorByCode(code) {
42
+ return OPERATORS.find((op) => op.code === code);
43
+ }
44
+ export function operatorById(id) {
45
+ return OPERATORS.find((op) => op.id === id);
46
+ }
47
+ /** Resolve a field's value options (sync wrapper that always returns a promise). */
48
+ export async function resolveValues(field, query) {
49
+ if (field.provider)
50
+ return field.provider(query);
51
+ const opts = field.options ?? [];
52
+ const q = query.toLowerCase();
53
+ return q ? opts.filter((o) => o.label.toLowerCase().includes(q)) : opts;
54
+ }
@@ -0,0 +1,33 @@
1
+ import { type Schema } from './schema';
2
+ export type SuggestKind = 'field' | 'operator' | 'value';
3
+ export interface Suggestion {
4
+ /** What to show on the row. */
5
+ label: string;
6
+ /** Secondary, dimmed line. */
7
+ hint?: string;
8
+ /** Right-aligned monospace token (operator code / field name). */
9
+ code?: string;
10
+ /** The string to splice into the query when accepted. */
11
+ insert: string;
12
+ /** Caret position after the insert (absolute index). */
13
+ caret: number;
14
+ /** When true, keep the dropdown open and advance to the next step. */
15
+ advance?: boolean;
16
+ }
17
+ export interface SuggestState {
18
+ kind: SuggestKind;
19
+ /** [start, end) of the token region these suggestions replace. */
20
+ span: [number, number];
21
+ items: Suggestion[];
22
+ }
23
+ /** The whitespace-delimited token under the caret (quotes count as non-ws). */
24
+ export declare function activeToken(s: string, pos: number): {
25
+ start: number;
26
+ end: number;
27
+ raw: string;
28
+ };
29
+ /**
30
+ * Compute the suggestion state for the caret. Value lookups are async (they may
31
+ * hit the network), so this returns a promise. `null` means "no dropdown".
32
+ */
33
+ export declare function suggest(schema: Schema, s: string, pos: number): Promise<SuggestState | null>;
@@ -0,0 +1,125 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Autocomplete engine — the SECOND consumer of the schema (the first is the
3
+ // compiler). Given the raw string + caret position it figures out which of the
4
+ // three YouTrack-style steps we're in (field → operator → value) and produces
5
+ // the suggestion list + the text edit to apply when one is accepted.
6
+ //
7
+ // This is pure logic, deliberately UI-free so the Svelte organism stays dumb.
8
+ // ─────────────────────────────────────────────────────────────────────────
9
+ import { findField, operatorsFor, resolveValues, } from './schema';
10
+ // Longest first so `>=`/`!:` win over `>`/`:`.
11
+ const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
12
+ /** The whitespace-delimited token under the caret (quotes count as non-ws). */
13
+ export function activeToken(s, pos) {
14
+ let start = pos;
15
+ while (start > 0 && !/\s/.test(s[start - 1]))
16
+ start--;
17
+ let end = pos;
18
+ while (end < s.length && !/\s/.test(s[end]))
19
+ end++;
20
+ return { start, end, raw: s.slice(start, end) };
21
+ }
22
+ /** Split a token into field / operator-code / value parts (earliest op wins). */
23
+ function splitToken(raw) {
24
+ let best = -1;
25
+ let bestCode = null;
26
+ for (const code of OP_CODES) {
27
+ const idx = raw.indexOf(code);
28
+ if (idx === -1)
29
+ continue;
30
+ // earliest position wins; on a tie the longer code wins (handled by order).
31
+ if (best === -1 || idx < best) {
32
+ best = idx;
33
+ bestCode = code;
34
+ }
35
+ }
36
+ if (bestCode === null)
37
+ return { field: raw, opCode: null, value: '' };
38
+ return {
39
+ field: raw.slice(0, best),
40
+ opCode: bestCode,
41
+ value: raw.slice(best + bestCode.length),
42
+ };
43
+ }
44
+ function unquote(v) {
45
+ return v.replace(/^["']|["']$/g, '');
46
+ }
47
+ function quoteIfNeeded(v) {
48
+ return /[\s,()]/.test(v) ? `"${v}"` : v;
49
+ }
50
+ function fieldSuggestions(schema, frag, span) {
51
+ const q = frag.toLowerCase();
52
+ const items = schema.fields
53
+ .filter((f) => !q ||
54
+ f.name.toLowerCase().includes(q) ||
55
+ f.label.toLowerCase().includes(q) ||
56
+ f.aliases?.some((a) => a.toLowerCase().includes(q)))
57
+ .map((f) => {
58
+ const op = operatorsFor(f)[0];
59
+ const insert = `${f.name}${op.code}`;
60
+ return {
61
+ label: f.label,
62
+ hint: f.aliases?.length ? `aka ${f.aliases.join(', ')}` : f.type,
63
+ code: f.name,
64
+ insert,
65
+ caret: span[0] + insert.length,
66
+ advance: true,
67
+ };
68
+ });
69
+ return { kind: 'field', span, items };
70
+ }
71
+ function operatorSuggestions(field, span) {
72
+ const items = operatorsFor(field).map((op) => {
73
+ const insert = `${field.name}${op.code}`;
74
+ return {
75
+ label: op.label,
76
+ code: op.code,
77
+ insert,
78
+ caret: span[0] + insert.length,
79
+ advance: true,
80
+ };
81
+ });
82
+ return { kind: 'operator', span, items };
83
+ }
84
+ function buildValueState(field, opCode, span, options) {
85
+ const items = options.map((o) => {
86
+ const insert = `${field.name}${opCode}${quoteIfNeeded(o.value)}`;
87
+ return {
88
+ label: o.label,
89
+ hint: o.hint,
90
+ insert,
91
+ caret: span[0] + insert.length,
92
+ };
93
+ });
94
+ return { kind: 'value', span, items };
95
+ }
96
+ /**
97
+ * Compute the suggestion state for the caret. Value lookups are async (they may
98
+ * hit the network), so this returns a promise. `null` means "no dropdown".
99
+ */
100
+ export async function suggest(schema, s, pos) {
101
+ const { start, end, raw } = activeToken(s, pos);
102
+ const span = [start, end];
103
+ // Empty caret position (between tokens or empty input) → offer all fields.
104
+ if (raw === '')
105
+ return fieldSuggestions(schema, '', span);
106
+ const { field: fieldFrag, opCode, value } = splitToken(raw);
107
+ const field = findField(schema, fieldFrag);
108
+ // No operator typed yet.
109
+ if (opCode === null) {
110
+ // Exact field match → it's time to pick an operator.
111
+ if (field && fieldFrag.toLowerCase() === field.name.toLowerCase()) {
112
+ return operatorSuggestions(field, span);
113
+ }
114
+ // Otherwise we're still completing the field name.
115
+ return fieldSuggestions(schema, fieldFrag, span);
116
+ }
117
+ // Operator present but field unknown → nothing useful to suggest.
118
+ if (!field)
119
+ return null;
120
+ // Operator present → suggest values (async; filtered by what's typed).
121
+ const opts = await resolveValues(field, unquote(value));
122
+ if (opts.length === 0)
123
+ return null;
124
+ return buildValueState(field, opCode, span, opts);
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
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",