@dorsk/tsumikit 0.50.0 → 0.52.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/README.md CHANGED
@@ -154,6 +154,60 @@ for a light theme (`:root` defaults to dark, so dark themes may omit it).
154
154
  - Adding a built-in theme to the kit = one entry in `THEMES`
155
155
  (`stores/theme.svelte.ts`) + one block in `styles/themes.css`.
156
156
 
157
+ ### Styling a component from outside
158
+
159
+ A Svelte consumer's **scoped** CSS cannot reach a child component's markup — the
160
+ scoping attribute is only added to elements the consumer itself renders. `class`
161
+ passthrough therefore only helps for layout hooks (margin, flex, grid placement).
162
+ CSS custom properties *do* cross the component boundary, so they are the
163
+ sanctioned escape hatch for everything else.
164
+
165
+ - **Naming:** `--<prefix>-<axis>`, where `<prefix>` is the component's short
166
+ prefix (`btn`, `badge`, `txt`, `select`, `input`, `textarea`, `card`, `md`, …)
167
+ and `<axis>` is one of `bg`, `fg`, `border`, `size`, `radius`, plus a few
168
+ component-specific ones (`--btn-tone`, `--badge-max-width`, `--gauge-w`).
169
+ `Text` uses `txt` because `--text*` is already the theme's foreground token.
170
+ - **Setting them:** per instance via `style`, or on any ancestor for a subtree.
171
+
172
+ ```svelte
173
+ <Button style="--btn-bg: var(--c-violet); --btn-fg: #fff; --btn-radius: 0">Ship</Button>
174
+
175
+ <div class="danger-zone">
176
+ <Button>Delete</Button> <!-- inherits the block below -->
177
+ </div>
178
+
179
+ <style>
180
+ .danger-zone { --btn-border: var(--danger); --btn-fg: var(--danger); }
181
+ </style>
182
+ ```
183
+
184
+ - Every property is read as `var(--x, <default>)`, so leaving it unset renders
185
+ exactly as before.
186
+ - **`:global()` into kit internals is unsupported.** Class names like `.btn`,
187
+ `.badge`, `.select-wrap` or `.textarea` are private and change without a major
188
+ bump. If an axis you need is missing, open an issue — do not reach in.
189
+
190
+ | component | published properties |
191
+ | --- | --- |
192
+ | `Button` | `--btn-bg`, `--btn-fg`, `--btn-border`, `--btn-size`, `--btn-radius`, `--btn-tone`, `--btn-on`, `--btn-box` |
193
+ | `Badge` | `--badge-bg`, `--badge-fg`, `--badge-border`, `--badge-radius`, `--badge-tone`, `--badge-max-width` |
194
+ | `Text` | `--txt-fg`, `--txt-size` |
195
+ | `Select` | `--select-bg`, `--select-fg`, `--select-border`, `--select-size`, `--select-radius` |
196
+ | `Input` | `--input-bg`, `--input-fg`, `--input-border`, `--input-size`, `--input-radius` |
197
+ | `Textarea` | `--textarea-bg`, `--textarea-fg`, `--textarea-border`, `--textarea-size`, `--textarea-radius` |
198
+ | `Card` | `--card-pad`, `--card-gap`, `--card-border-style` |
199
+ | `Divider` | `--divider-color`, `--divider-spacing` |
200
+ | `Gauge` | `--gauge-w`, `--gauge-h`, `--gauge-fill` |
201
+ | `MasterDetail` | `--md-list-w`, `--md-gap`, `--md-divider` |
202
+ | `NavBar` | `--navbar-max` |
203
+ | `Callout` | `--callout-tone` |
204
+ | `Fieldset` | `--fieldset-pad`, `--fieldset-border` |
205
+ | `GitRef` | `--git-ref-tone`, `--git-ref-max-width` |
206
+ | `EmptyState` | `--empty-tone` |
207
+
208
+ `tests/css-custom-property-contract.test.js` reads this table and fails if a
209
+ listed property is not actually read by its component, so the docs cannot drift.
210
+
157
211
  ## Components
158
212
 
159
213
  **Atoms:** Text, Heading, Button, Input (`icon` inset leading glyph,
@@ -213,7 +267,10 @@ hooks on head/row/cell; `responsive="stack"` turns rows into cards below
213
267
  prefix, the `<table>` stays a table for assistive tech), FilterSearchBar /
