@dorsk/tsumikit 0.11.0 → 0.11.2

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.
@@ -20,6 +20,11 @@
20
20
  import Badge from '../atoms/Badge.svelte';
21
21
  import Icon from '../atoms/Icon.svelte';
22
22
  import { filters, freeText, type Query } from '../../query/ast';
23
+ import {
24
+ autoQuoteEdit,
25
+ backspaceEmptyQuotes,
26
+ closingQuoteExit
27
+ } from '../../query/edit';
23
28
  import { findField, operatorById, type Schema } from '../../query/schema';
24
29
  import { parse } from '../../query/parser';
25
30
  import { suggest, type SuggestState } from '../../query/suggest';
@@ -29,6 +34,7 @@
29
34
  value = $bindable(''),
30
35
  placeholder = 'artist:"Daft Punk" AND year>=2000',
31
36
  showChips = true,
37
+ autoQuote = true,
32
38
  onchange,
33
39
  onsubmit
34
40
  }: {
@@ -38,6 +44,12 @@
38
44
  placeholder?: string;
39
45
  /** Render the parsed, removable filter chips under the bar. */
40
46
  showChips?: boolean;
47
+ /**
48
+ * When a string field's value step opens (`title:`), auto-insert a `""`
49
+ * pair with the caret inside so multi-word values stay together; Tab exits
50
+ * the quotes. Set false for bare typing where spaces split the value.
51
+ */
52
+ autoQuote?: boolean;
41
53
  /** Fires the parsed AST on every change — feed this to your backend. */
42
54
  onchange?: (query: Query, raw: string) => void;
43
55
  /** Fires on Enter / clear / chip-remove with the raw query string. */
@@ -45,9 +57,11 @@
45
57
  } = $props();
46
58
 
47
59
  let el = $state<HTMLInputElement | null>(null);
60
+ let menuEl = $state<HTMLDivElement | null>(null);
48
61
  let menu = $state<SuggestState | null>(null);
49
62
  let active = $state(0);
50
63
  let open = $state(false);
64
+ let keyboardNavigation = $state(false);
51
65
  let reqId = 0;
52
66
 
53
67
  // Parsed view (drives the removable chips + the onchange AST).
@@ -60,10 +74,42 @@
60
74
  onchange?.(ast, value);
61
75
  });
62
76
 
77
+ // Keep keyboard navigation visible without moving the menu under the pointer
78
+ // when the active option changes because of hover.
79
+ $effect(() => {
80
+ active;
81
+ if (!keyboardNavigation || !open || !menuEl) return;
82
+ menuEl
83
+ .querySelector<HTMLElement>('.fsb__opt.is-active')
84
+ ?.scrollIntoView({ block: 'nearest' });
85
+ });
86
+
63
87
  function labelFor(fieldName: string): string {
64
88
  return findField(schema, fieldName)?.label ?? fieldName;
65
89
  }
66
90
 
91
+ function setCaret(pos: number) {
92
+ queueMicrotask(() => {
93
+ if (!el) return;
94
+ el.focus();
95
+ el.setSelectionRange(pos, pos);
96
+ });
97
+ }
98
+
99
+ function oninput(e: Event) {
100
+ if (autoQuote && el && (e as InputEvent).inputType?.startsWith('insert')) {
101
+ const pos = el.selectionStart ?? value.length;
102
+ const edit = autoQuoteEdit(schema, value, pos);
103
+ if (edit) {
104
+ value = edit.value;
105
+ setCaret(edit.pos);
106
+ queueMicrotask(refresh);
107
+ return;
108
+ }
109
+ }
110
+ refresh();
111
+ }
112
+
67
113
  async function refresh() {
68
114
  if (!el) return;
69
115
  const pos = el.selectionStart ?? value.length;
@@ -72,6 +118,7 @@
72
118
  if (id !== reqId) return; // a newer keystroke won
73
119
  menu = next;
74
120
  active = 0;
121
+ keyboardNavigation = false;
75
122
  open = !!next && next.items.length > 0;
76
123
  }
77
124
 
@@ -81,18 +128,51 @@
81
128
  if (!s) return;
82
129
  const [a, b] = menu.span;
83
130
  value = value.slice(0, a) + s.insert + value.slice(b);
131
+ let caret = s.caret;
132
+ // A field/operator pick that opens a string field's value step gets the
133
+ // same auto-quotes as manual typing.
134
+ if (autoQuote && s.advance) {
135
+ const edit = autoQuoteEdit(schema, value, caret);
136
+ if (edit) {
137
+ value = edit.value;
138
+ caret = edit.pos;
139
+ }
140
+ }
84
141
  // Restore caret after the inserted text, then re-run suggestions if the
85
142
  // step advanced (field → operator → value) so the flow keeps going.
