@happyvertical/smrt-ui 0.55.1 → 0.55.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.
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { untrack } from 'svelte';
2
+ import { type Snippet, untrack } from 'svelte';
3
3
  import {
4
4
  emitControlChange,
5
5
  highlightControl,
@@ -43,6 +43,31 @@ export interface Props {
43
43
  * field stays empty rather than flashing the raw value (an id).
44
44
  */
45
45
  valueLabel?: string;
46
+ /**
47
+ * Whether typing filters `options` by label. Set `false` when the caller
48
+ * supplies the matching options itself (server-side search via `onquery`),
49
+ * so a result that matched on something other than its label stays listed.
50
+ */
51
+ filter?: boolean;
52
+ /**
53
+ * Called with the text to search for when the list opens (empty) and on
54
+ * every keystroke. Pair with `filter={false}` and fresh `options`.
55
+ */
56
+ onquery?: (query: string) => void;
57
+ /**
58
+ * Message announced politely to assistive technology while the list is open
59
+ * ("Searching…", "3 results", "No results"). It is also shown inside the
60
+ * list while there are no options to list.
61
+ */
62
+ status?: string;
63
+ /** Marks the combobox busy while options are being fetched. */
64
+ busy?: boolean;
65
+ /** Marks the field invalid (`aria-invalid`). */
66
+ invalid?: boolean;
67
+ /** Ids of elements describing the field (error text, hints). */
68
+ describedby?: string;
69
+ /** Custom content for one option row; defaults to the label. */
70
+ optionContent?: Snippet<[ControlOption]>;
46
71
  /** Callback when the selected value changes. */
47
72
  onvaluechange?: (value: string) => void;
48
73
  /** CSS class to apply to the combobox container. */
@@ -59,6 +84,13 @@ let {
59
84
  allowCustom = false,
60
85
  interaction,
61
86
  valueLabel,
87
+ filter = true,
88
+ onquery,
89
+ status,
90
+ busy = false,
91
+ invalid = false,
92
+ describedby,
93
+ optionContent,
62
94
  onvaluechange,
63
95
  class: className = '',
64
96
  }: Props = $props();
@@ -83,7 +115,7 @@ let query = $state(untrack(() => labelForValue(value)));
83
115
  let typed = $state(false);
84
116
  let activeIndex = $state(0);
85
117
  const filtered = $derived(
86
- typed
118
+ typed && filter
87
119
  ? options.filter((option) =>
88
120
  option.label.toLowerCase().includes(query.toLowerCase()),
89
121
  )
@@ -136,6 +168,7 @@ function openList() {
136
168
  (option) => String(option.value) === value,
137
169
  );
138
170
  activeIndex = selected >= 0 ? selected : 0;
171
+ onquery?.('');
139
172
  }
140
173
  function closeList() {
141
174
  open = false;
@@ -147,6 +180,7 @@ function handleInput(event: Event & { currentTarget: HTMLInputElement }) {
147
180
  typed = true;
148
181
  open = true;
149
182
  activeIndex = 0;
183
+ onquery?.(query);
150
184
  if (allowCustom) {
151
185
  value = query;
152
186
  onvaluechange?.(value);
@@ -179,6 +213,10 @@ function handleKeydown(event: KeyboardEvent) {
179
213
  closeList();
180
214
  }
181
215
  }
216
+ // Options that arrive late can be fewer than the highlighted row.
217
+ $effect(() => {
218
+ if (activeIndex >= filtered.length) activeIndex = 0;
219
+ });
182
220
  $effect(() => {
183
221
  const form = inputEl?.form;
184
222
  if (!form) return;
@@ -300,15 +338,19 @@ useControlRegistration(() => {
300
338
  <!-- The form posts the committed value (an option id), never the label the person sees. -->
301
339
  {#if name}<input type="hidden" {name} {value} {disabled} />{/if}
302
340
  <input bind:this={inputEl} id={inputId} role="combobox" autocomplete="off" {placeholder} {disabled} required={required && !value} aria-required={required ? 'true' : undefined} value={query}
303
- aria-expanded={open} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={open && filtered[activeIndex] ? `${listId}-${activeIndex}` : undefined}
341
+ aria-expanded={open} aria-controls={listId} aria-busy={busy ? 'true' : undefined} aria-invalid={invalid ? 'true' : undefined} aria-describedby={describedby} aria-autocomplete="list" aria-activedescendant={open && filtered[activeIndex] ? `${listId}-${activeIndex}` : undefined}
304
342
  onfocus={openList} onclick={openList} oninput={handleInput} onkeydown={handleKeydown} />
305
343
  {#if open && filtered.length}<div id={listId} class="options" role="listbox">{#each filtered as option, index (option.value)}<button id={`${listId}-${index}`} type="button" role="option"
306
- aria-selected={String(option.value) === value} class:active={index === activeIndex} disabled={option.disabled} onpointerdown={(event) => event.preventDefault()} onclick={() => commit(option, true)}>{option.label}</button>{/each}</div>{/if}
344
+ aria-selected={String(option.value) === value} class:active={index === activeIndex} disabled={option.disabled} onpointerdown={(event) => event.preventDefault()} onclick={() => commit(option, true)}>{#if optionContent}{@render optionContent(option)}{:else}{option.label}{/if}</button>{/each}</div>
345
+ {:else if open && status}<div class="options empty" aria-hidden="true">{status}</div>{/if}
346
+ {#if status !== undefined}<div class="sr-status" role="status" aria-live="polite">{open ? status : ''}</div>{/if}
307
347
  </div>
308
348
  <style>
309
349
  .combobox { position: relative; display: grid; gap: var(--smrt-spacing-1); color: var(--smrt-color-on-surface); }
310
350
  label { font: var(--smrt-typography-label-large-font); } input { box-sizing: border-box; width: 100%; padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface); color: inherit; }
311
351
  input:focus { outline: 2px solid var(--smrt-color-primary); outline-offset: 1px; } .options { position: absolute; z-index: var(--smrt-z-index-dropdown); top: 100%; left: 0; right: 0; display: grid; padding: var(--smrt-spacing-1); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-small); background: var(--smrt-color-surface-container); box-shadow: var(--smrt-elevation-2); }
312
352
  .options button { padding: var(--smrt-spacing-2) var(--smrt-spacing-3); border: 0; border-radius: var(--smrt-radius-extra-small); background: transparent; color: var(--smrt-color-on-surface); text-align: left; } .options button.active { background: var(--smrt-color-secondary-container); }
353
+ .options.empty { padding: var(--smrt-spacing-2) var(--smrt-spacing-3); color: var(--smrt-color-on-surface-variant); }
354
+ .sr-status { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
313
355
  :global(.combobox[data-smrt-highlighted='true']) { outline: 3px solid var(--smrt-color-tertiary); outline-offset: 4px; }
314
356
  </style>
@@ -1,3 +1,4 @@
1
+ import { type Snippet } from 'svelte';
1
2
  import type { ControlInteractionOptions, ControlOption } from './control-interaction.js';
2
3
  export interface Props {
3
4
  /** Array of options to select from. */
@@ -24,6 +25,31 @@ export interface Props {
24
25
  * field stays empty rather than flashing the raw value (an id).
25
26
  */
26
27
  valueLabel?: string;
28
+ /**
29
+ * Whether typing filters `options` by label. Set `false` when the caller
30
+ * supplies the matching options itself (server-side search via `onquery`),
31
+ * so a result that matched on something other than its label stays listed.
32
+ */
33
+ filter?: boolean;
34
+ /**
35
+ * Called with the text to search for when the list opens (empty) and on
36
+ * every keystroke. Pair with `filter={false}` and fresh `options`.
37
+ */
38
+ onquery?: (query: string) => void;
39
+ /**
40
+ * Message announced politely to assistive technology while the list is open
41
+ * ("Searching…", "3 results", "No results"). It is also shown inside the
42
+ * list while there are no options to list.
43
+ */
44
+ status?: string;
45
+ /** Marks the combobox busy while options are being fetched. */
46
+ busy?: boolean;
47
+ /** Marks the field invalid (`aria-invalid`). */
48
+ invalid?: boolean;
49
+ /** Ids of elements describing the field (error text, hints). */
50
+ describedby?: string;
51
+ /** Custom content for one option row; defaults to the label. */
52
+ optionContent?: Snippet<[ControlOption]>;
27
53
  /** Callback when the selected value changes. */
28
54
  onvaluechange?: (value: string) => void;
29
55
  /** CSS class to apply to the combobox container. */
@@ -1 +1 @@
1
- {"version":3,"file":"Combobox.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Combobox.svelte.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,yBAAyB,EACzB,aAAa,EACd,MAAM,0BAA0B,CAAC;AAWlC,MAAM,WAAW,KAAK;IACpB,uCAAuC;IACvC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,mDAAmD;IACnD,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gDAAgD;IAChD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAyQD,QAAA,MAAM,QAAQ,gDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"Combobox.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/forms/Combobox.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,OAAO,EAAW,MAAM,QAAQ,CAAC;AAM/C,OAAO,KAAK,EACV,yBAAyB,EACzB,aAAa,EACd,MAAM,0BAA0B,CAAC;AAWlC,MAAM,WAAW,KAAK;IACpB,uCAAuC;IACvC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,kDAAkD;IAClD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,4BAA4B;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,mDAAmD;IACnD,WAAW,CAAC,EAAE,yBAAyB,GAAG,KAAK,CAAC;IAChD;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,gDAAgD;IAChD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gEAAgE;IAChE,aAAa,CAAC,EAAE,OAAO,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC;IACzC,gDAAgD;IAChD,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAwRD,QAAA,MAAM,QAAQ,gDAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-ui",
3
- "version": "0.55.1",
3
+ "version": "0.55.2",
4
4
  "smrtJsdoc": "strict",
5
5
  "description": "Domain-agnostic Svelte 5 UI runtime for SMRT: primitives, i18n client, theme system, and module UI registry",
6
6
  "type": "module",
@@ -139,7 +139,7 @@
139
139
  "access": "public"
140
140
  },
141
141
  "dependencies": {
142
- "@happyvertical/smrt-types": "0.55.1",
142
+ "@happyvertical/smrt-types": "0.55.2",
143
143
  "esm-env": "^1.2.2"
144
144
  },
145
145
  "peerDependencies": {