214
268
  FilterInput (`size="sm"` compact bar on `--control-height-compact`,
215
269
  `shape="pill"`, `surface`, `hotkey="/"` focuses the input from anywhere
216
- outside an editable element, `showHotkey` renders the `<kbd>` hint, `grow`).
270
+ outside an editable element, `showHotkey` renders the `<kbd>` hint, `grow`;
271
+ `key="cwd"` puts FilterInput in single-key mode — `value` is the bare value
272
+ instead of a `key:"value"` query, no key prefix in the box, the `placeholder`
273
+ shows while empty, and completion still runs through that field's provider).
217
274
 
218
275
  **Layouts:** AppShell (responsive header/sidebar/main/footer — persistent
219
276
  sidebar on desktop, overlay drawer on mobile, optionally resizable;
@@ -166,13 +166,13 @@
166
166
  align-items: center;
167
167
  gap: var(--sp-1);
168
168
  padding: 0.15rem var(--sp-2);
169
- border-radius: var(--r-pill);
169
+ border-radius: var(--badge-radius, var(--r-pill));
170
170
  font-size: var(--fs-xs);
171
171
  font-weight: var(--fw-medium);
172
172
  line-height: 1.4;
173
- background: var(--bg-elevated-2);
174
- color: var(--text-muted);
175
- border: 1px solid var(--border);
173
+ background: var(--badge-bg, var(--bg-elevated-2));
174
+ color: var(--badge-fg, var(--text-muted));
175
+ border: 1px solid var(--badge-border, var(--border));
176
176
  white-space: nowrap;
177
177
  max-width: var(--badge-max-width, 100%);
178
178
  }
@@ -188,9 +188,9 @@
188
188
  padding: 0.05rem var(--sp-2);
189
189
  }
190
190
  .toned {
191
- color: var(--badge-tone);
192
- border-color: color-mix(in srgb, var(--badge-tone) 40%, transparent);
193
- background: color-mix(in srgb, var(--badge-tone) 12%, transparent);
191
+ color: var(--badge-fg, var(--badge-tone));
192
+ border-color: var(--badge-border, color-mix(in srgb, var(--badge-tone) 40%, transparent));
193
+ background: var(--badge-bg, color-mix(in srgb, var(--badge-tone) 12%, transparent));
194
194
  }
195
195
  .text {
196
196
  padding: 0;
@@ -165,11 +165,11 @@
165
165
  justify-content: center;
166
166
  gap: var(--sp-2);
167
167
  padding: var(--sp-2) var(--sp-4);
168
- min-height: var(--control-height-default);
169
- border: 1px solid var(--border-strong);
170
- border-radius: var(--r-md);
171
- background: var(--surface);
172
- color: var(--text);
168
+ min-height: var(--btn-size, var(--control-height-default));
169
+ border: 1px solid var(--btn-border, var(--border-strong));
170
+ border-radius: var(--btn-radius, var(--r-md));
171
+ background: var(--btn-bg, var(--surface));
172
+ color: var(--btn-fg, var(--text));
173
173
  font-weight: var(--fw-medium);
174
174
  font-size: var(--fs-sm);
175
175
  line-height: 1;
@@ -188,9 +188,9 @@
188
188
  cursor: not-allowed;
189
189
  }
190
190
  .btn-primary {
191
- background: var(--accent);
192
- border-color: var(--accent);
193
- color: var(--text-on-accent);
191
+ background: var(--btn-bg, var(--accent));
192
+ border-color: var(--btn-border, var(--accent));
193
+ color: var(--btn-fg, var(--text-on-accent));
194
194
  font-weight: var(--fw-semibold);
195
195
  }
196
196
  .btn-primary:hover:not(:disabled) {
@@ -205,21 +205,21 @@
205
205
  border-color: var(--danger);
206
206
  }
207
207
  .btn-ghost {
208
- background: transparent;
209
- border-color: transparent;
208
+ background: var(--btn-bg, transparent);
209
+ border-color: var(--btn-border, transparent);
210
210
  }
211
211
  .btn-ghost:hover:not(:disabled) {
212
212
  background: var(--bg-elevated-2);
213
213
  border-color: transparent;
214
214
  }
215
215
  .btn-sm {
216
- height: var(--control-height-compact);
217
- min-height: var(--control-height-compact);
216
+ height: var(--btn-size, var(--control-height-compact));
217
+ min-height: var(--btn-size, var(--control-height-compact));
218
218
  padding: var(--sp-1) var(--sp-3);
219
219
  font-size: var(--fs-xs);
220
220
  }
221
221
  .btn-lg {
222
- min-height: var(--control-height-large);
222
+ min-height: var(--btn-size, var(--control-height-large));
223
223
  padding: var(--sp-3) var(--sp-5);
224
224
  font-size: var(--fs-base);
225
225
  }
@@ -298,13 +298,13 @@
298
298
  align-items: center;