86
143
  queueMicrotask(() => {
87
144
  if (!el) return;
88
145
  el.focus();
89
- el.setSelectionRange(s.caret, s.caret);
146
+ el.setSelectionRange(caret, caret);
90
147
  if (s.advance) refresh();
91
148
  else open = false;
92
149
  });
93
150
  }
94
151
 
95
152
  function onkeydown(e: KeyboardEvent) {
153
+ if (autoQuote && el) {
154
+ const pos = el.selectionStart ?? value.length;
155
+ if (e.key === 'Tab') {
156
+ // Inside auto-quotes Tab exits them (takes priority over accepting a
157
+ // dropdown item); elsewhere it falls through to accept.
158
+ const exit = closingQuoteExit(value, pos);
159
+ if (exit !== null) {
160
+ e.preventDefault();
161
+ open = false;
162
+ setCaret(exit);
163
+ return;
164
+ }
165
+ } else if (e.key === 'Backspace') {
166
+ const edit = backspaceEmptyQuotes(value, pos);
167
+ if (edit) {
168
+ e.preventDefault();
169
+ value = edit.value;
170
+ setCaret(edit.pos);
171
+ queueMicrotask(refresh);
172
+ return;
173
+ }
174
+ }
175
+ }
96
176
  if (!open || !menu) {
97
177
  if (e.key === 'Enter') submit();
98
178
  return;
@@ -100,10 +180,12 @@
100
180
  switch (e.key) {
101
181
  case 'ArrowDown':
102
182
  e.preventDefault();
183
+ keyboardNavigation = true;
103
184
  active = (active + 1) % menu.items.length;
104
185
  break;
105
186
  case 'ArrowUp':
106
187
  e.preventDefault();
188
+ keyboardNavigation = true;
107
189
  active = (active - 1 + menu.items.length) % menu.items.length;
108
190
  break;
109
191
  case 'Enter':
@@ -150,7 +232,7 @@
150
232
  spellcheck="false"
151
233
  autocomplete="off"
152
234
  {placeholder}
153
- oninput={refresh}
235
+ {oninput}
154
236
  onclick={refresh}
155
237
  onkeyup={(e) => {
156
238
  if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') refresh();
@@ -176,7 +258,7 @@
176
258
  </div>
177
259
 
178
260
  {#if open && menu}
179
- <div class="fsb__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
261
+ <div bind:this={menuEl} class="fsb__menu" role="listbox" aria-label={kindLabel[menu.kind]}>
180
262
  <div class="fsb__menu-head">{kindLabel[menu.kind]}</div>
181
263
  {#each menu.items as item, i (item.insert + i)}
182
264
  <button
@@ -186,7 +268,10 @@
186
268
  role="option"
187
269
  aria-selected={i === active}
188
270
  onmousedown={(e) => e.preventDefault()}
189
- onmouseenter={() => (active = i)}
271
+ onmouseenter={() => {
272
+ keyboardNavigation = false;
273
+ active = i;
274
+ }}
190
275
  onclick={() => accept(i)}
191
276
  >
192
277
  <span class="fsb__opt-label">{item.label}</span>
@@ -7,6 +7,12 @@ type $$ComponentProps = {
7
7
  placeholder?: string;
8
8
  /** Render the parsed, removable filter chips under the bar. */
9
9
  showChips?: boolean;
10
+ /**
11
+ * When a string field's value step opens (`title:`), auto-insert a `""`
12
+ * pair with the caret inside so multi-word values stay together; Tab exits
13
+ * the quotes. Set false for bare typing where spaces split the value.
14
+ */
15
+ autoQuote?: boolean;
10
16
  /** Fires the parsed AST on every change — feed this to your backend. */
11
17
  onchange?: (query: Query, raw: string) => void;
12
18
  /** Fires on Enter / clear / chip-remove with the raw query string. */
@@ -0,0 +1,27 @@
1
+ import { type Schema } from './schema';
2
+ /**
3
+ * When the text just before `pos` completes a string field's operator
4
+ * (`title:` / `title!:`) and no quote already follows, return the value with an
5
+ * empty `""` pair spliced in at `pos` and the caret parked between them. Returns
6
+ * `null` when it does not apply.
7
+ */
8
+ export declare function autoQuoteEdit(schema: Schema, value: string, pos: number): {
9
+ value: string;
10
+ pos: number;
11
+ } | null;
12
+ /** True when the caret sits inside an open quote. */
13
+ export declare function insideQuoteAtCaret(value: string, pos: number): boolean;
14
+ /**
15
+ * If the caret is inside a quote and its closing quote follows, return the index
16
+ * just past that quote (where Tab should move the caret to); otherwise `null`.
17
+ */
18
+ export declare function closingQuoteExit(value: string, pos: number): number | null;
19
+ /**
20
+ * If the caret sits between an empty matching quote pair (`"|"`), return the
21
+ * value with both quotes removed so a Backspace clears an unwanted auto-quote
22
+ * instead of leaving an orphan.
23
+ */
24
+ export declare function backspaceEmptyQuotes(value: string, pos: number): {
25
+ value: string;
26
+ pos: number;
27
+ } | null;
@@ -0,0 +1,79 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Editor affordances — pure text edits an input host applies as the user types.
3
+ //
4
+ // The parser treats an unquoted value as ending at the first space, so
5
+ // `title:star wars` means `title:star` + free-text `wars`. To type a multi-word
6
+ // value the user otherwise has to add the quotes by hand. These helpers let the
7
+ // FilterSearchBar auto-insert the `""` pair the moment a string field's value
8
+ // step opens, so spaces land inside the quotes — the already-supported phrase
9
+ // form — without any grammar change. UI-free and framework-agnostic.
10
+ // ─────────────────────────────────────────────────────────────────────────
11
+ import { findField } from './schema';
12
+ // A field name immediately followed by a string operator (`:` contains / `!:`
13
+ // not-contains), anchored at the caret.
14
+ const STRING_CLAUSE = /([A-Za-z_][\w-]*)(!?:)$/;
15
+ function isQuote(c) {
16
+ return c === '"' || c === "'";
17
+ }
18
+ /**
19
+ * When the text just before `pos` completes a string field's operator
20
+ * (`title:` / `title!:`) and no quote already follows, return the value with an
21
+ * empty `""` pair spliced in at `pos` and the caret parked between them. Returns
22
+ * `null` when it does not apply.
23
+ */
24
+ export function autoQuoteEdit(schema, value, pos) {
25
+ if (isQuote(value[pos]))
26
+ return null;
27
+ const m = STRING_CLAUSE.exec(value.slice(0, pos));
28
+ if (!m)
29
+ return null;
30
+ const field = findField(schema, m[1]);
31
+ if (field?.type !== 'string')
32
+ return null;
33
+ return { value: `${value.slice(0, pos)}""${value.slice(pos)}`, pos: pos + 1 };
34
+ }
35
+ // Scan from the start tracking quote state (spaces inside quotes are NOT
36
+ // boundaries), so the caret's quote context is correct even for `"star wars"`.
37
+ function openQuoteAt(value, pos) {
38
+ let quote = null;
39
+ for (let i = 0; i < pos; i++) {
40
+ const c = value[i];
41
+ if (quote) {
42
+ if (c === quote)
43
+ quote = null;
44
+ }
45
+ else if (isQuote(c)) {
46
+ quote = c;
47
+ }
48
+ }
49
+ return quote;
50
+ }
51
+ /** True when the caret sits inside an open quote. */
52
+ export function insideQuoteAtCaret(value, pos) {
53
+ return openQuoteAt(value, pos) !== null;
54
+ }
55
+ /**
56
+ * If the caret is inside a quote and its closing quote follows, return the index
57
+ * just past that quote (where Tab should move the caret to); otherwise `null`.
58
+ */
59
+ export function closingQuoteExit(value, pos) {
60
+ const quote = openQuoteAt(value, pos);
61
+ if (!quote)
62
+ return null;
63
+ for (let i = pos; i < value.length; i++) {
64
+ if (value[i] === quote)
65
+ return i + 1;
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * If the caret sits between an empty matching quote pair (`"|"`), return the
71
+ * value with both quotes removed so a Backspace clears an unwanted auto-quote
72
+ * instead of leaving an orphan.
73
+ */
74
+ export function backspaceEmptyQuotes(value, pos) {
75
+ const open = value[pos - 1];
76
+ if (!isQuote(open) || value[pos] !== open)
77
+ return null;
78
+ return { value: value.slice(0, pos - 1) + value.slice(pos + 1), pos: pos - 1 };
79
+ }
@@ -1,5 +1,6 @@
1
1
  export type { AndNode, ExprNode, FilterNode, LeafNode, NotNode, OrNode, Query, QueryNode, TextNode, } from './ast';
2
2
  export { filters, freeText, walk } from './ast';
3
+ export { autoQuoteEdit, backspaceEmptyQuotes, closingQuoteExit, insideQuoteAtCaret, } from './edit';
3
4
  export { parse } from './parser';
4
5
  export { compilePredicate, serialize, serializeFilter, toSql } from './query';
5
6
  export type { FieldDef, FieldType, Operator, OperatorId, Schema, ValueOption, ValueProvider, } from './schema';
@@ -3,6 +3,7 @@
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
5
  export { filters, freeText, walk } from './ast';
6
+ export { autoQuoteEdit, backspaceEmptyQuotes, closingQuoteExit, insideQuoteAtCaret, } from './edit';
6
7
  export { parse } from './parser';
7
8
  export { compilePredicate, serialize, serializeFilter, toSql } from './query';
8
9
  export { defaultOperator, findField, OPERATORS, operatorByCode, operatorById, operatorsFor, resolveValues, } from './schema';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
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",