svelte-5-select 1.0.0 β†’ 2.0.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.
Files changed (36) hide show
  1. package/README.md +239 -109
  2. package/dist/ChevronIcon.svelte +8 -13
  3. package/dist/ClearIcon.svelte +3 -11
  4. package/dist/LoadingIcon.svelte +9 -2
  5. package/dist/Select.svelte +955 -437
  6. package/dist/Select.svelte.d.ts +37 -5
  7. package/dist/aria-handlers.svelte.d.ts +4 -1
  8. package/dist/aria-handlers.svelte.js +19 -8
  9. package/dist/filter.d.ts +10 -2
  10. package/dist/filter.js +14 -7
  11. package/dist/index.d.ts +2 -3
  12. package/dist/index.js +1 -2
  13. package/dist/keyboard-navigation.svelte.d.ts +7 -2
  14. package/dist/keyboard-navigation.svelte.js +285 -47
  15. package/dist/no-styles/ChevronIcon.svelte +11 -0
  16. package/dist/no-styles/ChevronIcon.svelte.d.ts +26 -0
  17. package/dist/no-styles/ClearIcon.svelte +8 -0
  18. package/dist/no-styles/ClearIcon.svelte.d.ts +26 -0
  19. package/dist/no-styles/LoadingIcon.svelte +13 -0
  20. package/dist/no-styles/LoadingIcon.svelte.d.ts +26 -0
  21. package/dist/no-styles/Select.svelte +1383 -0
  22. package/dist/no-styles/Select.svelte.d.ts +38 -0
  23. package/dist/select-state.svelte.d.ts +15 -0
  24. package/dist/select-state.svelte.js +161 -0
  25. package/dist/styles/default.css +112 -71
  26. package/dist/tailwind.css +120 -17
  27. package/dist/types.d.ts +459 -129
  28. package/dist/use-hover.svelte.d.ts +3 -7
  29. package/dist/use-hover.svelte.js +91 -36
  30. package/dist/use-load-options.svelte.d.ts +18 -3
  31. package/dist/use-load-options.svelte.js +333 -42
  32. package/dist/use-value.svelte.d.ts +2 -11
  33. package/dist/use-value.svelte.js +322 -111
  34. package/dist/utils.d.ts +19 -8
  35. package/dist/utils.js +52 -8
  36. package/package.json +117 -95
package/README.md CHANGED
@@ -14,11 +14,7 @@
14
14
 
15
15
  ## Demos
16
16
 