299
299
  justify-content: center;
300
300
  gap: var(--sp-2);
301
- height: var(--control-height);
302
- min-height: var(--control-height);
301
+ height: var(--btn-size, var(--control-height));
302
+ min-height: var(--btn-size, var(--control-height));
303
303
  padding: 0 var(--sp-3);
304
- border: 1px solid var(--border-strong);
305
- border-radius: var(--r-md);
306
- background: var(--surface);
307
- color: var(--text);
304
+ border: 1px solid var(--btn-border, var(--border-strong));
305
+ border-radius: var(--btn-radius, var(--r-md));
306
+ background: var(--btn-bg, var(--surface));
307
+ color: var(--btn-fg, var(--text));
308
308
  font-weight: var(--fw-medium);
309
309
  font-size: var(--fs-sm);
310
310
  line-height: 1;
@@ -326,9 +326,9 @@
326
326
  the primary action with the neutral --surface; restore the accent fill when
327
327
  both are present. */
328
328
  .btn-control.btn-primary {
329
- background: var(--accent);
330
- border-color: var(--accent);
331
- color: var(--text-on-accent);
329
+ background: var(--btn-bg, var(--accent));
330
+ border-color: var(--btn-border, var(--accent));
331
+ color: var(--btn-fg, var(--text-on-accent));
332
332
  }
333
333
  .btn-control.btn-primary:hover:not(:disabled) {
334
334
  border-color: var(--accent);
@@ -389,7 +389,7 @@
389
389
  align-self: flex-start;
390
390
  }
391
391
  .btn-pill {
392
- border-radius: var(--r-pill);
392
+ border-radius: var(--btn-radius, var(--r-pill));
393
393
  }
394
394
  /* Link variant: no box, inherits the surrounding text size. Placed after the
395
395
  size rules so their min-height doesn't reapply; a tone wins over --link. */
@@ -419,23 +419,23 @@
419
419
  }
420
420
  /* `square`: side = the height contract in force (size tier or control). */
421
421
  .btn-square {
422
- width: var(--control-height-default);
423
- min-width: var(--control-height-default);
422
+ width: var(--btn-size, var(--control-height-default));
423
+ min-width: var(--btn-size, var(--control-height-default));
424
424
  padding: 0;
425
425
  flex: none;
426
426
  }
427
427
  .btn-square.btn-sm {
428
- width: var(--control-height-compact);
429
- min-width: var(--control-height-compact);
428
+ width: var(--btn-size, var(--control-height-compact));
429
+ min-width: var(--btn-size, var(--control-height-compact));
430
430
  }
431
431
  .btn-square.btn-lg {
432
- width: var(--control-height-large);
433
- min-width: var(--control-height-large);
434
- height: var(--control-height-large);
432
+ width: var(--btn-size, var(--control-height-large));
433
+ min-width: var(--btn-size, var(--control-height-large));
434
+ height: var(--btn-size, var(--control-height-large));
435
435
  }
436
436
  .btn-square.btn-control {
437
- width: var(--control-height);
438
- min-width: var(--control-height);
437
+ width: var(--btn-size, var(--control-height));
438
+ min-width: var(--btn-size, var(--control-height));
439
439
  }
440
440
  /* `box`: explicit square scale, wins over icon/chip/square/size sizing. */
441
441
  .btn-box {
@@ -130,13 +130,13 @@
130
130
  <style>
131
131
  .input {
132
132
  width: 100%;
133
- min-height: var(--control-height-default);
133
+ min-height: var(--input-size, var(--control-height-default));
134
134
  padding: var(--sp-2) var(--sp-3);
135
135
  line-height: var(--lh-tight);
136
- background: var(--bg);
137
- border: 1px solid var(--border-strong);
138
- border-radius: var(--r-md);
139
- color: var(--text);
136
+ background: var(--input-bg, var(--bg));
137
+ border: 1px solid var(--input-border, var(--border-strong));
138
+ border-radius: var(--input-radius, var(--r-md));
139
+ color: var(--input-fg, var(--text));
140
140
  transition: border-color 0.12s var(--ease);
141
141
  }
142
142
  .input:focus {
@@ -148,12 +148,12 @@
148
148
  outline-offset: var(--focus-ring-offset);
149
149
  }
150
150
  .input-sm {
151
- min-height: var(--control-height-compact);
151
+ min-height: var(--input-size, var(--control-height-compact));
152
152
  padding: var(--sp-1) var(--sp-2);
153
153
  font-size: var(--fs-sm);
154
154
  }
155
155
  .input-lg {
156
- min-height: var(--control-height-large);
156
+ min-height: var(--input-size, var(--control-height-large));
157
157
  padding: var(--sp-3) var(--sp-4);
158
158
  font-size: var(--fs-base);
159
159
  }
@@ -166,7 +166,7 @@
166
166
  flex: none;
167
167
  }
