@dorsk/tsumikit 0.9.6 → 0.11.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.
@@ -8,33 +8,50 @@
8
8
  // only opens/closes under our control) and is positioned by the shared
9
9
  // `place()` helper — so, like Popover, it escapes ancestor overflow/transform
10
10
  // clipping and flips/clamps to stay in the viewport. Dependency-free.
11
+ //
12
+ // Rich mode: pass a `content` snippet instead of (or as well as — `content`
13
+ // wins) `text` and the panel renders arbitrary markup: key/value rows, code
14
+ // ids, CopyButton, small buttons. Closing is then hovercard-style: debounced
15
+ // by `closeDelay` and cancelled when the pointer (or focus) enters the panel,
16
+ // so users can move in to select/copy text or click a button. Note the
17
+ // role="tooltip" caveat: `aria-describedby` flattens the panel to its text.
11
18
  import type { Snippet } from 'svelte';
12
19
  import { place } from '../../floating';
13
20
 
14
21
  let {
15
22
  text,
23
+ content,
16
24
  placement = 'top',
17
25
  delay = 200,
26
+ closeDelay = 250,
18
27
  trigger
19
28
  }: {
20
- text: string;
29
+ /** Plain-text bubble. Ignored when `content` is provided. */
30
+ text?: string;
31
+ /** Rich panel content — makes the panel hover-persistent and selectable. */
32
+ content?: Snippet;
21
33
  placement?: 'top' | 'bottom' | 'left' | 'right';
34
+ /** Open delay (ms) on hover. */
22
35
  delay?: number;
36
+ /** Grace period (ms) before closing — lets the pointer travel into the panel. */
37
+ closeDelay?: number;
23
38
  trigger: Snippet;
24
39
  } = $props();
25
40
 
26
41
  const id = `tip-${Math.random().toString(36).slice(2, 8)}`;
27
42
  let wrapEl = $state<HTMLElement | null>(null);
28
43
  let tipEl = $state<HTMLElement | null>(null);
29
- let timer: ReturnType<typeof setTimeout> | undefined;
44
+ let showTimer: ReturnType<typeof setTimeout> | undefined;
45
+ let hideTimer: ReturnType<typeof setTimeout> | undefined;
30
46
 
31
47
  function reposition() {
32
48
  if (wrapEl && tipEl) place(wrapEl, tipEl, `${placement}-center`, 6);
33
49
  }
34
50
 