17
- [πŸ’₯ Examples of every prop, event, slot and more πŸ’₯](https://svelte-5-select-examples.vercel.app)
18
-
19
- [✨ REPL: Simple ✨] Doesn't exist yet
20
-
21
- [πŸ’ƒ REPL: Show me everything πŸ•Ί]Doesn't exist yet
17
+ [πŸ’₯ Examples of every event and snippet, plus the major props πŸ’₯](https://github.com/IDontKnowMyUsername/svelte-5-select/tree/master/src/routes/examples)
22
18
 
23
19
  ## Installation
24
20
 
@@ -38,9 +34,9 @@ List position and floating is powered by `floating-ui`, see their [package-entry
38
34
 
39
35
  | Prop | Type | Default | Description |
40
36
  | ---------------------- | --------- | --------------- | -------------------------------------------------------------- |
41
- | items | `any[]` | `[]` | Array of items available to display / filter |
42
- | value | `any` | `null` | Selected value(s) |
43
- | justValue | `any` | `null` | **READ-ONLY** Selected value(s) excluding container object |
37
+ | items | `any[]` | `null` | Array of items available to display / filter |
38
+ | value | `any` | `undefined` | Selected value(s); an emptied selection is always `undefined` |
39
+ | justValue | `any` | `undefined` | Raw `itemId` value(s) of the selection (pairs with `useJustValue`); bindable β€” writing it while no selection exists hydrates `value`; while one exists it stays derived from `value` |
44
40
  | itemId | `string` | `value` | Override default identifier |
45
41
  | label | `string` | `label` | Override default label |
46
42
  | id | `string` | `null` | id attr for input field |
@@ -51,14 +47,14 @@ List position and floating is powered by `floating-ui`, see their [package-entry
51
47
  | class | `string` | `''` | container classes |
52
48
  | containerStyles | `string` | `''` | Add inline styles to container |
53
49
  | clearable | `boolean` | `true` | Enable clearing of value(s) |
54
- | disabled | `boolean` | `false` | Disable select |
50
+ | disabled | `boolean` | `false` | Disable select: closes the list and releases focus; keeps the selection, except with `loadOptions` (value and loaded items are cleared) |
55
51
  | multiple | `boolean` | `false` | Enable multi-select |
56
- | searchable | `boolean` | `true` | If `false` search/filtering is disabled |
52
+ | searchable | `boolean` | `true` | If `false` search/filtering is disabled; typing moves to the next matching option (type-ahead) |
57
53
  | groupHeaderSelectable | `boolean` | `false` | Enable selectable group headers |
58
- | focused | `boolean` | `false` | Controls input focus |
54
+ | focused | `boolean` | `false` | Input focus; set `true` to focus the input, `false` to blur it and close the list |
59
55
  | listAutoWidth | `boolean` | `true` | If `false` will ignore width of select |
60
56
  | showChevron | `boolean` | `false` | Show chevron |
61
- | inputAttributes | `object` | `{}` | Pass in HTML attributes to Select's input |
57
+ | inputAttributes | `object` | `{}` | Pass in HTML attributes to Select's input; `on*` handlers run after the component's own, not instead of them |
62
58
  | placeholderAlwaysShow | `boolean` | `false` | When `multiple` placeholder text will always show |
63
59
  | loading | `boolean` | `false` | Shows `loading-icon`. `loadOptions` will override this |
64
60
  | listOffset | `number` | `5` | `px` space between select and list |
@@ -68,45 +64,97 @@ List position and floating is powered by `floating-ui`, see their [package-entry
68
64
  | name | `string` | `null` | Name attribute of hidden input, helpful for form actions |
69
65
  | required | `boolean` | `false` | If `Select` is within a `<form>` will restrict form submission |
70
66
  | multiFullItemClearable | `boolean` | `false` | When `multiple` selected items will clear on click |
71
- | closeListOnChange | `boolean` | `true` | After `on:change` list will close |
72
- | clearFilterTextOnBlur | `boolean` | `true` | If `false`, `filterText` value is preserved on:blur |
67
+ | closeListOnChange | `boolean` | `true` | After selection the list will close |
68
+ | clearFilterTextOnBlur | `boolean` | `true` | If `false`, `filterText` value is preserved on blur |
69
+ | useJustValue | `boolean` | `false` | Hidden form input uses `justValue` (raw id) instead of JSON |
70
+ | filterSelectedItems | `boolean` | `true` | When `multiple`, hide selected items from the list |
71
+ | inputStyles | `string` | `''` | Add inline styles to the input |
72
+ | listStyles | `string` | `''` | Add inline styles to the list |
73
+ | hoverItemIndex | `number` | `0` | Index of the currently hovered item (bindable) |
74
+ | loadOptionsDeps | `any[]` | `[]` | When these values change, `loadOptions` re-fires. Compared by `===` β€” pass primitives or stable references, not inline literals |
75
+ | ariaLabel | `string` | `undefined` | Explicit `aria-label` for the input (and the listbox); when omitted, a `<label for={id}>` (or the placeholder, as a last resort) names it |
76
+ | ariaErrorMessage | `string` | `undefined` | id of your error element; wired to the input's `aria-errormessage` while `hasError` is `true` |
77
+ | ariaClearSelectLabel | `string` | `'Clear selection'` | `aria-label` for the clear-all button |
78
+ | ariaRemoveItemLabel | `(label: string) => string` | ``(label) => `Remove ${label}` `` | `aria-label` for each multi-select tag's remove button |
79
+ | ariaCleared | `() => string` | see below | Announcement after the selection is cleared |
80
+ | ariaEmpty | `() => string` | see below | Announcement when the open list has no options |
81
+ | ariaLoading | `() => string` | see below | Announcement while the open list is loading |
82
+ | ariaFocused | `() => string` | see below | Announcement when the input receives focus |
83
+ | ariaListOpen | `(label: string, count: number) => string` | see below | Announcement when the list opens on a focused option |
84
+ | ariaValues | `(values: string) => string` | see below | Announcement naming the current selection |
85
+
86
+ The `aria*` text-builder defaults are shown in the [A11y](#a11y-accessibility) section. See [Function props](#function-props) for the overridable behavior functions (`loadOptions`, `filter`, `groupBy`, and friends).
87
+
88
+ ### Bindable props
89
+
90
+ `value`, `filterText`, `items`, `listOpen`, `loading`, `focused`, `justValue`, and `hoverItemIndex` support `bind:`. The DOM references `container` and `input` are also bindable.
91
+
92
+ ```svelte
93
+ <Select {items} bind:value bind:listOpen />
94
+ ```
95
+
96
+ > **The DOM input's value is the filter text, not the selection.** A selection is rendered in an element beside the input (so it can be rich markup via `selectionSnippet`, or multiple tags) and announced to screen readers via the live regions β€” it is never written into the textbox, so `input.value` is `''` unless the user is filtering. Read the selection from `bind:value`/`bind:justValue`, the `onValueChange`/`onSelectionChange` callbacks, or the submitted form field (`name` prop); in tests, assert on `.selected-item` (or `.multi-item`) rather than the input's value.
97
+
73
98
 
99
+ ## Snippets
74
100
 
75
- ## Named slots
101
+ Rendering is customized with [snippets](https://svelte.dev/docs/svelte/snippet). Declare them inside `<Select>` and they are passed as props automatically.
76
102
 
77
103
  ```svelte
78
- <Select>
79
- <div slot="prepend" />
80
- <div slot="selection" let:selection let:index /> <!-- index only available when multiple -->
81
- <div slot="clear-icon" />
82
- <div slot="multi-clear-icon" />
83
- <div slot="loading-icon" />
84
- <div slot="chevron-icon" />
85
- <div slot="list-prepend" />
86
- <div slot="list" let:filteredItems />
87
- <div slot="list-append" />
88
- <div slot="item" let:item let:index />
89
- <div slot="input-hidden" let:value />
90
- <div slot="required" let:value />
91
- <!-- Remember you can also use `svelte:fragment` to avoid a container DOM element. -->
92
- <svelte:fragment slot="empty" />
104
+ <Select {items}>
105
+ {#snippet prependSnippet()}
106
+ <span>πŸ”</span>
107
+ {/snippet}
108
+
109
+ {#snippet itemSnippet(item, index)}
110
+ <div>{index + 1}. {item.label}</div>
111
+ {/snippet}
112
+
113
+ {#snippet selectionSnippet(selection)}
114
+ <strong>{selection.label}</strong>
115
+ {/snippet}
93
116
  </Select>
94
117
  ```
95
118
 
119
+ | Snippet prop | Arguments | Description |
120
+ | --------------------- | -------------------- | ------------------------------------------------------ |
121
+ | chevronIconSnippet | `listOpen` | Chevron indicator icon |
122
+ | clearIconSnippet | β€” | Clear indicator icon |
123
+ | emptySnippet | β€” | Shown when there are no items to display |
124
+ | inputHiddenSnippet | `value` | Override the hidden form input(s) |
125
+ | itemSnippet | `item, index` | A list item |
126
+ | listAppendSnippet | β€” | Rendered after the list |
127
+ | listPrependSnippet | β€” | Rendered before the list |
128
+ | listSnippet | `filteredItems` | Replace the entire list |
129
+ | loadingIconSnippet | β€” | Loading indicator icon |
130
+ | multiClearIconSnippet | β€” | Remove icon on multi-select items |
131
+ | prependSnippet | β€” | Rendered before the value container |
132
+ | requiredSnippet | `value` | Override the hidden native `required` input |
133
+ | selectionSnippet | `selection, index?` | A selected value (`index` only in `multiple` mode) |
134
+
135
+
136
+ ## Events (callback props)
137
+
138
+ Events are plain callback props. Handlers receive the value directly β€” there is no `CustomEvent` and no `event.detail`.
139
+
140
+ ```svelte
141
+ <Select {items} onSelectionChange={(value) => console.log('selected', value)} />
142
+ ```
96
143
 
97
- ## Events
144
+ | Prop | Arguments | Description |
145
+ | ----------------- | ------------------- | --------------------------------------------------------------------------------------- |
146
+ | onblur | `event: FocusEvent` | fires when the input loses focus |
147
+ | onclear | `value` | fires when the value is cleared or a multi-select item is removed |
148
+ | onerror | `{ type, details }` | fires when an error is caught (e.g. a `loadOptions` rejection) |
149
+ | onfilter | `items` | fires when `listOpen: true` and items are filtered |
150
+ | onfocus | `event: FocusEvent` | fires when the input gains focus |
151
+ | onhoveritem | `index` | fires when `hoverItemIndex` changes |
152
+ | onloaded | `options` | fires when `loadOptions` resolves |
153
+ | onselect | `selection` | fires with just the picked item when the user selects an option |
154
+ | onSelectionChange | `value` | fires with the whole value when the user selects an option (never on clears or writes) |
155
+ | onValueChange | `value` | fires on _every_ value change β€” selection, clear, programmatic write, deps invalidation |
98
156
 
99
- | Event Name | Callback | Description |
100
- | ---------- | ----------------- | -------------------------------------------------------------------------- |
101
- | change | { detail } | fires when the user selects an option |
102
- | input | { detail } | fires when the value has been changed |
103
- | focus | { detail } | fires when select > input on:focus |
104
- | blur | { detail } | fires when select > input on:blur |
105
- | clear | { detail } | fires when clear is invoked or item is removed (by user) from multi select |
106
- | loaded | { options } | fires when `loadOptions` resolves |
107
- | error | { type, details } | fires when error is caught |
108
- | filter | { detail } | fires when `listOpen: true` and items are filtered |
109
- | hoverItem | { detail } | fires when hoverItemIndex changes |
157
+ `onSelectionChange` and `onValueChange` are component state-change callbacks (headless-library naming), not DOM events β€” typing lives in `bind:filterText`, and only `onblur`/`onfocus` are literal DOM event passthroughs.
110
158
 
111
159
 
112
160
  ### Items
@@ -115,7 +163,7 @@ List position and floating is powered by `floating-ui`, see their [package-entry
115
163
 
116
164
  ```html
117
165
  <script>
118
- import Select from 'svelte-5-select';
166
+ import { Select } from 'svelte-5-select';
119
167
 
120
168
  let simple = ['one', 'two', 'three'];
121
169
 
@@ -135,7 +183,7 @@ They can also be grouped and include non-selectable items.
135
183
 
136
184
  ```html
137
185
  <script>
138
- import Select from 'svelte-5-select';
186
+ import { Select } from 'svelte-5-select';
139
187
 
140
188
  const items = [
141
189
  {value: 'chocolate', label: 'Chocolate', group: 'Sweet'},
@@ -155,7 +203,7 @@ You can also use custom collections.
155
203
 
156
204
  ```html
157
205
  <script>
158
- import Select from 'svelte-5-select';
206
+ import { Select } from 'svelte-5-select';
159
207
 
160
208
  const itemId = 'id';
161
209
  const label = 'title';
@@ -171,11 +219,11 @@ You can also use custom collections.
171
219
 
172
220
  ### Async Items
173
221
 
174
- To load items asynchronously then `loadOptions` is the simplest solution. Supply a function that returns a `Promise` that resolves with a list of items. `loadOptions` has debounce baked in and fires each time `filterText` is updated.
222
+ To load items asynchronously then `loadOptions` is the simplest solution. Supply a function that returns a `Promise` that resolves with a list of items. `loadOptions` fires once on mount, on typing non-empty `filterText` (debounced), and whenever `loadOptionsDeps` or `disabled` change. Emptying the filter text or closing the list cancels a pending typing-driven load instead of re-fetching. One open/close-related exception: reopening the list with retained filter text whose load was cancelled on close (e.g. with `clearFilterTextOnBlur={false}`) refetches immediately, so the list never shows results that are stale for the visible text.
175
223
 
176
224
  ```html
177
225
  <script>
178
- import Select from 'svelte-5-select';
226
+ import { Select } from 'svelte-5-select';
179
227
 
180
228
  import { someApiCall } from './services';
181
229
 
@@ -198,7 +246,7 @@ To load items asynchronously then `loadOptions` is the simplest solution. Supply
198
246
 
199
247
  ```html
200
248
  <script>
201
- import Select from 'svelte-5-select';
249
+ import { Select } from 'svelte-5-select';
202
250
 
203
251
  let floatingConfig = {
204
252
  strategy: 'fixed'
@@ -208,92 +256,174 @@ To load items asynchronously then `loadOptions` is the simplest solution. Supply
208
256
  <Select {floatingConfig} />
209
257
  ```
210
258
 
211
- ### Exposed methods
212
- These internal functions are exposed to override if needed. Look through the test file (test/src/index.js) for examples.
259
+ ### Function props
213
260
 
214
- ```js
215
- export let itemFilter = (label, filterText, option) => label.toLowerCase().includes(filterText.toLowerCase());
216
- ```
261
+ Core behavior can be overridden by passing your own functions as props. Look through the test suite ([tests/src/select.test.ts](/tests/src/select.test.ts)) and [src/lib/filter.ts](/src/lib/filter.ts) for examples.
217
262
 
218
- ```js
219
- export let groupBy = undefined;
220
- ```
263
+ ```svelte
264
+ <script>
265
+ import { Select } from 'svelte-5-select';
221
266
 
222
- ```js
223
- export let groupFilter = groups => groups;
224
- ```
267
+ // How a single item is matched against filterText
268
+ const itemFilter = (label, filterText, option) => label.toLowerCase().includes(filterText.toLowerCase());
269
+
270
+ // Grouping
271
+ const groupBy = (item) => item.group;
272
+ const groupFilter = (groups) => groups; // sort/filter group order
273
+ const createGroupHeaderItem = (groupValue, item) => ({ value: groupValue, label: groupValue });
225
274
 
226
- ```js
227
- export let createGroupHeaderItem = groupValue => {
228
- return {
229
- value: groupValue,
230
- label: groupValue
275
+ // Async loading β€” must return a Promise that resolves with items
276
+ const loadOptions = async (filterText) => fetchMyItems(filterText);
277
+
278
+ // Debounce used by loadOptions
279
+ let timeout;
280
+ const debounce = (fn, wait = 1) => {
281
+ clearTimeout(timeout);
282
+ timeout = setTimeout(fn, wait);
231
283
  };
232
- };
233
- ```
234
284
 
235
- ```js
236
- export function handleClear() {
237
- value = undefined;
238
- listOpen = false;
239
- onClear(value);
240
- handleFocus();
241
- }
242
- ```
285
+ // Runs when the clear indicator is clicked
286
+ const handleClear = () => { /* your own clearing logic */ };
287
+ </script>
243
288
 
244
- ```js
245
- export let loadOptions = undefined; // if used must return a Promise that updates 'items'
246
- /* Return an object with { cancelled: true } to keep the loading state as active. */
289
+ <Select {items} {itemFilter} {groupBy} {groupFilter} {createGroupHeaderItem} {loadOptions} {handleClear} />
247
290
  ```
248
291
 
249
- ```js
250
- export const getFilteredItems = () => {
251
- return filteredItems;
252
- };
253
- ```
292
+ The `filter` prop replaces the entire filtering pipeline β€” override it at your own risk.
293
+
294
+ | Prop | Type | Description |
295
+ | --------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------- |
296
+ | loadOptions | `(filterText: string) => Promise<Item[] \| string[]>` | Load items asynchronously; see [Async Items](#async-items). Overrides the `loading` prop. |
297
+ | itemFilter | `(label: string, filterText: string, option) => boolean` | Whether one item matches `filterText`. Defaults to a case-insensitive substring match. |
298
+ | groupBy | `(item) => string` | Group items under headers by the returned key. Unset by default. |
299
+ | groupFilter | `(groups: string[]) => string[]` | Sort or filter the group order. Defaults to identity (`(groups) => groups`). |
300
+ | createGroupHeaderItem | `(groupValue: string, item) => SelectItem` | Build the header item for a group. Defaults to `{ value: groupValue, [label]: groupValue }` β€” the header's text is keyed by the `label` prop. |
301
+ | debounce | `(fn: () => void, wait: number) => void` | Debounce strategy for `loadOptions`. Defaults to a `setTimeout` of `debounceWait` ms. |
302
+ | handleClear | `(e?: MouseEvent) => void` | Runs when the clear indicator is clicked. Defaults to clearing `value` and refocusing. |
303
+ | filter | `(config: FilterConfig<Item>) => (Item \| SelectItem)[]` | Replaces the entire filtering pipeline. Defaults to the built-in `filter`. Override at your own risk. |
254
304
 
255
- ```js
256
- export let debounce = (fn, wait = 1) => {
257
- clearTimeout(timeout);
258
- timeout = setTimeout(fn, wait);
259
- };
305
+ ### Instance methods
306
+
307
+ A component reference exposes a couple of methods:
308
+
309
+ ```svelte
310
+ <script>
311
+ import { Select } from 'svelte-5-select';
312
+
313
+ let select;
314
+ </script>
315
+
316
+ <Select bind:this={select} {items} />
317
+
318
+ <button onclick={() => console.log(select.getFilteredItems())}>Log filtered items</button>
319
+ <button onclick={() => select.reset()}>Reset</button>
260
320
  ```
261
321
 
262
- Override core functionality at your own risk! See ([get-items.js](/src/lib/get-items.js) & [filter.js](/src/lib/filter.js))
322
+ ## Exports
323
+
324
+ Everything `svelte-5-select` exports:
325
+
326
+ | Export | Kind | Description |
327
+ | ------ | ---- | ----------- |
328
+ | `Select` | component | The select/combobox component, default styles included. |
329
+ | `ChevronIcon`, `ClearIcon`, `LoadingIcon` | components | The built-in icons, exported for reuse inside custom snippets. |
330
+ | `filter` | function | The filtering pipeline `Select` uses internally β€” the basis for a custom `filter` prop (see [Function props](#function-props)). |
331
+ | `areItemsEqual` | function | Compares two items by `itemId`. |
332
+ | `isGroupHeader` | function | Type guard narrowing a `SelectRow` to a group header synthesized by `groupBy`. |
333
+ | `normalizeItem` | function | Resolves a raw string to a `{ value, label }` item; passes items through. |
334
+
335
+ Types: `ItemLike`, `SelectItem`, `SelectGroupHeader`, `SelectRow`, `SelectProps`, `SelectValue`, `SelectValueProp`, `SelectClearValue`, `JustValue`, `FloatingConfig`, `FilterConfig`, and `SelectErrorEvent`.
336
+
337
+ Subpath exports: `svelte-5-select/styles/default.css` (the default stylesheet on its own), `svelte-5-select/tailwind.css`, and `svelte-5-select/no-styles/Select.svelte` (the component with its style block stripped β€” see the [experimental section](#-experimental-replace-styles-tailwind-bootstrap-bulma-etc)).
338
+
339
+ Internal wiring (the shared state store, the composables, and their types) is deliberately not exported and cannot be deep-imported.
263
340
 
264
- ```js
265
- // core replaceable methods...
266
- <Select
267
- filter={...}
268
- getItems={...}
269
- />
341
+ ## TypeScript
342
+
343
+ The component is generic over your item type: values, items, snippets, and callbacks are typed from the `items` you pass in β€” plain interfaces work, no index signature needed (TypeScript >= 5.4). Only `items`, `value`, and `loadOptions` drive that inference: configuration callbacks like `groupBy`/`itemFilter` receive the inferred `Item` but never widen it, so a loosely-typed callback const can't silently change what `Item` means. The `multiple` prop narrows types too: with `multiple`, `bind:value` and the `onValueChange`/`onSelectionChange` payloads are `Item[]`; without it they are `Item | null`. The `null` appears only in the dispatch payloads (`onValueChange(null)` on clear) β€” an emptied `bind:value` is always `undefined`, so test it with falsiness, not `=== null`.
344
+
345
+ ```svelte
346
+ <script lang="ts">
347
+ interface Country {
348
+ code: string;
349
+ name: string;
350
+ }
351
+
352
+ let countries: Country[] = [
353
+ { code: 'de', name: 'Germany' },
354
+ { code: 'fr', name: 'France' },
355
+ ];
356
+ let value: Country[] | undefined = $state();
357
+ </script>
358
+
359
+ <Select items={countries} itemId="code" label="name" multiple bind:value onValueChange={(v) => v.length} />
270
360
  ```
271
361
 
362
+ `SelectProps`, `SelectItem`, `SelectValue`, and `SelectValueProp` (the bindable `value` shape, which also accepts raw string ids) are exported for annotating your own wrappers.
363
+
272
364
  ## A11y (Accessibility)
273
365
 
274
- Override these methods to change the `aria-context` and `aria-selection` text.
366
+ The input renders as a WAI-ARIA combobox with a listbox popup, including `aria-expanded`, `aria-activedescendant`, `aria-required`, `aria-invalid`, `aria-busy` (while loading), and `aria-multiselectable` where applicable. When `groupBy` is set, each group's options are wrapped in a `role="group"` region named by its header (via `aria-labelledby`). Set `hasError` with `ariaErrorMessage` to wire the input to an external error element via `aria-errormessage`. A `disabled` Select marks the input `aria-disabled` + `readonly` (rather than natively `disabled`) so the combobox and its value stay in the accessibility tree and remain announceable, while staying non-interactive and out of the tab order. Keyboard support covers ArrowUp/ArrowDown, PageUp/PageDown, Home/End, Enter, Tab (commits the highlighted option β€” but only after you've navigated or typed, so tabbing straight past an opened list never selects), Escape, `Alt`+ArrowDown/ArrowUp (open/close), Space (select in select-only mode), and Backspace/ArrowLeft/ArrowRight for multi-select items; with `multiFullItemClearable`, each tag is a focusable button that Enter/Space removes. The open listbox is named by `ariaLabel`, an `aria-labelledby` supplied via `inputAttributes`, or β€” on the `id` + `<label for>` path β€” by that label (a wrapping `<label>` contributes only its own text). The focused input shows a ring (`--focused-box-shadow`) in addition to the border colour; the option under the keyboard cursor shows a >=3:1 outline (`--item-hover-outline`) on top of the hover background; the spinner and item transitions respect `prefers-reduced-motion`; and focus/selection stay visible under Windows High Contrast Mode (`forced-colors`).
275
367
 
276
- ```js
277
- export let ariaValues = (values) => {
278
- return `Option ${values}, selected.`;
279
- }
368
+ The textbox itself only ever contains the typed filter text β€” the current selection is rendered beside it and conveyed to assistive tech through two polite `role="status"` live regions (customizable via the `ariaValues`, `ariaFocused`, `ariaListOpen`, `ariaEmpty`, `ariaLoading`, and `ariaCleared` builders): focusing a valued Select announces the selection, and selecting/clearing announces the change.
280
369
 
281
- export let ariaListOpen = (label, count) => {
282
- return `You are currently focused on option ${label}. There are ${count} results available.`;
283
- }
370
+ Give the input an accessible name with either `ariaLabel` or an external `<label for={id}>` (set the `id` prop). In development the component logs a `console.warn` if it finds neither `ariaLabel`, an `aria-labelledby`, nor an associated `<label>` β€” the placeholder is only a last-resort fallback that some screen readers ignore. The warning is stripped from production builds.
284
371
 
285
- export let ariaFocused = () => {
286
- return `Select is focused, type to refine list, press down to open the menu.`;
287
- }
372
+ Selection and list state (including the empty/loading state) are announced through two polite `role="status"` live regions; the visible "No options"/"Loading Data" text is `aria-hidden` so it is not read twice. Override these props to change the screen-reader announcement text:
373
+
374
+ ```svelte
375
+ <Select
376
+ {items}
377
+ ariaLabel="Choose a flavour"
378
+ ariaValues={(values) => `Option ${values}, selected.`}
379
+ ariaListOpen={(label, count) => `You are currently focused on option ${label}. There are ${count} results available.`}
380
+ ariaFocused={() => `Select is focused, type to refine list, press down to open the menu.`}
381
+ ariaEmpty={() => `No options`}
382
+ ariaLoading={() => `Loading Data`}
383
+ ariaCleared={() => `Selection cleared.`}
384
+ ariaClearSelectLabel="Clear selection"
385
+ ariaRemoveItemLabel={(label) => `Remove ${label}`}
386
+ />
288
387
  ```
289
388
 
389
+ ## Migrating from svelte-select (Svelte 4)
390
+
391
+ The public API moved to idiomatic Svelte 5:
392
+
393
+ - **Slots β†’ snippets.** `<div slot="item" let:item />` becomes `{#snippet itemSnippet(item, index)}...{/snippet}` declared inside `<Select>`. See the [Snippets](#snippets) table for the full mapping (`slot="chevron-icon"` β†’ `chevronIconSnippet`, etc.).
394
+ - **Events β†’ callback props.** `on:change={(e) => e.detail}` becomes `onSelectionChange={(value) => ...}` and `on:input` becomes `onValueChange={(value) => ...}` β€” handlers receive the value directly, with no `event.detail`.
395
+ - **`export let` overrides β†’ regular props.** Functions like `itemFilter`, `groupBy`, and the aria text builders are passed as props.
396
+ - **CSS variables are kebab-case.** `--borderRadius` is now `--border-radius`; see [the full list](/docs/theming_variables.md).
397
+
398
+ ## Migrating from 1.x to 2.0
399
+
400
+ Every change below is covered in detail in the [changelog](CHANGELOG.md); this is the upgrade checklist:
401
+
402
+ - **`oninput` and `onchange` are renamed.** `oninput` is now `onValueChange` and `onchange` is now `onSelectionChange` β€” same payloads, same firing rules. The old names collided with DOM-event expectations on a component wrapping a text input (`oninput` never fired per keystroke; typing is `bind:filterText`).
403
+ - **An emptied `value` is always `undefined`.** Every clear path (clear button, last tag removed, `loadOptionsDeps` invalidation, disabling a `loadOptions` select, multiple→single switch) writes `undefined` — never `null` or `[]` — so test emptiness with falsiness, not `=== null`. `justValue` follows the same rule. Clearing a single select dispatches `onValueChange(null)` instead of `onValueChange([])`.
404
+ - **`onloaded` receives `(Item | SelectItem)[]`, not `Item[]`.** A loader that resolves raw strings delivers the synthesized `{ value, label, index }` items built from them, so handlers explicitly annotated `(options: Item[]) => void` need the widened element type (narrow rows before reading item fields).
405
+ - **Removed exports.** `useKeyboardNavigation` (with its `KeyboardNavigationContext`/`isCancelled` surface) and `isStringArray` are gone; the composables are internal.
406
+ - **`listStyle` is renamed `listStyles`**, matching the `containerStyles`/`inputStyles` pluralization.
407
+ - **`ErrorEvent` is renamed `SelectErrorEvent`** β€” the old name shadowed the DOM's global `ErrorEvent` and has been removed; update imports.
408
+ - **Rendered-list surfaces are typed `SelectRow<Item>`.** `getFilteredItems()`, `onfilter`, `listSnippet`, and `itemSnippet` see the group headers `groupBy` synthesizes; narrow rows with the exported `isGroupHeader` guard.
409
+ - **`SelectValue` takes a `Multiple` type parameter** (inferred from the `multiple` prop), and `onclear` receives the `Multiple`-discriminated `SelectClearValue` instead of a flat union.
410
+ - **`loadOptions` triggers changed.** It fires on mount, on typing, on `loadOptionsDeps` changes, and on disabled toggles β€” never on list open/close, except that reopening onto results that don't reflect the current filter text re-fetches. Pending fetches that become moot are cancelled (including on a programmatic `bind:listOpen = false`), results are no longer re-filtered by `itemFilter`, and only deps-driven reloads clear a stale value.
411
+ - **Enter and Escape pass through when the list is closed.** Keys are only claimed when the component acts on them, so Enter on a closed select now submits the surrounding form and Escape now closes the surrounding dialog β€” re-test forms and dialogs that relied on the old swallow-everything behavior. Exception: in select-only mode (`searchable={false}`) Enter on a closed list opens it instead of passing through, per the APG select-only combobox pattern.
412
+ - **Tab only commits after real navigation.** Tab still selects the highlighted option in a single press, but only once you've moved the cursor (keys or pointer movement over the list) or typed filter text during that open β€” merely opening the list and tabbing away now closes it without selecting, instead of silently committing the first option. A seeded or retained `filterText` doesn't count as typing.
413
+ - **`selectable: undefined` is keyboard-reachable.** Arrow navigation uses the same rule as click/Enter (`selectable !== false`), so an item carrying an explicit `selectable: undefined` is no longer skipped by the keyboard.
414
+ - **`selectionSnippet` is `Snippet<[Item, number?]>`** β€” always a single item, also in multiple mode.
415
+ - **Custom `filter` implementations:** `FilterConfig.filterGroupedItems` is renamed `applyGrouping`, and `FilterConfig.value` is now honestly typed `Item | SelectItem | (Item | SelectItem)[] | null | undefined` (it was `SelectItem`-only) β€” implementations annotating that field may need the widened type.
416
+ - **Markup and a11y changes.** The multi-select remove control is a real `<button>` in the tab order; grouped options are wrapped in `role="group"` regions named by their headers; the input no longer defaults its `aria-label` to the placeholder (name it with `ariaLabel` or an external `<label for>`).
417
+ - **Behavior fixes worth re-testing:** `bind:focused` writes now move real DOM focus; disabling releases focus and keyboard control; and an initial `filterText` is kept on mount (it used to be silently cleared) β€” it filters and drives the mount `loadOptions` fetch, without opening the list or moving focus.
418
+ - **Node >= 18 at runtime** (Svelte 5's own floor); **TypeScript >= 5.4** for the published types (they use `NoInfer`). Developing this repository needs Node >= 22.12.
419
+
290
420
  ## CSS custom properties (variables)
291
421
 
292
422
  You can style a component by overriding [the available CSS custom properties](/docs/theming_variables.md).
293
423
 
294
424
  ```html
295
425
  <script>
296
- import Select from 'svelte-5-select';
426
+ import { Select } from 'svelte-5-select';
297
427
  </script>
298
428
 
299
429
  <Select --border-radius= "10px" --placeholder-color="blue" />
@@ -303,7 +433,7 @@ You can also use the `inputStyles` prop to write in any override styles needed f
303
433
 
304
434
  ```html
305
435
  <script>
306
- import Select from 'svelte-5-select';
436
+ import { Select } from 'svelte-5-select';
307
437
 
308
438
  const items = ['One', 'Two', 'Three'];
309
439
  </script>
@@ -312,9 +442,9 @@ You can also use the `inputStyles` prop to write in any override styles needed f
312
442
  ```
313
443
 
314
444
  ### πŸ§ͺ Experimental: Replace styles (Tailwind, Bootstrap, Bulma etc)
315
- If you'd like to supply your own styles use: `import Select from 'svelte-5-select/no-styles/Select.svelte'`. Then somewhere in your code or build pipeline add your own. There is a tailwind stylesheet via `import 'svelte-5-select/tailwind.css'`. It uses `@extend` so PostCSS is required.
445
+ If you'd like to supply your own styles use: `import Select from 'svelte-5-select/no-styles/Select.svelte'`. Then somewhere in your code or build pipeline add your own. There is a tailwind stylesheet via `import 'svelte-5-select/tailwind.css'`. It uses `@import 'tailwindcss'` and `@apply`, so your app must have Tailwind CSS v4 set up to process it.
316
446
 
317
447
 
318
448
  ## License
319
449
 
320
- [LIL](LICENSE)
450
+ [License](LICENSE)
@@ -1,11 +1,6 @@
1
- <svg
2
- width="100%"
3
- height="100%"
4
- viewBox="0 0 20 20"
5
- focusable="false"
6
- aria-hidden="true">
1
+ <svg width="100%" height="100%" viewBox="0 0 20 20" focusable="false" aria-hidden="true">
7
2
  <path
8
- fill="currentColor"
3
+ fill="currentColor"
9
4
  d="M4.516 7.548c0.436-0.446 1.043-0.481 1.576 0l3.908 3.747
10
5
  3.908-3.747c0.533-0.481 1.141-0.446 1.574 0 0.436 0.445 0.408 1.197 0
11
6
  1.615-0.406 0.418-4.695 4.502-4.695 4.502-0.217 0.223-0.502
@@ -14,9 +9,9 @@
14
9
  </svg>
15
10
 
16
11
  <style>
17
- svg {
18
- width: var(--chevron-icon-width, 20px);
19
- height: var(--chevron-icon-width, 20px);
20
- color: var(--chevron-icon-colour, currentColor);
21
- }
22
- </style>
12
+ svg {
13
+ width: var(--chevron-icon-width, 20px);
14
+ height: var(--chevron-icon-width, 20px);
15
+ color: var(--chevron-icon-colour, currentColor);
16
+ }
17
+ </style>
@@ -1,16 +1,8 @@
1
- <svg
2
- width="100%"
3
- height="100%"
4
- viewBox="-2 -2 50 50"
5
- focusable="false"
6
- aria-hidden="true"
7
- role="presentation"
8
- >
1
+ <svg width="100%" height="100%" viewBox="-2 -2 50 50" focusable="false" aria-hidden="true" role="presentation">
9
2
  <path
10
3
  fill="currentColor"
11
4
  d="M34.923,37.251L24,26.328L13.077,37.251L9.436,33.61l10.923-10.923L9.436,11.765l3.641-3.641L24,19.047L34.923,8.124
12
- l3.641,3.641L27.641,22.688L38.564,33.61L34.923,37.251z"
13
- />
5
+ l3.641,3.641L27.641,22.688L38.564,33.61L34.923,37.251z" />
14
6
  </svg>
15
7
 
16
8
  <style>
@@ -19,4 +11,4 @@
19
11
  height: var(--clear-icon-width, 20px);
20
12
  color: var(--clear-icon-color, currentColor);
21
13
  }
22
- </style>
14
+ </style>
@@ -1,4 +1,4 @@
1
- <svg class="loading" viewBox="25 25 50 50">
1
+ <svg class="loading" viewBox="25 25 50 50" aria-hidden="true" focusable="false">
2
2
  <circle
3
3
  class="circle_path"
4
4
  cx="50"
@@ -30,4 +30,11 @@
30
30
  transform: rotate(360deg);
31
31
  }
32
32
  }
33
- </style>
33
+
34
+ /* Respect users who ask for reduced motion: stop the continuous spin. */
35
+ @media (prefers-reduced-motion: reduce) {
36
+ .loading {
37
+ animation: none;
38
+ }
39
+ }
40
+ </style>