168
168
  .input-pill {
169
- border-radius: var(--r-pill);
169
+ border-radius: var(--input-radius, var(--r-pill));
170
170
  padding-inline: var(--sp-4);
171
171
  }
172
172
  .input[aria-invalid='true'] {
@@ -205,13 +205,13 @@
205
205
  }
206
206
  .select {
207
207
  width: 100%;
208
- min-height: var(--control-height-default);
208
+ min-height: var(--select-size, var(--control-height-default));
209
209
  padding: var(--sp-2) var(--sp-3);
210
210
  line-height: var(--lh-tight);
211
- background: var(--bg);
212
- border: 1px solid var(--border-strong);
213
- border-radius: var(--r-md);
214
- color: var(--text);
211
+ background: var(--select-bg, var(--bg));
212
+ border: 1px solid var(--select-border, var(--border-strong));
213
+ border-radius: var(--select-radius, var(--r-md));
214
+ color: var(--select-fg, var(--text));
215
215
  transition: border-color 0.12s var(--ease);
216
216
  }
217
217
  /* Default variant: drop the OS chevron, reserve room for our own. */
@@ -226,7 +226,7 @@
226
226
  }
227
227
  /* Compact inline form for dense headers/toolbars. */
228
228
  .select.select-lg {
229
- min-height: var(--control-height-large);
229
+ min-height: var(--select-size, var(--control-height-large));
230
230
  padding: var(--sp-3) var(--sp-4);
231
231
  font-size: var(--fs-base);
232
232
  }
@@ -240,15 +240,15 @@
240
240
  /* Toolbar contract: size="sm" shares the compact control height so it lines up
241
241
  with Button size="sm", Popover size="sm" and SegmentedControl size="sm". */
242
242
  .select.select-sm {
243
- height: var(--control-height-compact);
243
+ height: var(--select-size, var(--control-height-compact));
244
244
  }
245
245
  .select.w-auto {
246
246
  width: auto;
247
247
  }
248
248
  .select.embedded {
249
- background: var(--bg-elevated-2);
249
+ background: var(--select-bg, var(--bg-elevated-2));
250
250
  border: none;
251
- border-radius: var(--r-sm);
251
+ border-radius: var(--select-radius, var(--r-sm));
252
252
  }
253
253
  .select:focus {
254
254
  outline: none;
@@ -281,7 +281,7 @@
281
281
  gap: var(--sp-2);
282
282
  padding: 0 var(--sp-3);
283
283
  padding-right: calc(var(--sp-3) + 1.25rem);
284
- color: var(--text);
284
+ color: var(--select-fg, var(--text));
285
285
  pointer-events: none;
286
286
  overflow: hidden;
287
287
  }
@@ -95,29 +95,31 @@
95
95
  /* Base: inherit everything — a bare <Text> renders like a plain span. */
96
96
  .text {
97
97
  margin: 0;
98
+ color: var(--txt-fg, inherit);
99
+ font-size: var(--txt-size, inherit);
98
100
  }
99
101
  /* Variants (presets) — listed before tone/weight/size so those override. */
100
102
  .v-body {
101
- font-size: var(--fs-base);
103
+ font-size: var(--txt-size, var(--fs-base));
102
104
  line-height: var(--lh-normal);
103
- color: var(--text);
105
+ color: var(--txt-fg, var(--text));
104
106
  }
105
107
  .v-label {
106
- font-size: var(--fs-sm);
108
+ font-size: var(--txt-size, var(--fs-sm));
107
109
  font-weight: var(--fw-medium);
108
- color: var(--text-muted);
110
+ color: var(--txt-fg, var(--text-muted));
109
111
  }
110
112
  .v-caption {
111
- font-size: var(--fs-xs);
112
- color: var(--text-faint);
113
+ font-size: var(--txt-size, var(--fs-xs));
114
+ color: var(--txt-fg, var(--text-faint));
113
115
  }
114
116
  .v-code {
115
117
  font-family: var(--font-mono);
116
- font-size: 0.92em;
118
+ font-size: var(--txt-size, 0.92em);
117
119
  }
118
120
  .v-eyebrow {
119
- font-size: var(--fs-xs);
120
- color: var(--text-muted);
121
+ font-size: var(--txt-size, var(--fs-xs));
122
+ color: var(--txt-fg, var(--text-muted));
121
123
  font-weight: var(--fw-medium);
122
124
  text-transform: uppercase;
123
125
  letter-spacing: 0.04em;
@@ -127,28 +129,28 @@
127
129
  font-family: var(--font-mono);
128
130
  }
