@dorsk/tsumikit 0.8.1 → 0.9.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/atoms/Badge.svelte +1 -0
- package/dist/components/atoms/Button.svelte +1 -0
- package/dist/components/atoms/Card.svelte +1 -0
- package/dist/components/atoms/Checkbox.svelte +1 -1
- package/dist/components/atoms/Dot.svelte +2 -2
- package/dist/components/atoms/Heading.svelte +1 -1
- package/dist/components/atoms/Icon.svelte +1 -0
- package/dist/components/atoms/Input.svelte +1 -0
- package/dist/components/atoms/Link.svelte +2 -2
- package/dist/components/atoms/Progress.svelte +1 -0
- package/dist/components/atoms/Select.svelte +2 -2
- package/dist/components/atoms/Slider.svelte +1 -1
- package/dist/components/atoms/Switch.svelte +1 -0
- package/dist/components/atoms/Text.svelte +1 -0
- package/dist/components/atoms/Textarea.svelte +1 -1
- package/dist/components/layouts/AppShell.svelte +1 -1
- package/dist/components/layouts/AutoGrid.svelte +1 -0
- package/dist/components/layouts/Cluster.svelte +1 -0
- package/dist/components/layouts/Container.svelte +1 -0
- package/dist/components/layouts/NavItem.svelte +1 -0
- package/dist/components/layouts/NavSection.svelte +1 -1
- package/dist/components/layouts/Stack.svelte +1 -0
- package/dist/components/molecules/Accordion.svelte +1 -1
- package/dist/components/molecules/CodeBlock.svelte +1 -1
- package/dist/components/molecules/CopyButton.svelte +1 -0
- package/dist/components/molecules/Dropzone.svelte +2 -0
- package/dist/components/molecules/EmptyState.svelte +1 -1
- package/dist/components/molecules/Field.svelte +1 -1
- package/dist/components/molecules/FileButton.svelte +1 -0
- package/dist/components/molecules/FontScalePicker.svelte +1 -0
- package/dist/components/molecules/IconButton.svelte +1 -0
- package/dist/components/molecules/Menu.svelte +1 -1
- package/dist/components/molecules/Metric.svelte +1 -0
- package/dist/components/molecules/Modal.svelte +1 -0
- package/dist/components/molecules/OptionButton.svelte +1 -0
- package/dist/components/molecules/Popover.svelte +1 -0
- package/dist/components/molecules/RadioGroup.svelte +1 -1
- package/dist/components/molecules/SegmentedControl.svelte +1 -0
- package/dist/components/molecules/SelectButton.svelte +4 -2
- package/dist/components/molecules/SelectButton.svelte.d.ts +1 -0
- package/dist/components/molecules/Tabs.svelte +1 -1
- package/dist/components/molecules/ThemePicker.svelte +1 -0
- package/dist/components/molecules/Timestamp.svelte +3 -3
- package/dist/components/molecules/Toaster.svelte +1 -1
- package/dist/components/molecules/Toggle.svelte +1 -0
- package/dist/components/molecules/Tooltip.svelte +1 -1
- package/dist/components/molecules/Truncate.svelte +2 -2
- package/dist/components/organisms/DataTable.svelte +1 -1
- package/dist/components/organisms/FilterSearchBar.svelte +340 -0
- package/dist/components/organisms/FilterSearchBar.svelte.d.ts +17 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/query/ast.d.ts +24 -0
- package/dist/query/ast.js +16 -0
- package/dist/query/index.d.ts +8 -0
- package/dist/query/index.js +9 -0
- package/dist/query/parser.d.ts +3 -0
- package/dist/query/parser.js +181 -0
- package/dist/query/query.d.ts +8 -0
- package/dist/query/query.js +128 -0
- package/dist/query/schema.d.ts +49 -0
- package/dist/query/schema.js +47 -0
- package/dist/query/suggest.d.ts +33 -0
- package/dist/query/suggest.js +125 -0
- package/package.json +1 -1
|
@@ -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,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 {};
|