35
51
  function show() {
36
- clearTimeout(timer);
37
- timer = setTimeout(() => {
52
+ clearTimeout(hideTimer); // pointer came back within the grace period
53
+ clearTimeout(showTimer);
54
+ showTimer = setTimeout(() => {
38
55
  if (!tipEl || tipEl.matches(':popover-open')) return; // re-entry guard
39
56
  tipEl.showPopover(); // top layer — displayed before we measure it
40
57
  reposition();
@@ -42,12 +59,23 @@
42
59
  addEventListener('resize', reposition);
43
60
  }, delay);
44
61
  }
45
- function hide() {
46
- clearTimeout(timer);
62
+ function hideNow() {
63
+ clearTimeout(showTimer);
64
+ clearTimeout(hideTimer);
47
65
  if (tipEl?.matches(':popover-open')) tipEl.hidePopover();
48
66
  removeEventListener('scroll', reposition, true);
49
67
  removeEventListener('resize', reposition);
50
68
  }
69
+ // Debounced close: cancelled if the pointer/focus enters the panel (rich
70
+ // mode) or returns to the trigger before `closeDelay` elapses.
71
+ function scheduleHide() {
72
+ clearTimeout(showTimer);
73
+ clearTimeout(hideTimer);
74
+ hideTimer = setTimeout(hideNow, closeDelay);
75
+ }
76
+ function cancelHide() {
77
+ clearTimeout(hideTimer);
78
+ }
51
79
 
52
80
  const FOCUSABLE = 'a[href],button,input,select,textarea,[tabindex]';
53
81
  // Action: wire aria-describedby onto the trigger's focusable element AND the
@@ -58,19 +86,39 @@
58
86
  | HTMLElement
59
87
  | null;
60
88
  target?.setAttribute('aria-describedby', id);
61
- const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
89
+ const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hideNow();
62
90
  node.addEventListener('mouseenter', show);
63
- node.addEventListener('mouseleave', hide);
91
+ node.addEventListener('mouseleave', scheduleHide);
64
92
  node.addEventListener('focusin', show);
65
- node.addEventListener('focusout', hide);
93
+ node.addEventListener('focusout', scheduleHide);
66
94
  node.addEventListener('keydown', onkey);
67
95
  return {
68
96
  destroy() {
69
97
  target?.removeAttribute('aria-describedby');
70
98
  node.removeEventListener('mouseenter', show);
71
- node.removeEventListener('mouseleave', hide);
99
+ node.removeEventListener('mouseleave', scheduleHide);
72
100
  node.removeEventListener('focusin', show);
73
- node.removeEventListener('focusout', hide);
101
+ node.removeEventListener('focusout', scheduleHide);
102
+ node.removeEventListener('keydown', onkey);
103
+ }
104
+ };
105
+ }
106
+
107
+ // Action for the panel: in rich mode it accepts the pointer/focus, which
108
+ // cancels the pending close (hovercard persistence).
109
+ function panel(node: HTMLElement) {
110
+ const onkey = (e: KeyboardEvent) => e.key === 'Escape' && hideNow();
111
+ node.addEventListener('mouseenter', cancelHide);
112
+ node.addEventListener('mouseleave', scheduleHide);
113
+ node.addEventListener('focusin', cancelHide);
114
+ node.addEventListener('focusout', scheduleHide);
115
+ node.addEventListener('keydown', onkey);
116
+ return {
117
+ destroy() {
118
+ node.removeEventListener('mouseenter', cancelHide);
119
+ node.removeEventListener('mouseleave', scheduleHide);
120
+ node.removeEventListener('focusin', cancelHide);
121
+ node.removeEventListener('focusout', scheduleHide);
74
122
  node.removeEventListener('keydown', onkey);
75
123
  }
76
124
  };
@@ -81,9 +129,17 @@
81
129
  {@render trigger()}
82
130
  </span>
83
131
 
84
- <span bind:this={tipEl} {id} role="tooltip" popover="manual" class="tip">
85
- {text}
86
- </span>
132
+ <div
133
+ bind:this={tipEl}
134
+ {id}
135
+ role="tooltip"
136
+ popover="manual"
137
+ class="tip"
138
+ class:rich={!!content}
139
+ use:panel
140
+ >
141
+ {#if content}{@render content()}{:else}{text}{/if}
142
+ </div>
87
143
 
88
144
  <style>
89
145
  .tip-wrap {
@@ -106,6 +162,15 @@
106
162
  white-space: normal;
107
163
  pointer-events: none; /* non-interactive: never steals hover/clicks */
108
164
  }
165
+ /* Rich panel: interactive + selectable so the user can move the pointer in
166
+ to copy an id or click a button (the close grace period allows the trip). */
167
+ .tip.rich {
168
+ pointer-events: auto;
169
+ user-select: text;
170
+ max-width: min(22rem, calc(100vw - 2 * var(--sp-3)));
171
+ padding: var(--sp-2);
172
+ border-radius: var(--r-md);
173
+ }
109
174
  /* Fade/scale in when shown (skipped under reduced-motion via the global rule). */
110
175
  .tip:popover-open {
111
176
  animation: tip-in 0.12s var(--ease);
@@ -1,8 +1,14 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
- text: string;
3
+ /** Plain-text bubble. Ignored when `content` is provided. */
4
+ text?: string;
5
+ /** Rich panel content — makes the panel hover-persistent and selectable. */
6
+ content?: Snippet;
4
7
  placement?: 'top' | 'bottom' | 'left' | 'right';
8
+ /** Open delay (ms) on hover. */
5
9
  delay?: number;
10
+ /** Grace period (ms) before closing — lets the pointer travel into the panel. */
11
+ closeDelay?: number;
6
12
  trigger: Snippet;
7
13
  };
8
14
  declare const Tooltip: import("svelte").Component<$$ComponentProps, {}, "">;
package/dist/index.d.ts CHANGED
@@ -50,7 +50,7 @@ export { default as Tooltip } from './components/molecules/Tooltip.svelte';
50
50
  export { default as Truncate } from './components/molecules/Truncate.svelte';
51
51
  export { type Column, default as DataTable } from './components/organisms/DataTable.svelte';
52
52
  export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
53
- 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';
53
+ export { type AndNode, activeToken, compilePredicate, defaultOperator, type ExprNode, type FieldDef, type FieldType, type FilterNode, filters, findField, freeText, type LeafNode, type NotNode, OPERATORS, type Operator, type OperatorId, type OrNode, 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, walk, } from './query';
54
54
  export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
55
55
  export { type Mode, THEMES, theme } from './stores/theme.svelte';
56
56
  export { type Toast, type ToastTone, toasts } from './stores/toast.svelte';
package/dist/index.js CHANGED
@@ -62,7 +62,7 @@ export { default as Truncate } from './components/molecules/Truncate.svelte';
62
62
  export { default as DataTable } from './components/organisms/DataTable.svelte';
63
63
  export { default as FilterSearchBar } from './components/organisms/FilterSearchBar.svelte';
64
64
  // ---- query core (headless: schema / parser / AST / suggest / compilers) ----
65
- export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, suggest, toSql, } from './query';
65
+ export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, suggest, toSql, walk, } from './query';
66
66
  export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
67
67
  // ---- stores / actions ----
68
68
  export { THEMES, theme } from './stores/theme.svelte';
@@ -15,10 +15,38 @@ export interface TextNode {
15
15
  value: string;
16
16
  span: [number, number];
17
17
  }
18
- export type QueryNode = FilterNode | TextNode;
18
+ /** Conjunction every child must match. Implicit between adjacent clauses. */
19
+ export interface AndNode {
20
+ kind: 'and';
21
+ children: ExprNode[];
22
+ }
23
+ /** Disjunction — at least one child must match. Written with `OR`. */
24
+ export interface OrNode {
25
+ kind: 'or';
26
+ children: ExprNode[];
27
+ }
28
+ /** Boolean negation of a whole sub-expression. Written with `NOT`. */
29
+ export interface NotNode {
30
+ kind: 'not';
31
+ child: ExprNode;
32
+ }
33
+ /** A leaf of the expression tree. */
34
+ export type LeafNode = FilterNode | TextNode;
35
+ /** Any node in the boolean expression tree. */
36
+ export type ExprNode = AndNode | OrNode | NotNode | LeafNode;
37
+ /**
38
+ * Backwards-compatible alias for a leaf node. Historically `QueryNode` was the
39
+ * only node type (the tree was a flat list of these); it now names the leaf
40
+ * union so existing type imports keep resolving.
41
+ */
42
+ export type QueryNode = LeafNode;
19
43
  export interface Query {
20
- /** Clauses are AND-combined (YouTrack semantics). */
21
- nodes: QueryNode[];
44
+ /** Root of the boolean expression tree, or `null` for an empty query. */
45
+ root: ExprNode | null;
22
46
  }
47
+ /** Depth-first walk of every node in the tree (leaves included). */
48
+ export declare function walk(node: ExprNode | null, visit: (n: ExprNode) => void): void;
49
+ /** Every filter leaf in the tree, in source order. */
23
50
  export declare function filters(q: Query): FilterNode[];
51
+ /** All free-text leaves joined — the flat full-text fragment. */
24
52
  export declare function freeText(q: Query): string;
package/dist/query/ast.js CHANGED
@@ -3,14 +3,44 @@
3
3
  // the autocomplete UI and the backend compiler consume. The frontend never
4
4
  // asks the server to re-parse free text; it ships this tree (or the server
5
5
  // runs the same parser and gets the same tree).
6
+ //
7
+ // The tree is a boolean expression: `And` / `Or` / `Not` nodes wrap the leaf
8
+ // `FilterNode` / `TextNode`. The leaf shapes are unchanged from the flat-AND
9
+ // era, so `schema.ts` and value providers are untouched. A query with no
10
+ // explicit `OR` / `NOT` / parens parses to a single `And` of its leaves — the
11
+ // same result set as the old implicit-AND clause list.
6
12
  // ─────────────────────────────────────────────────────────────────────────
13
+ /** Depth-first walk of every node in the tree (leaves included). */
14
+ export function walk(node, visit) {
15
+ if (!node)
16
+ return;
17
+ visit(node);
18
+ switch (node.kind) {
19
+ case 'and':
20
+ case 'or':
21
+ for (const c of node.children)
22
+ walk(c, visit);
23
+ break;
24
+ case 'not':
25
+ walk(node.child, visit);
26
+ break;
27
+ }
28
+ }
29
+ /** Every filter leaf in the tree, in source order. */
7
30
  export function filters(q) {
8
- return q.nodes.filter((n) => n.kind === 'filter');
31
+ const out = [];
32
+ walk(q.root, (n) => {
33
+ if (n.kind === 'filter')
34
+ out.push(n);
35
+ });
36
+ return out;
9
37
  }
38
+ /** All free-text leaves joined — the flat full-text fragment. */
10
39
  export function freeText(q) {
11
- return q.nodes
12
- .filter((n) => n.kind === 'text')
13
- .map((n) => n.value)
14
- .join(' ')
15
- .trim();
40
+ const parts = [];
41
+ walk(q.root, (n) => {
42
+ if (n.kind === 'text')
43
+ parts.push(n.value);
44
+ });
45
+ return parts.join(' ').trim();
16
46
  }
@@ -1,5 +1,5 @@
1
- export type { FilterNode, Query, QueryNode, TextNode } from './ast';
2
- export { filters, freeText } from './ast';
1
+ export type { AndNode, ExprNode, FilterNode, LeafNode, NotNode, OrNode, Query, QueryNode, TextNode, } from './ast';
2
+ export { filters, freeText, walk } from './ast';
3
3
  export { parse } from './parser';
4
4
  export { compilePredicate, serialize, serializeFilter, toSql } from './query';
5
5
  export type { FieldDef, FieldType, Operator, OperatorId, Schema, ValueOption, ValueProvider, } from './schema';
@@ -2,7 +2,7 @@
2
2
  // serialisable AST, autocomplete engine and AST compilers. Framework-agnostic:
3
3
  // no Svelte, no DOM. A host app supplies a `Schema` (optionally with async
4
4
  // `ValueProvider`s) and consumes the `Query` AST against its own backend.
5
- export { filters, freeText } from './ast';
5
+ export { filters, freeText, walk } from './ast';
6
6
  export { parse } from './parser';
7
7
  export { compilePredicate, serialize, serializeFilter, toSql } from './query';
8
8
  export { defaultOperator, findField, OPERATORS, operatorByCode, operatorById, operatorsFor, resolveValues, } from './schema';
@@ -1,12 +1,19 @@
1
1
  // ─────────────────────────────────────────────────────────────────────────
2
- // Layer 2 — a tolerant, error-recovering parser.
2
+ // Layer 2 — a tolerant, error-recovering recursive-descent parser.
3
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.
4
+ // Hand-written (not a grammar generator) precisely BECAUSE the input is "free
5
+ // for all": we must parse partial input (`artist:` with no value, for
6
+ // autocomplete) and slightly-wrong input (a malformed clause must degrade to
7
+ // free text, never throw). Output is the serialisable expression tree in
8
+ // ast.ts.
8
9
  //
9
- // Textual forms understood:
10
+ // Grammar (precedence NOT > AND > OR; AND is implicit between adjacent terms):
11
+ // orExpr := andExpr ( OR andExpr )*
12
+ // andExpr := notExpr ( (AND)? notExpr )*
13
+ // notExpr := (NOT)? primary
14
+ // primary := '(' orExpr ')' | filter | text-word
15
+ //
16
+ // Leaf textual forms understood:
10
17
  // artist:"Daft Punk" → contains
11
18
  // artist!:bootleg → not_contains
12
19
  // year>=2021 released<2020 → numeric/date comparisons
@@ -14,16 +21,26 @@
14
21
  // genre in (house, techno) → any of
15
22
  // year..2021..2024 → range (field..a..b) also field:a..b
16
23
  // free floating words → TextNode (full-text)
17
- // AND / and → ignored separator (clauses are implicitly AND)
24
+ //
25
+ // Error recovery: unmatched `)` is ignored, a missing `)` closes at end of
26
+ // input, a dangling AND/OR/NOT with no operand collapses to its operand, and an
27
+ // empty group yields nothing. The parser never throws.
18
28
  // ─────────────────────────────────────────────────────────────────────────
19
29
  import { findField, operatorByCode, operatorsFor } from './schema';
20
30
  const IDENT = /[A-Za-z_][\w-]*/y;
21
31
  // Longest operator codes first so `>=` wins over `>`.
22
32
  const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
33
+ const KEYWORDS = new Set(['AND', 'OR', 'NOT']);
23
34
  function skipWs(sc) {
24
35
  while (sc.i < sc.s.length && /\s/.test(sc.s[sc.i]))
25
36
  sc.i++;
26
37
  }
38
+ // Only skip spaces/tabs; used so `artist: foo` (space after colon) still
39
+ // attaches `foo` as the value without crossing a real token boundary.
40
+ function skipWsInline(sc) {
41
+ while (sc.i < sc.s.length && (sc.s[sc.i] === ' ' || sc.s[sc.i] === '\t'))
42
+ sc.i++;
43
+ }
27
44
  /** Read a value token: a "quoted string", or a bare run up to whitespace. */
28
45
  function readValue(sc) {
29
46
  skipWsInline(sc);
@@ -42,17 +59,12 @@ function readValue(sc) {
42
59
  !/\s/.test(sc.s[sc.i]) &&
43
60
  sc.s[sc.i] !== ',' &&
44
61
  sc.s[sc.i] !== ')' &&
62
+ sc.s[sc.i] !== '(' &&
45
63
  // stop at a `..` range separator so `a..b` splits into two values
46
64
  !sc.s.startsWith('..', sc.i))
47
65
  sc.i++;
48
66
  return { value: sc.s.slice(start, sc.i), end: sc.i };
49
67
  }
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
68
  function matchOp(s, i) {
57
69
  for (const code of OP_CODES) {
58
70
  if (s.startsWith(code, i))
@@ -60,57 +72,160 @@ function matchOp(s, i) {
60
72
  }
61
73
  return null;
62
74
  }
75
+ /**
76
+ * Peek at an upcoming connective keyword (AND / OR / NOT) without consuming it.
77
+ * A keyword only counts when it stands alone — i.e. it is NOT immediately
78
+ * followed by an operator, so `and:foo` or `or=x` stay field clauses. Returns
79
+ * the keyword and the index just past it, or null.
80
+ */
81
+ function peekKeyword(sc) {
82
+ let j = sc.i;
83
+ while (j < sc.s.length && /\s/.test(sc.s[j]))
84
+ j++;
85
+ IDENT.lastIndex = j;
86
+ const m = IDENT.exec(sc.s);
87
+ if (!m || m.index !== j)
88
+ return null;
89
+ const word = m[0].toUpperCase();
90
+ if (!KEYWORDS.has(word))
91
+ return null;
92
+ // If an operator follows the ident it is a field name, not a connective.
93
+ let k = j + m[0].length;
94
+ while (sc.s[k] === ' ')
95
+ k++;
96
+ if (matchOp(sc.s, k))
97
+ return null;
98
+ return { word, next: j + m[0].length };
99
+ }
63
100
  export function parse(input, schema) {
64
101
  const sc = { s: input, i: 0 };
65
- const nodes = [];
66
- while (sc.i < sc.s.length) {
102
+ const root = parseOr(sc, schema);
103
+ return { root };
104
+ }
105
+ // orExpr := andExpr ( OR andExpr )*
106
+ function parseOr(sc, schema) {
107
+ const children = [];
108
+ const first = parseAnd(sc, schema);
109
+ if (first)
110
+ children.push(first);
111
+ for (;;) {
112
+ const kw = peekKeyword(sc);
113
+ if (!kw || kw.word !== 'OR')
114
+ break;
115
+ sc.i = kw.next;
116
+ const next = parseAnd(sc, schema);
117
+ if (next)
118
+ children.push(next);
119
+ }
120
+ if (children.length === 0)
121
+ return first ?? null;
122
+ if (children.length === 1)
123
+ return children[0];
124
+ return { kind: 'or', children };
125
+ }
126
+ // andExpr := notExpr ( (AND)? notExpr )*
127
+ function parseAnd(sc, schema) {
128
+ const children = [];
129
+ for (;;) {
67
130
  skipWs(sc);
68
131
  if (sc.i >= sc.s.length)
69
132
  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
- }
133
+ if (sc.s[sc.i] === ')')
134
+ break;
135
+ const kw = peekKeyword(sc);
136
+ if (kw?.word === 'OR')
137
+ break; // let the OR level handle it
138
+ let explicitAnd = false;
139
+ if (kw?.word === 'AND') {
140
+ sc.i = kw.next;
141
+ explicitAnd = true;
96
142
  }
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
- }
143
+ const before = sc.i;
144
+ const node = parseNot(sc, schema);
145
+ if (!node) {
146
+ // No progress → avoid an infinite loop on stray tokens.
147
+ if (sc.i === before)
148
+ break;
149
+ continue;
150
+ }
151
+ // Merge adjacent implicit-AND free-text words into one TextNode so a bare
152
+ // `foo bar` still matches the contiguous phrase (old flat-AND behaviour).
153
+ const last = children[children.length - 1];
154
+ if (!explicitAnd && node.kind === 'text' && last && last.kind === 'text') {
155
+ last.value += ` ${node.value}`;
156
+ last.span[1] = node.span[1];
157
+ continue;
158
+ }
159
+ children.push(node);
160
+ }
161
+ if (children.length === 0)
162
+ return null;
163
+ if (children.length === 1)
164
+ return children[0];
165
+ return { kind: 'and', children };
166
+ }
167
+ // notExpr := (NOT)? primary
168
+ function parseNot(sc, schema) {
169
+ const kw = peekKeyword(sc);
170
+ if (kw?.word === 'NOT') {
171
+ sc.i = kw.next;
172
+ const child = parseNot(sc, schema);
173
+ if (!child)
174
+ return null; // dangling NOT → nothing
175
+ return { kind: 'not', child };
176
+ }
177
+ return parsePrimary(sc, schema);
178
+ }
179
+ // primary := '(' orExpr ')' | filter | text-word
180
+ function parsePrimary(sc, schema) {
181
+ skipWs(sc);
182
+ if (sc.i >= sc.s.length)
183
+ return null;
184
+ // Parenthesised group.
185
+ if (sc.s[sc.i] === '(') {
186
+ sc.i++;
187
+ const inner = parseOr(sc, schema);
188
+ skipWs(sc);
189
+ if (sc.s[sc.i] === ')')
190
+ sc.i++; // tolerate a missing close paren
191
+ return inner; // empty group → null
192
+ }
193
+ if (sc.s[sc.i] === ')')
194
+ return null;
195
+ // A connective keyword here isn't a leaf; let the caller handle it.
196
+ if (peekKeyword(sc))
197
+ return null;
198
+ const leaf = readLeaf(sc, schema);
199
+ return leaf;
200
+ }
201
+ /** Read a single leaf: a `field op value` filter, or one free-text word. */
202
+ function readLeaf(sc, schema) {
203
+ const clauseStart = sc.i;
204
+ IDENT.lastIndex = sc.i;
205
+ const m = IDENT.exec(sc.s);
206
+ if (m && m.index === sc.i) {
207
+ const ident = m[0];
208
+ const afterIdent = sc.i + ident.length;
209
+ const field = findField(schema, ident);
210
+ let j = afterIdent;
211
+ while (sc.s[j] === ' ')
212
+ j++;
213
+ const inKeyword = sc.s.slice(j, j + 3).toLowerCase() === 'in ' || sc.s.slice(j) === 'in';
214
+ const opMatch = matchOp(sc.s, j);
215
+ if (field && (opMatch || inKeyword)) {
216
+ const node = readFilter(sc, field, clauseStart, j, opMatch, inKeyword);
217
+ if (node)
218
+ return node;
111
219
  }
112
220
  }
113
- return { nodes };
221
+ // Fallback: consume one word (up to whitespace or a paren) as free text.
222
+ const wStart = sc.i;
223
+ while (sc.i < sc.s.length && !/\s/.test(sc.s[sc.i]) && sc.s[sc.i] !== '(' && sc.s[sc.i] !== ')')
224
+ sc.i++;
225
+ const word = sc.s.slice(wStart, sc.i);
226
+ if (!word)
227
+ return null;
228
+ return { kind: 'text', value: word, span: [wStart, sc.i] };
114
229
  }
115
230
  function readFilter(sc, field, clauseStart, opPos, opMatch, inKeyword) {
116
231
  const legal = operatorsFor(field);
@@ -1,4 +1,4 @@
1
- import { type FilterNode, type Query } from './ast';
1
+ import type { FilterNode, Query } from './ast';
2
2
  /** AST → canonical textual query (round-trips through the parser). */
3
3
  export declare function serialize(q: Query): string;
4
4
  export declare function serializeFilter(f: FilterNode): string;
@@ -2,22 +2,51 @@
2
2
  // AST consumers: serialise back to the textual query (for the code editor and
3
3
  // the URL), compile to a human-readable SQL-ish WHERE (to prove the filter
4
4
  // "properly propagates to backend & database"), and compile to a JS predicate
5
- // (our fake in-memory backend).
5
+ // (our fake in-memory backend). All three walk the boolean expression tree with
6
+ // correct precedence / grouping / short-circuiting.
6
7
  // ─────────────────────────────────────────────────────────────────────────
7
- import { filters, freeText } from './ast';
8
8
  import { operatorById } from './schema';
9
9
  function quoteIfNeeded(v) {
10
10
  return /[\s,()]/.test(v) || v === '' ? `"${v}"` : v;
11
11
  }
12
+ // ── Serialiser (AST → canonical textual query, round-trips through parse) ────
13
+ /** Precedence rank: higher binds tighter. Leaves are atomic. */
14
+ function rank(node) {
15
+ switch (node.kind) {
16
+ case 'or':
17
+ return 1;
18
+ case 'and':
19
+ return 2;
20
+ case 'not':
21
+ return 3;
22
+ default:
23
+ return 4; // leaf
24
+ }
25
+ }
26
+ /** Serialise `node`, wrapping in parens when its precedence is below `min`. */
27
+ function serializeNode(node, min) {
28
+ const s = serializeBare(node);
29
+ return rank(node) < min ? `(${s})` : s;
30
+ }
31
+ function serializeBare(node) {
32
+ switch (node.kind) {
33
+ case 'filter':
34
+ return serializeFilter(node);
35
+ case 'text':
36
+ return node.value;
37
+ case 'not':
38
+ // Child must bind at least as tight as NOT, else parenthesise.
39
+ return `NOT ${serializeNode(node.child, 3)}`;
40
+ case 'and':
41
+ // Children need to sit above OR precedence; AND itself is implicit (space).
42
+ return node.children.map((c) => serializeNode(c, 2)).join(' ');
43
+ case 'or':
44
+ return node.children.map((c) => serializeNode(c, 1)).join(' OR ');
45
+ }
46
+ }
12
47
  /** AST → canonical textual query (round-trips through the parser). */
13
48
  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(' ');
49
+ return q.root ? serializeBare(q.root) : '';
21
50
  }
22
51
  export function serializeFilter(f) {
23
52
  const op = operatorById(f.op);
@@ -38,32 +67,41 @@ const SQL_OP = {
38
67
  lt: '<',
39
68
  lte: '<=',
40
69
  };
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
- }
70
+ function sqlLiteral(s) {
71
+ return `'${s.replace(/'/g, "''")}'`;
72
+ }
73
+ function filterToSql(f) {
74
+ const col = f.field;
75
+ switch (f.op) {
76
+ case 'contains':
77
+ return `${col} ILIKE '%${f.values[0]}%'`;
78
+ case 'not_contains':
79
+ return `${col} NOT ILIKE '%${f.values[0]}%'`;
80
+ case 'in':
81
+ return `${col} IN (${f.values.map(sqlLiteral).join(', ')})`;
82
+ case 'range':
83
+ return `${col} BETWEEN ${sqlLiteral(f.values[0])} AND ${sqlLiteral(f.values[1])}`;
84
+ default:
85
+ return `${col} ${SQL_OP[f.op]} ${sqlLiteral(f.values[0] ?? '')}`;
62
86
  }
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';
87
+ }
88
+ /** Serialise a node to a SQL boolean expression, parenthesising sub-groups. */
89
+ function nodeToSql(node) {
90
+ switch (node.kind) {
91
+ case 'filter':
92
+ return filterToSql(node);
93
+ case 'text':
94
+ return `to_tsvector(title) @@ plainto_tsquery(${sqlLiteral(node.value)})`;
95
+ case 'not':
96
+ return `NOT (${nodeToSql(node.child)})`;
97
+ case 'and':
98
+ return node.children.map((c) => `(${nodeToSql(c)})`).join(' AND ');
99
+ case 'or':
100
+ return node.children.map((c) => `(${nodeToSql(c)})`).join(' OR ');
101
+ }
102
+ }
103
+ export function toSql(q, table = 'tracks') {
104
+ const where = q.root ? nodeToSql(q.root) : 'TRUE';
67
105
  return `SELECT * FROM ${table}\nWHERE ${where};`;
68
106
  }
69
107
  function asNumber(v) {
@@ -108,21 +146,65 @@ function matchFilter(f, row) {
108
146
  return true;
109
147
  }
110
148
  }
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) {
149
+ /**
150
+ * Is this leaf still incomplete (mid-typing)? Such leaves are neutral — they
151
+ * neither match nor reject — so a half-written clause never changes the result
152
+ * set, matching the old flat-AND "ignore incomplete clauses" behaviour.
153
+ */
154
+ function isIncomplete(node) {
155
+ if (node.kind === 'filter')
156
+ return node.values.every((v) => v === '');
157
+ if (node.kind === 'text')
158
+ return node.value.trim() === '';
159
+ return false;
160
+ }
161
+ /** Evaluate a node against a row. `null` = neutral (skip / don't constrain). */
162
+ function evalNode(node, row) {
163
+ if (isIncomplete(node))
164
+ return null;
165
+ switch (node.kind) {
166
+ case 'filter':
167
+ return matchFilter(node, row);
168
+ case 'text': {
122
169
  const hay = Object.values(row).join(' ').toLowerCase();
123
- if (!hay.includes(text))
124
- return false;
170
+ return hay.includes(node.value.trim().toLowerCase());
171
+ }
172
+ case 'not': {
173
+ const inner = evalNode(node.child, row);
174
+ return inner === null ? null : !inner;
125
175
  }
126
- return true;
176
+ case 'and': {
177
+ let sawReal = false;
178
+ for (const c of node.children) {
179
+ const r = evalNode(c, row);
180
+ if (r === null)
181
+ continue; // neutral
182
+ sawReal = true;
183
+ if (!r)
184
+ return false;
185
+ }
186
+ return sawReal ? true : null;
187
+ }
188
+ case 'or': {
189
+ let sawReal = false;
190
+ for (const c of node.children) {
191
+ const r = evalNode(c, row);
192
+ if (r === null)
193
+ continue;
194
+ sawReal = true;
195
+ if (r)
196
+ return true;
197
+ }
198
+ return sawReal ? false : null;
199
+ }
200
+ }
201
+ }
202
+ export function compilePredicate(q) {
203
+ const root = q.root;
204
+ return (row) => {
205
+ if (!root)
206
+ return true;
207
+ const r = evalNode(root, row);
208
+ return r === null ? true : r;
127
209
  };
128
210
  }
@@ -20,13 +20,6 @@ export interface SuggestState {
20
20
  span: [number, number];
21
21
  items: Suggestion[];
22
22
  }
23
- /**
24
- * The token under the caret. Whitespace ends a token EXCEPT inside an open
25
- * quote, so a quoted value containing spaces (`album:"ok computer`) stays one
26
- * token and keeps the caret in the value step rather than collapsing to an
27
- * empty token (which would wrongly re-offer fields). A closed quote followed by
28
- * whitespace starts a fresh token (so the next `field:` can be chained).
29
- */
30
23
  export declare function activeToken(s: string, pos: number): {
31
24
  start: number;
32
25
  end: number;
@@ -10,15 +10,19 @@ import { findField, operatorsFor, resolveValues, } from './schema';
10
10
  // Longest first so `>=`/`!:` win over `>`/`:`.
11
11
  const OP_CODES = ['!:', '!=', '>=', '<=', ':', '=', '>', '<'];
12
12
  /**
13
- * The token under the caret. Whitespace ends a token EXCEPT inside an open
14
- * quote, so a quoted value containing spaces (`album:"ok computer`) stays one
15
- * token and keeps the caret in the value step rather than collapsing to an
16
- * empty token (which would wrongly re-offer fields). A closed quote followed by
17
- * whitespace starts a fresh token (so the next `field:` can be chained).
13
+ * The token under the caret. Whitespace and boolean grouping parens `(` `)` —
14
+ * end a token EXCEPT inside an open quote, so a quoted value containing spaces
15
+ * (`album:"ok computer`) stays one token and keeps the caret in the value step
16
+ * rather than collapsing to an empty token (which would wrongly re-offer
17
+ * fields). Breaking on parens means `(artist:` offers operators for a clean
18
+ * `artist`, and autocomplete fires at the right spot inside a group.
18
19
  */
20
+ function isBoundary(c) {
21
+ return /\s/.test(c) || c === '(' || c === ')';
22
+ }
19
23
  export function activeToken(s, pos) {
20
24
  // Walk from the start so we can track quote state; the last unquoted
21
- // whitespace before the caret is the token boundary.
25
+ // boundary before the caret is the token start.
22
26
  let start = 0;
23
27
  let inQuote = false;
24
28
  let q = '';
@@ -32,7 +36,7 @@ export function activeToken(s, pos) {
32
36
  inQuote = true;
33
37
  q = c;
34
38
  }
35
- else if (/\s/.test(c)) {
39
+ else if (isBoundary(c)) {
36
40
  start = i + 1;
37
41
  }
38
42
  }
@@ -40,7 +44,7 @@ export function activeToken(s, pos) {
40
44
  let end = pos;
41
45
  for (let i = pos; i < s.length; i++) {
42
46
  const c = s[i];
43
- if (!inQuote && /\s/.test(c))
47
+ if (!inQuote && isBoundary(c))
44
48
  break;
45
49
  if (inQuote) {
46
50
  if (c === q)
@@ -54,6 +58,9 @@ export function activeToken(s, pos) {
54
58
  }
55
59
  return { start, end, raw: s.slice(start, end) };
56
60
  }
61
+ // The boolean connectives are not fields/operators/values; when the caret sits
62
+ // on one there is nothing to autocomplete (the next token gets fresh fields).
63
+ const CONNECTIVES = new Set(['AND', 'OR', 'NOT']);
57
64
  /** Split a token into field / operator-code / value parts (earliest op wins). */
58
65
  function splitToken(raw) {
59
66
  let best = -1;
@@ -139,6 +146,10 @@ export async function suggest(schema, s, pos) {
139
146
  if (raw === '')
140
147
  return fieldSuggestions(schema, '', span);
141
148
  const { field: fieldFrag, opCode, value } = splitToken(raw);
149
+ // The caret is parked on a bare boolean connective (`AND`/`OR`/`NOT`) — not a
150
+ // field, so suppress the dropdown until the next token begins.
151
+ if (opCode === null && CONNECTIVES.has(fieldFrag.toUpperCase()))
152
+ return null;
142
153
  const field = findField(schema, fieldFrag);
143
154
  // No operator typed yet.
144
155
  if (opCode === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.9.6",
3
+ "version": "0.11.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",