129
131
  .tone-default {
130
- color: var(--text);
132
+ color: var(--txt-fg, var(--text));
131
133
  }
132
134
  .tone-muted {
133
- color: var(--text-muted);
135
+ color: var(--txt-fg, var(--text-muted));
134
136
  }
135
137
  .tone-faint {
136
- color: var(--text-faint);
138
+ color: var(--txt-fg, var(--text-faint));
137
139
  }
138
140
  .tone-success {
139
- color: var(--ok);
141
+ color: var(--txt-fg, var(--ok));
140
142
  }
141
143
  .tone-warn {
142
- color: var(--warn);
144
+ color: var(--txt-fg, var(--warn));
143
145
  }
144
146
  .tone-danger {
145
- color: var(--danger);
147
+ color: var(--txt-fg, var(--danger));
146
148
  }
147
149
  .tone-info {
148
- color: var(--info);
150
+ color: var(--txt-fg, var(--info));
149
151
  }
150
152
  .tone-accent {
151
- color: var(--accent);
153
+ color: var(--txt-fg, var(--accent));
152
154
  }
153
155
  /* Weight — overrides variant weight. */
154
156
  .fw-normal {
@@ -165,46 +167,46 @@
165
167
  }
166
168
  /* Size — overrides variant size (listed last so it wins). */
167
169
  .fs-xs {
168
- font-size: var(--fs-xs);
170
+ font-size: var(--txt-size, var(--fs-xs));
169
171
  }
170
172
  .fs-sm {
171
- font-size: var(--fs-sm);
173
+ font-size: var(--txt-size, var(--fs-sm));
172
174
  }
173
175
  .fs-base {
174
- font-size: var(--fs-base);
176
+ font-size: var(--txt-size, var(--fs-base));
175
177
  }
176
178
  .fs-md {
177
- font-size: var(--fs-md);
179
+ font-size: var(--txt-size, var(--fs-md));
178
180
  }
179
181
  .fs-lg {
180
- font-size: var(--fs-lg);
182
+ font-size: var(--txt-size, var(--fs-lg));
181
183
  }
182
184
  .fs-xl {
183
- font-size: var(--fs-xl);
185
+ font-size: var(--txt-size, var(--fs-xl));
184
186
  }
185
187
  .fs-2xl {
186
- font-size: var(--fs-2xl);
188
+ font-size: var(--txt-size, var(--fs-2xl));
187
189
  }
188
190
  .noscale.fs-xs {
189
- font-size: 12px;
191
+ font-size: var(--txt-size, 12px);
190
192
  }
191
193
  .noscale.fs-sm {
192
- font-size: 13px;
194
+ font-size: var(--txt-size, 13px);
193
195
  }
194
196
  .noscale.fs-base {
195
- font-size: 15px;
197
+ font-size: var(--txt-size, 15px);
196
198
  }
197
199
  .noscale.fs-md {
198
- font-size: 16px;
200
+ font-size: var(--txt-size, 16px);
199
201
  }
200
202
  .noscale.fs-lg {
201
- font-size: 18px;
203
+ font-size: var(--txt-size, 18px);
202
204
  }
203
205
  .noscale.fs-xl {
204
- font-size: 22px;
206
+ font-size: var(--txt-size, 22px);
205
207
  }
206
208
  .noscale.fs-2xl {
207
- font-size: 28px;
209
+ font-size: var(--txt-size, 28px);
208
210
  }
209
211
  .italic {
210
212
  font-style: italic;
@@ -223,16 +223,16 @@
223
223
  --sp-3 top+bottom would overshoot it and a `rows={1}` textarea would
224
224
  render taller than the buttons it sits beside. */
225
225
  padding: var(--sp-2) var(--sp-3);
226
- background: var(--bg);
227
- border: 1px solid var(--border-strong);
228
- border-radius: var(--r-md);
229
- color: var(--text);
226
+ background: var(--textarea-bg, var(--bg));
227
+ border: 1px solid var(--textarea-border, var(--border-strong));
228
+ border-radius: var(--textarea-radius, var(--r-md));
229
+ color: var(--textarea-fg, var(--text));
230
230
  transition: border-color 0.12s var(--ease);
231
231
  /* Custom handle replaces the native grip; never show the native one. */
232
232
  resize: none;
233
233
  /* Match the single-row height of Button/Input so a `rows={1}` textarea
234
234
  lines up with them; the native `rows` attribute grows it from here. */
235
- min-height: 2.5rem;
235
+ min-height: var(--textarea-size, 2.5rem);
236
236
  line-height: var(--lh-tight);
237
237
  font-family: inherit;
238
238
  }
@@ -250,12 +250,12 @@
250
250
  .textarea-sm {
251
251
  padding: var(--sp-1) var(--sp-2);
252
252
  font-size: var(--fs-sm);
253
- min-height: 2rem;
253
+ min-height: var(--textarea-size, 2rem);
254
254
  }
255
255
  .textarea-lg {
256
256
  padding: var(--sp-3) var(--sp-4);
257
257
  font-size: var(--fs-base);
258
- min-height: var(--control-height-large);
258
+ min-height: var(--textarea-size, var(--control-height-large));
259
259
  }
260
260
  .textarea[aria-invalid='true'],
261
261
  .textarea[aria-invalid='true']:focus {
@@ -56,7 +56,7 @@
56
56
  {...rest}
57
57
  >
58
58
  <div class="navbar-inner">
59
- {#each items as item (item.href)}
59
+ {#each items as item, i (`${i}:${item.href}`)}
60
60
  <a
61
61
  href={item.href}
62
62
  class="navbar-link"
@@ -43,13 +43,15 @@
43
43
  import { autoQuoteEdit, backspaceEmptyQuotes, closingQuoteExit } from '../../query/edit';
44
44
  import { parse } from '../../query/parser';
45
45
  import { type Schema } from '../../query/schema';
46
+ import { singleQuery, suggestSingle } from '../../query/single';
46
47
  import { suggest, type SuggestState } from '../../query/suggest';
47
48
  import { getFieldContext, warnUnlabelled } from '../../field-context';
48
49
 
49
50
  let {
50
51
  schema,
52
+ key,
51
53
  value = $bindable(''),
52
- placeholder = 'artist:"Daft Punk" AND year>=2000',
54
+ placeholder,
53
55
  autoQuote = true,
54
56
  showClear = true,
55
57
  icon = 'search',
@@ -70,13 +72,23 @@
70
72
  style: styleProp = '',
71
73
  }: {
72
74
  schema: Schema;
73
- /** The raw textual query (two-way bindable). */
75
+ /**
76
+ * Single-key mode: the name (or alias) of the ONE schema field being
77
+ * edited. `value` then holds the bare value instead of a `key:"value"`
78
+ * query — no key prefix in the box, the placeholder shows while empty and
79
+ * completion still runs through that field's provider. The equivalent query
80
+ * is synthesised for `onchange` and the snippet context.
81
+ */
82
+ key?: string;
83
+ /** The raw textual query, or the bare value in single-key mode (bindable). */
74
84
  value?: string;
85
+ /** Defaults to a sample query, or to nothing in single-key mode. */
75
86
  placeholder?: string;
76
87
  /**
77
88
  * When a string field's value step opens (`title:`), auto-insert a `""`
78
89
  * pair with the caret inside so multi-word values stay together; Tab exits
79
90
  * the quotes. Set false for bare typing where spaces split the value.
91
+ * Always off in single-key mode, which quotes nothing.
80
92
  */
81
93
  autoQuote?: boolean;
82
94
  /** Show the trailing clear (✕) button when the field is non-empty. */
@@ -147,8 +159,12 @@
147
159
  return () => document.removeEventListener('keydown', onHotkey);
148
160
  });
149
161
 
162
+ const single = $derived(!!key);
163
+ const quoting = $derived(autoQuote && !single);
164
+ const hint = $derived(placeholder ?? (single ? '' : 'artist:"Daft Punk" AND year>=2000'));
165
+
150
166
  // Parsed view (drives the onchange AST + the snippet context).
151
- const ast = $derived(parse(value, schema));
167
+ const ast = $derived(parse(key ? singleQuery(schema, key, value) : value, schema));
152
168
  const chips = $derived(filters(ast));
153
169
  const text = $derived(freeText(ast));
154
170
 
@@ -183,7 +199,7 @@
183
199
  }
184
200
 
185
201
  function oninput(e: Event) {
186
- if (autoQuote && el && (e as InputEvent).inputType?.startsWith('insert')) {
202
+ if (quoting && el && (e as InputEvent).inputType?.startsWith('insert')) {
187
203
  const pos = el.selectionStart ?? value.length;
188
204
  const edit = autoQuoteEdit(schema, value, pos);
189
205
  if (edit) {
@@ -200,7 +216,7 @@
200
216
  if (!el) return;
201
217
  const pos = el.selectionStart ?? value.length;
202
218
  const id = ++reqId;
203
- const next = await suggest(schema, value, pos);
219
+ const next = key ? await suggestSingle(schema, key, value) : await suggest(schema, value, pos);
204
220
  if (id !== reqId) return; // a newer keystroke won
205
221
  menu = next;
206
222
  active = 0;
@@ -217,7 +233,7 @@
217
233
  let caret = s.caret;
218
234
  // A field/operator pick that opens a string field's value step gets the
219
235
  // same auto-quotes as manual typing.
220
- if (autoQuote && s.advance) {
236
+ if (quoting && s.advance) {
221
237
  const edit = autoQuoteEdit(schema, value, caret);
222
238
  if (edit) {
223
239
  value = edit.value;
@@ -236,7 +252,7 @@
236
252
  }
237
253
 
238
254
  function onkeydown(e: KeyboardEvent) {
239
- if (autoQuote && el) {
255
+ if (quoting && el) {
240
256
  const pos = el.selectionStart ?? value.length;
241
257
  if (e.key === 'Tab') {
242
258
  // Inside auto-quotes Tab exits them (takes priority over accepting a
@@ -292,6 +308,14 @@
292
308
  }
293
309
 
294
310
  function removeChip(span: [number, number]) {
311
+ // Single-key spans index the synthesised query, not the box: the only
312
+ // clause there is the value itself, so removing it empties the field.
313
+ if (single) {
314
+ value = '';
315
+ onsubmit?.('');
316
+ queueMicrotask(() => el?.focus());
317
+ return;
318
+ }
295
319
  // Splice the clause out, plus trailing spaces to avoid doubles.
296
320
  const [a, b] = span;
297
321
  let end = b;
@@ -332,7 +356,7 @@
332
356
  aria-invalid={ariaInvalid ?? (field?.invalid ? 'true' : undefined)}
333
357
  spellcheck="false"
334
358
  autocomplete="off"
335
- {placeholder}
359
+ placeholder={hint}
336
360
  {oninput}
337
361
  onclick={refresh}
338
362
  onkeyup={(e) => {
@@ -24,13 +24,23 @@ import { type IconName } from '../atoms/Icon.svelte';
24
24
  import { type Schema } from '../../query/schema';
25
25
  type $$ComponentProps = {
26
26
  schema: Schema;
27
- /** The raw textual query (two-way bindable). */
27
+ /**
28
+ * Single-key mode: the name (or alias) of the ONE schema field being
29
+ * edited. `value` then holds the bare value instead of a `key:"value"`
30
+ * query — no key prefix in the box, the placeholder shows while empty and
31
+ * completion still runs through that field's provider. The equivalent query
32
+ * is synthesised for `onchange` and the snippet context.
33
+ */
34
+ key?: string;
35
+ /** The raw textual query, or the bare value in single-key mode (bindable). */
28
36
  value?: string;
37
+ /** Defaults to a sample query, or to nothing in single-key mode. */
29
38
  placeholder?: string;
30
39
  /**
31
40
  * When a string field's value step opens (`title:`), auto-insert a `""`
32
41
  * pair with the caret inside so multi-word values stay together; Tab exits
33
42
  * the quotes. Set false for bare typing where spaces split the value.
43
+ * Always off in single-key mode, which quotes nothing.
34
44
  */
35
45
  autoQuote?: boolean;
36
46
  /** Show the trailing clear (✕) button when the field is non-empty. */
package/dist/index.d.ts CHANGED
@@ -87,7 +87,7 @@ export { default as FilterSearchBar } from './components/organisms/FilterSearchB
87
87
  export { EMOJI_GROUPS, type EmojiEntry, type EmojiGroup, searchEmoji } from './emoji';
88
88
  export { FIELD_KEY, type FieldContext, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
89
89
  export * as filterQuery from './query';
90
- 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 ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
90
+ 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, singleQuery, suggest, suggestSingle, type TextNode, toSql, type ValueContext, type ValueOption, type ValueProvider, walk, } from './query';
91
91
  export { type OptionSection, sectionOptions } from './select-options';
92
92
  export type { ControlSize } from './size';
93
93
  export { fontScale, SCALE_LEVELS, type ScaleLevel } from './stores/fontscale.svelte';
package/dist/index.js CHANGED
@@ -98,7 +98,7 @@ export { EMOJI_GROUPS, searchEmoji } from './emoji';
98
98
  export { FIELD_KEY, getFieldContext, setFieldContext, warnUnlabelled, } from './field-context';
99
99
  // ---- query core (headless: schema / parser / AST / suggest / compilers) ----
100
100
  export * as filterQuery from './query';
101
- export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, suggest, toSql, walk, } from './query';
101
+ export { activeToken, compilePredicate, defaultOperator, filters, findField, freeText, OPERATORS, operatorByCode, operatorById, operatorsFor, parse, resolveValues, serialize, serializeFilter, singleQuery, suggest, suggestSingle, toSql, walk, } from './query';
102
102
  export { sectionOptions } from './select-options';
103
103
  export { fontScale, SCALE_LEVELS } from './stores/fontscale.svelte';
104
104
  // ---- stores / actions ----
@@ -5,5 +5,6 @@ export { parse } from './parser';
5
5
  export { compilePredicate, serialize, serializeFilter, toSql } from './query';
6
6
  export type { FieldDef, FieldType, Operator, OperatorId, Schema, ValueContext, ValueOption, ValueProvider, } from './schema';
7
7
  export { defaultOperator, findField, OPERATORS, operatorByCode, operatorById, operatorsFor, resolveValues, } from './schema';
8
+ export { singleQuery, suggestSingle } from './single';
8
9
  export type { Suggestion, SuggestKind, SuggestState } from './suggest';
9
10
  export { activeToken, suggest } from './suggest';
@@ -7,4 +7,5 @@ export { autoQuoteEdit, backspaceEmptyQuotes, closingQuoteExit, insideQuoteAtCar
7
7
  export { parse } from './parser';
8
8
  export { compilePredicate, serialize, serializeFilter, toSql } from './query';
9
9
  export { defaultOperator, findField, OPERATORS, operatorByCode, operatorById, operatorsFor, resolveValues, } from './schema';
10
+ export { singleQuery, suggestSingle } from './single';
10
11
  export { activeToken, suggest } from './suggest';
@@ -0,0 +1,15 @@
1
+ import { type Schema } from './schema';
2
+ import type { SuggestState } from './suggest';
3
+ /**
4
+ * The textual query a bare `value` stands for, using `key`'s default operator —
5
+ * `"/srv/my app"` under key `cwd` becomes `cwd:"/srv/my app"`. Empty values (and
6
+ * unknown keys) yield an empty query so the AST stays empty rather than parsing
7
+ * a half-written clause.
8
+ */
9
+ export declare function singleQuery(schema: Schema, key: string, value: string): string;
10
+ /**
11
+ * Value suggestions for `key` given the whole box as the fragment. The span
12
+ * always covers the entire value, so accepting an item replaces it outright —
13
+ * there is no field or operator step to advance to.
14
+ */
15
+ export declare function suggestSingle(schema: Schema, key: string, value: string): Promise<SuggestState | null>;
@@ -0,0 +1,43 @@
1
+ // ─────────────────────────────────────────────────────────────────────────
2
+ // Single-key mode — the headless half of FilterInput's `key` prop: the box
3
+ // holds the bare value (`/srv/app`, not `cwd:"/srv/app"`), completing through
4
+ // the field's own provider, and the equivalent query is synthesised only for
5
+ // the `inline` / `below` snippets and the `onchange` AST.
6
+ // ─────────────────────────────────────────────────────────────────────────
7
+ import { defaultOperator, findField, resolveValues } from './schema';
8
+ function quoteIfNeeded(v) {
9
+ return /[\s,()]/.test(v) ? `"${v}"` : v;
10
+ }
11
+ /**
12
+ * The textual query a bare `value` stands for, using `key`'s default operator —
13
+ * `"/srv/my app"` under key `cwd` becomes `cwd:"/srv/my app"`. Empty values (and
14
+ * unknown keys) yield an empty query so the AST stays empty rather than parsing
15
+ * a half-written clause.
16
+ */
17
+ export function singleQuery(schema, key, value) {
18
+ const field = findField(schema, key);
19
+ if (!field || !value)
20
+ return '';
21
+ return `${field.name}${defaultOperator(field).code}${quoteIfNeeded(value)}`;
22
+ }
23
+ /**
24
+ * Value suggestions for `key` given the whole box as the fragment. The span
25
+ * always covers the entire value, so accepting an item replaces it outright —
26
+ * there is no field or operator step to advance to.
27
+ */
28
+ export async function suggestSingle(schema, key, value) {
29
+ const field = findField(schema, key);
30
+ if (!field)
31
+ return null;
32
+ const span = [0, value.length];
33
+ const options = await resolveValues(field, value, { rawQuery: value, span, caret: value.length });
34
+ if (options.length === 0)
35
+ return null;
36
+ const items = options.map((o) => ({
37
+ label: o.label,
38
+ hint: o.hint,
39
+ insert: o.value,
40
+ caret: o.value.length,
41
+ }));
42
+ return { kind: 'value', span, items };
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.50.0",
3
+ "version": "0.52.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",