@lyeve-labs/ui-kit 0.8.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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -0
  3. package/dist/components/Accordion.svelte +48 -0
  4. package/dist/components/Accordion.svelte.d.ts +10 -0
  5. package/dist/components/AccordionItem.svelte +55 -0
  6. package/dist/components/AccordionItem.svelte.d.ts +10 -0
  7. package/dist/components/Alert.svelte +60 -0
  8. package/dist/components/Alert.svelte.d.ts +13 -0
  9. package/dist/components/Autocomplete.svelte +198 -0
  10. package/dist/components/Autocomplete.svelte.d.ts +23 -0
  11. package/dist/components/Avatar.svelte +65 -0
  12. package/dist/components/Avatar.svelte.d.ts +9 -0
  13. package/dist/components/AvatarGroup.svelte +45 -0
  14. package/dist/components/AvatarGroup.svelte.d.ts +14 -0
  15. package/dist/components/Badge.svelte +48 -0
  16. package/dist/components/Badge.svelte.d.ts +13 -0
  17. package/dist/components/Banner.svelte +73 -0
  18. package/dist/components/Banner.svelte.d.ts +13 -0
  19. package/dist/components/Breadcrumb.svelte +47 -0
  20. package/dist/components/Breadcrumb.svelte.d.ts +10 -0
  21. package/dist/components/Button.svelte +71 -0
  22. package/dist/components/Button.svelte.d.ts +18 -0
  23. package/dist/components/ButtonGroup.svelte +33 -0
  24. package/dist/components/ButtonGroup.svelte.d.ts +9 -0
  25. package/dist/components/Card.svelte +72 -0
  26. package/dist/components/Card.svelte.d.ts +17 -0
  27. package/dist/components/Checkbox.svelte +80 -0
  28. package/dist/components/Checkbox.svelte.d.ts +14 -0
  29. package/dist/components/DatePicker.svelte +274 -0
  30. package/dist/components/DatePicker.svelte.d.ts +17 -0
  31. package/dist/components/Divider.svelte +23 -0
  32. package/dist/components/Divider.svelte.d.ts +8 -0
  33. package/dist/components/Drawer.svelte +120 -0
  34. package/dist/components/Drawer.svelte.d.ts +16 -0
  35. package/dist/components/Dropdown.svelte +88 -0
  36. package/dist/components/Dropdown.svelte.d.ts +23 -0
  37. package/dist/components/EmptyState.svelte +51 -0
  38. package/dist/components/EmptyState.svelte.d.ts +13 -0
  39. package/dist/components/FileInput.svelte +102 -0
  40. package/dist/components/FileInput.svelte.d.ts +15 -0
  41. package/dist/components/Indicator.svelte +38 -0
  42. package/dist/components/Indicator.svelte.d.ts +11 -0
  43. package/dist/components/Input.svelte +60 -0
  44. package/dist/components/Input.svelte.d.ts +17 -0
  45. package/dist/components/Kbd.svelte +18 -0
  46. package/dist/components/Kbd.svelte.d.ts +8 -0
  47. package/dist/components/Label.svelte +30 -0
  48. package/dist/components/Label.svelte.d.ts +11 -0
  49. package/dist/components/Modal.svelte +105 -0
  50. package/dist/components/Modal.svelte.d.ts +14 -0
  51. package/dist/components/MultiSelect.svelte +212 -0
  52. package/dist/components/MultiSelect.svelte.d.ts +22 -0
  53. package/dist/components/NumberInput.svelte +98 -0
  54. package/dist/components/NumberInput.svelte.d.ts +15 -0
  55. package/dist/components/PageHeader.svelte +22 -0
  56. package/dist/components/PageHeader.svelte.d.ts +10 -0
  57. package/dist/components/Pagination.svelte +112 -0
  58. package/dist/components/Pagination.svelte.d.ts +10 -0
  59. package/dist/components/Progress.svelte +70 -0
  60. package/dist/components/Progress.svelte.d.ts +15 -0
  61. package/dist/components/Radio.svelte +65 -0
  62. package/dist/components/Radio.svelte.d.ts +14 -0
  63. package/dist/components/RadioGroup.svelte +96 -0
  64. package/dist/components/RadioGroup.svelte.d.ts +22 -0
  65. package/dist/components/SearchInput.svelte +81 -0
  66. package/dist/components/SearchInput.svelte.d.ts +10 -0
  67. package/dist/components/Select.svelte +69 -0
  68. package/dist/components/Select.svelte.d.ts +18 -0
  69. package/dist/components/Skeleton.svelte +20 -0
  70. package/dist/components/Skeleton.svelte.d.ts +9 -0
  71. package/dist/components/Spinner.svelte +19 -0
  72. package/dist/components/Spinner.svelte.d.ts +7 -0
  73. package/dist/components/Stat.svelte +45 -0
  74. package/dist/components/Stat.svelte.d.ts +14 -0
  75. package/dist/components/StepIndicator.svelte +142 -0
  76. package/dist/components/StepIndicator.svelte.d.ts +13 -0
  77. package/dist/components/Table.svelte +36 -0
  78. package/dist/components/Table.svelte.d.ts +11 -0
  79. package/dist/components/Tabs.svelte +39 -0
  80. package/dist/components/Tabs.svelte.d.ts +13 -0
  81. package/dist/components/Tag.svelte +56 -0
  82. package/dist/components/Tag.svelte.d.ts +11 -0
  83. package/dist/components/Textarea.svelte +75 -0
  84. package/dist/components/Textarea.svelte.d.ts +26 -0
  85. package/dist/components/ThemeToggle.svelte +33 -0
  86. package/dist/components/ThemeToggle.svelte.d.ts +6 -0
  87. package/dist/components/Toaster.svelte +54 -0
  88. package/dist/components/Toaster.svelte.d.ts +18 -0
  89. package/dist/components/Toggle.svelte +74 -0
  90. package/dist/components/Toggle.svelte.d.ts +11 -0
  91. package/dist/components/Tooltip.svelte +44 -0
  92. package/dist/components/Tooltip.svelte.d.ts +11 -0
  93. package/dist/components/dialog/ConfirmDialog.svelte +95 -0
  94. package/dist/components/dialog/ConfirmDialog.svelte.d.ts +8 -0
  95. package/dist/components/dialog/Dialog.svelte +225 -0
  96. package/dist/components/dialog/Dialog.svelte.d.ts +9 -0
  97. package/dist/components/dialog/DialogContainer.svelte +17 -0
  98. package/dist/components/dialog/DialogContainer.svelte.d.ts +18 -0
  99. package/dist/components/dialog/dialog-manager.svelte.d.ts +54 -0
  100. package/dist/components/dialog/dialog-manager.svelte.js +164 -0
  101. package/dist/components/dialog/types.d.ts +33 -0
  102. package/dist/components/dialog/types.js +10 -0
  103. package/dist/index.d.ts +63 -0
  104. package/dist/index.js +72 -0
  105. package/dist/stores/toast.svelte.d.ts +18 -0
  106. package/dist/stores/toast.svelte.js +28 -0
  107. package/dist/styles/theme.css +148 -0
  108. package/dist/utils/cn.d.ts +13 -0
  109. package/dist/utils/cn.js +24 -0
  110. package/dist/utils/theme.d.ts +26 -0
  111. package/dist/utils/theme.js +55 -0
  112. package/package.json +81 -0
  113. package/src/lib/styles/theme.css +148 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LyEve Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,199 @@
1
+ # @lyeve/ui-kit
2
+
3
+ A clean, accessible Svelte 5 component library. The design system behind [LyEve](https://lyeve.com).
4
+
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [![Svelte 5](https://img.shields.io/badge/svelte-5-ff3e00.svg)](https://svelte.dev)
7
+ [![Tailwind CSS 4](https://img.shields.io/badge/tailwind-v4-38bdf8.svg)](https://tailwindcss.com)
8
+
9
+ ```bash
10
+ pnpm add @lyeve/ui-kit
11
+ ```
12
+
13
+ ```svelte
14
+ <script lang="ts">
15
+ import { Button, Card, toast } from '@lyeve/ui-kit';
16
+ import '@lyeve/ui-kit/styles.css';
17
+ </script>
18
+
19
+ <Card title="Welcome">
20
+ <p>Three lines from a clean dark dashboard.</p>
21
+ <Button onclick={() => toast.success('Hello, world!')}>Try it</Button>
22
+ </Card>
23
+ ```
24
+
25
+ No config file, no theme provider, no setup ceremony.
26
+
27
+ ---
28
+
29
+ ## What's in the box
30
+
31
+ - **48 components:** buttons, inputs, modals, drawers, tabs, tables, toasts, the works.
32
+ - **Two themes:** Soft Dark (default) and Soft Light, switched by a single `data-theme` attribute on `<html>`.
33
+ - **One CSS file:** `@lyeve/ui-kit/styles.css` declares every token; the rest is just Tailwind.
34
+ - **Svelte 5 native:** built on runes and snippets, fully typed end-to-end.
35
+ - **No surprises:** a `<Button />` is a `<button>`, an `<Input />` is an `<input>`. Markup matches the preview.
36
+
37
+ ## Component list
38
+
39
+ <details>
40
+ <summary>48 components, organized by purpose</summary>
41
+
42
+ **Layout & display**
43
+ Card · PageHeader · Divider · Accordion · AccordionItem · Table
44
+
45
+ **Forms**
46
+ Button · ButtonGroup · Input · Textarea · NumberInput · SearchInput · FileInput · Label · Select · MultiSelect · Autocomplete · DatePicker · Checkbox · Radio · RadioGroup · Toggle
47
+
48
+ **Navigation**
49
+ Breadcrumb · Tabs · Pagination · StepIndicator · Dropdown
50
+
51
+ **Overlays**
52
+ Modal · Drawer · Tooltip · Dialog · DialogContainer · ConfirmDialog
53
+
54
+ **Feedback**
55
+ Alert · Banner · Badge · Tag · Indicator · Progress · Spinner · Skeleton · EmptyState · Stat · Kbd
56
+
57
+ **Media**
58
+ Avatar · AvatarGroup
59
+
60
+ **Theming**
61
+ ThemeToggle · Toaster (+ `toast` service)
62
+
63
+ </details>
64
+
65
+ ## Requirements
66
+
67
+ - **Svelte 5.0** or newer
68
+ - **Tailwind CSS v4** with the `@tailwindcss/vite` plugin
69
+ - **Node 20** or newer
70
+
71
+ ## Install
72
+
73
+ ```bash
74
+ pnpm add @lyeve/ui-kit
75
+ # or npm install @lyeve/ui-kit
76
+ # or yarn add @lyeve/ui-kit
77
+ ```
78
+
79
+ If you don't have Tailwind v4 yet:
80
+
81
+ ```bash
82
+ pnpm add -D tailwindcss @tailwindcss/vite
83
+ ```
84
+
85
+ ```ts
86
+ // vite.config.ts
87
+ import { sveltekit } from '@sveltejs/vite-plugin-svelte';
88
+ import tailwindcss from '@tailwindcss/vite';
89
+ import { defineConfig } from 'vite';
90
+
91
+ export default defineConfig({
92
+ plugins: [tailwindcss(), sveltekit()],
93
+ });
94
+ ```
95
+
96
+ ## Use
97
+
98
+ Import the stylesheet once at the top of your app's CSS entry:
99
+
100
+ ```css
101
+ /* src/app.css */
102
+ @import '@lyeve/ui-kit/styles.css';
103
+ ```
104
+
105
+ Then drop components into your routes:
106
+
107
+ ```svelte
108
+ <script lang="ts">
109
+ import { Button, Card, Toaster, toast } from '@lyeve/ui-kit';
110
+ </script>
111
+
112
+ <Card title="Profile" description="Update your details.">
113
+ <Button onclick={() => toast.success('Saved.')}>Save</Button>
114
+ </Card>
115
+
116
+ <Toaster />
117
+ ```
118
+
119
+ Mount the `<Toaster />` component in your root layout to surface toast notifications.
120
+ See `src/lib/components/Toaster.svelte` for the full prop list.
121
+
122
+ ## Theming
123
+
124
+ Two themes ship out of the box. Switch with one attribute:
125
+
126
+ ```html
127
+ <html data-theme="light">
128
+ <!-- light mode -->
129
+ </html>
130
+ <html>
131
+ <!-- dark mode (default) -->
132
+ </html>
133
+ ```
134
+
135
+ Or use the helpers:
136
+
137
+ ```ts
138
+ import { setTheme, toggleTheme, getTheme } from '@lyeve/ui-kit';
139
+
140
+ setTheme('light');
141
+ toggleTheme(); // returns the new theme
142
+ getTheme(); // 'dark' | 'light'
143
+ ```
144
+
145
+ Avoid the flash-of-wrong-theme by adding the boot script to your `app.html`.
146
+ Import `themeBootScript` from `@lyeve/ui-kit` and inline it in `<head>` before any
147
+ stylesheets.
148
+
149
+ Want to bend the palette to your own brand? Override individual tokens after
150
+ the import:
151
+
152
+ ```css
153
+ @import '@lyeve/ui-kit/styles.css';
154
+
155
+ @theme {
156
+ --color-brand: #ff5e9c;
157
+ --color-brand-light: #ff85b3;
158
+ }
159
+ ```
160
+
161
+ ## Local development
162
+
163
+ ```bash
164
+ pnpm install # install dependencies
165
+ pnpm test # run unit tests
166
+ pnpm check # type-check + svelte-check
167
+ pnpm build # svelte-package + publint → dist/
168
+ ```
169
+
170
+ The documentation site lives in its own repo (`lyeve-labs/ui-kit-docs`).
171
+ This repo is a single-purpose component library. Nothing but `src/lib/`.
172
+
173
+ ## Project layout
174
+
175
+ ```
176
+ src/
177
+ └── lib/ # → published as @lyeve/ui-kit
178
+ ├── components/ # 48 .svelte files
179
+ ├── stores/ # toast.svelte.ts
180
+ ├── styles/ # theme.css (the one stylesheet)
181
+ ├── utils/ # cn.ts, theme.ts
182
+ └── index.ts # public API
183
+ ```
184
+
185
+ ## Versioning
186
+
187
+ `@lyeve/ui-kit` follows [SemVer](https://semver.org). While under `1.0`,
188
+ breaking changes bump the **minor** version; additive changes bump the **patch**.
189
+ Every release is logged in [`CHANGELOG.md`](CHANGELOG.md) and on the docs site.
190
+
191
+ ## Contributing
192
+
193
+ Bug reports, prop suggestions, and component proposals are welcome. See
194
+ [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development setup, coding
195
+ conventions, and how the kit stays cohesive.
196
+
197
+ ## License
198
+
199
+ MIT. See [`LICENSE`](LICENSE).
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ import { setContext } from 'svelte';
3
+ import type { Snippet } from 'svelte';
4
+
5
+ interface Props {
6
+ multiple?: boolean;
7
+ flush?: boolean;
8
+ class?: string;
9
+ children: Snippet;
10
+ }
11
+
12
+ let { multiple = false, flush = false, class: cls = '', children }: Props = $props();
13
+
14
+ let openSet = $state<Set<string>>(new Set());
15
+
16
+ function toggle(id: string) {
17
+ if (openSet.has(id)) {
18
+ openSet.delete(id);
19
+ } else {
20
+ if (!multiple) openSet.clear();
21
+ openSet.add(id);
22
+ }
23
+ openSet = new Set(openSet);
24
+ }
25
+
26
+ function isOpen(id: string): boolean {
27
+ return openSet.has(id);
28
+ }
29
+
30
+ setContext<{ isOpen: (id: string) => boolean; toggle: (id: string) => void; flush: boolean }>(
31
+ 'accordion',
32
+ {
33
+ isOpen,
34
+ toggle,
35
+ get flush() {
36
+ return flush;
37
+ },
38
+ },
39
+ );
40
+ </script>
41
+
42
+ <div
43
+ class="{flush
44
+ ? 'divide-y divide-line'
45
+ : 'divide-y divide-line rounded-xl border border-line overflow-hidden'} {cls}"
46
+ >
47
+ {@render children()}
48
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ multiple?: boolean;
4
+ flush?: boolean;
5
+ class?: string;
6
+ children: Snippet;
7
+ }
8
+ declare const Accordion: import("svelte").Component<Props, {}, "">;
9
+ type Accordion = ReturnType<typeof Accordion>;
10
+ export default Accordion;
@@ -0,0 +1,55 @@
1
+ <script lang="ts">
2
+ import { getContext } from 'svelte';
3
+ import type { Snippet } from 'svelte';
4
+
5
+ interface Props {
6
+ id: string;
7
+ title: string;
8
+ class?: string;
9
+ children: Snippet;
10
+ }
11
+
12
+ let { id, title, class: cls = '', children }: Props = $props();
13
+
14
+ const ctx = getContext<
15
+ | {
16
+ isOpen: (id: string) => boolean;
17
+ toggle: (id: string) => void;
18
+ flush: boolean;
19
+ }
20
+ | undefined
21
+ >('accordion');
22
+
23
+ let open = $derived(ctx?.isOpen(id) ?? false);
24
+ </script>
25
+
26
+ <div class="bg-surface {cls}">
27
+ <button
28
+ type="button"
29
+ onclick={() => ctx?.toggle(id)}
30
+ aria-expanded={open}
31
+ class="flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium
32
+ text-fg transition-colors hover:bg-surface-2/50"
33
+ >
34
+ <span>{title}</span>
35
+ <svg
36
+ width="16"
37
+ height="16"
38
+ viewBox="0 0 24 24"
39
+ fill="none"
40
+ stroke="currentColor"
41
+ stroke-width="2"
42
+ stroke-linecap="round"
43
+ stroke-linejoin="round"
44
+ class="shrink-0 text-faint transition-transform {open ? 'rotate-180' : ''}"
45
+ aria-hidden="true"
46
+ >
47
+ <path d="M6 9l6 6 6-6" />
48
+ </svg>
49
+ </button>
50
+ {#if open}
51
+ <div class="px-5 pb-4 text-sm text-muted">
52
+ {@render children()}
53
+ </div>
54
+ {/if}
55
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ id: string;
4
+ title: string;
5
+ class?: string;
6
+ children: Snippet;
7
+ }
8
+ declare const AccordionItem: import("svelte").Component<Props, {}, "">;
9
+ type AccordionItem = ReturnType<typeof AccordionItem>;
10
+ export default AccordionItem;
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ type Tone = 'info' | 'success' | 'warn' | 'danger';
5
+
6
+ interface Props {
7
+ tone?: Tone;
8
+ title?: string;
9
+ dismissible?: boolean;
10
+ ondismiss?: () => void;
11
+ class?: string;
12
+ children?: Snippet;
13
+ }
14
+
15
+ let {
16
+ tone = 'info',
17
+ title = undefined,
18
+ dismissible = false,
19
+ ondismiss = undefined,
20
+ class: klass = '',
21
+ children,
22
+ }: Props = $props();
23
+
24
+ const tones: Record<Tone, { wrap: string; icon: string; mark: string }> = {
25
+ info: { wrap: 'bg-brand/8 border-brand/25', icon: 'text-brand', mark: 'ℹ' },
26
+ success: { wrap: 'bg-success/8 border-success/25', icon: 'text-success', mark: '✓' },
27
+ warn: { wrap: 'bg-warn/8 border-warn/25', icon: 'text-warn', mark: '!' },
28
+ danger: { wrap: 'bg-danger/8 border-danger/25', icon: 'text-danger', mark: '×' },
29
+ };
30
+ </script>
31
+
32
+ <div
33
+ class="flex items-start gap-3 rounded-lg border px-4 py-3 {tones[tone].wrap} {klass}"
34
+ role="alert"
35
+ >
36
+ <span
37
+ class="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-current text-xs font-bold {tones[
38
+ tone
39
+ ].icon}"
40
+ aria-hidden="true"
41
+ >
42
+ {tones[tone].mark}
43
+ </span>
44
+ <div class="flex-1 min-w-0">
45
+ {#if title}<p class="text-sm font-semibold text-fg">{title}</p>{/if}
46
+ {#if children}<div class="text-sm text-muted {title ? 'mt-0.5' : ''}">
47
+ {@render children()}
48
+ </div>{/if}
49
+ </div>
50
+ {#if dismissible}
51
+ <button
52
+ type="button"
53
+ onclick={ondismiss}
54
+ class="text-faint hover:text-fg transition-colors text-lg leading-none -mt-0.5"
55
+ aria-label="Dismiss"
56
+ >
57
+ ×
58
+ </button>
59
+ {/if}
60
+ </div>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ type Tone = 'info' | 'success' | 'warn' | 'danger';
3
+ interface Props {
4
+ tone?: Tone;
5
+ title?: string;
6
+ dismissible?: boolean;
7
+ ondismiss?: () => void;
8
+ class?: string;
9
+ children?: Snippet;
10
+ }
11
+ declare const Alert: import("svelte").Component<Props, {}, "">;
12
+ type Alert = ReturnType<typeof Alert>;
13
+ export default Alert;
@@ -0,0 +1,198 @@
1
+ <script lang="ts">
2
+ // Autocomplete (combobox): type to filter a list, navigate with ↑/↓, select with
3
+ // Enter. Binds `value` to the chosen option's value; shows its label in the input.
4
+ interface Option {
5
+ value: string;
6
+ label: string;
7
+ disabled?: boolean;
8
+ }
9
+
10
+ let {
11
+ value = $bindable(''),
12
+ options,
13
+ label = undefined,
14
+ hint = undefined,
15
+ error = undefined,
16
+ placeholder = 'Type to search…',
17
+ disabled = false,
18
+ required = false,
19
+ id = undefined,
20
+ name = undefined,
21
+ allowClear = true,
22
+ class: cls = '',
23
+ onchange = undefined,
24
+ }: {
25
+ value?: string;
26
+ options: Option[];
27
+ label?: string;
28
+ hint?: string;
29
+ error?: string;
30
+ placeholder?: string;
31
+ disabled?: boolean;
32
+ required?: boolean;
33
+ id?: string;
34
+ name?: string;
35
+ allowClear?: boolean;
36
+ class?: string;
37
+ onchange?: (value: string) => void;
38
+ } = $props();
39
+
40
+ const uid = `ac-${Math.random().toString(36).slice(2, 9)}`;
41
+ const listId = $derived(id ? `${id}-list` : uid);
42
+
43
+ let open = $state(false);
44
+ let query = $state('');
45
+ let active = $state(0); // highlighted index
46
+ let containerEl: HTMLDivElement | undefined = $state();
47
+ let dirty = $state(false); // user is editing the query vs. showing the selected label
48
+
49
+ const selectedLabel = $derived(options.find((o) => o.value === value)?.label ?? '');
50
+ const display = $derived(dirty ? query : selectedLabel);
51
+ const filtered = $derived(
52
+ dirty && query.trim()
53
+ ? options.filter((o) => o.label.toLowerCase().includes(query.trim().toLowerCase()))
54
+ : options,
55
+ );
56
+
57
+ function choose(opt: Option) {
58
+ if (opt.disabled) return;
59
+ value = opt.value;
60
+ onchange?.(opt.value);
61
+ query = '';
62
+ dirty = false;
63
+ open = false;
64
+ }
65
+
66
+ function clear() {
67
+ value = '';
68
+ query = '';
69
+ dirty = false;
70
+ onchange?.('');
71
+ }
72
+
73
+ function onInput(e: Event & { currentTarget: HTMLInputElement }) {
74
+ query = e.currentTarget.value;
75
+ dirty = true;
76
+ open = true;
77
+ active = 0;
78
+ }
79
+
80
+ function onKeydown(e: KeyboardEvent) {
81
+ if (e.key === 'ArrowDown') {
82
+ e.preventDefault();
83
+ open = true;
84
+ active = Math.min(active + 1, filtered.length - 1);
85
+ } else if (e.key === 'ArrowUp') {
86
+ e.preventDefault();
87
+ active = Math.max(active - 1, 0);
88
+ } else if (e.key === 'Enter') {
89
+ if (open && filtered[active]) {
90
+ e.preventDefault();
91
+ choose(filtered[active]);
92
+ }
93
+ } else if (e.key === 'Escape') {
94
+ open = false;
95
+ dirty = false;
96
+ query = '';
97
+ }
98
+ }
99
+
100
+ function handleOutside(e: MouseEvent) {
101
+ if (containerEl && !containerEl.contains(e.target as Node)) {
102
+ open = false;
103
+ dirty = false;
104
+ query = '';
105
+ }
106
+ }
107
+
108
+ $effect(() => {
109
+ if (open) document.addEventListener('click', handleOutside, { capture: true });
110
+ return () => document.removeEventListener('click', handleOutside, { capture: true });
111
+ });
112
+ </script>
113
+
114
+ <div class="flex flex-col gap-1.5 {cls}" bind:this={containerEl}>
115
+ {#if label}
116
+ <label for={id} class="text-sm font-medium text-fg">
117
+ {label}{#if required}<span class="text-danger ml-0.5">*</span>{/if}
118
+ </label>
119
+ {/if}
120
+
121
+ <div class="relative">
122
+ <input
123
+ {id}
124
+ {name}
125
+ {placeholder}
126
+ {disabled}
127
+ {required}
128
+ role="combobox"
129
+ aria-expanded={open}
130
+ aria-controls={listId}
131
+ aria-autocomplete="list"
132
+ autocomplete="off"
133
+ value={display}
134
+ oninput={onInput}
135
+ onkeydown={onKeydown}
136
+ onfocus={() => (open = true)}
137
+ onblur={() => setTimeout(() => (open = false), 150)}
138
+ class="w-full rounded-lg bg-surface-2 border px-3 py-2 text-sm text-fg placeholder:text-faint
139
+ transition-colors outline-none disabled:opacity-50 disabled:cursor-not-allowed
140
+ {allowClear && value ? 'pr-8' : 'pr-3'}
141
+ {error ? 'border-danger focus:border-danger' : 'border-line focus:border-brand'}"
142
+ />
143
+ {#if allowClear && value && !disabled}
144
+ <button
145
+ type="button"
146
+ aria-label="Clear"
147
+ onclick={clear}
148
+ class="absolute right-2.5 top-1/2 -translate-y-1/2 text-faint hover:text-fg leading-none"
149
+ >×</button
150
+ >
151
+ {/if}
152
+
153
+ {#if open}
154
+ <div
155
+ id={listId}
156
+ role="listbox"
157
+ class="absolute z-50 mt-1 w-full max-h-60 overflow-y-auto rounded-xl border border-line
158
+ bg-surface shadow-2xl py-1"
159
+ >
160
+ {#each filtered as opt, i (opt.value)}
161
+ <button
162
+ type="button"
163
+ role="option"
164
+ aria-selected={opt.value === value}
165
+ disabled={opt.disabled}
166
+ onmouseenter={() => (active = i)}
167
+ onclick={() => choose(opt)}
168
+ class="w-full flex items-center justify-between px-3 py-2 text-sm text-left transition-colors
169
+ disabled:opacity-40 disabled:cursor-not-allowed
170
+ {i === active ? 'bg-surface-2' : ''}
171
+ {opt.value === value ? 'text-brand' : 'text-fg'}"
172
+ >
173
+ <span>{opt.label}</span>
174
+ {#if opt.value === value}
175
+ <svg width="12" height="10" viewBox="0 0 12 10" fill="none" aria-hidden="true">
176
+ <path
177
+ d="M1 5l3.5 3.5L11 1.5"
178
+ stroke="currentColor"
179
+ stroke-width="1.5"
180
+ stroke-linecap="round"
181
+ stroke-linejoin="round"
182
+ />
183
+ </svg>
184
+ {/if}
185
+ </button>
186
+ {:else}
187
+ <p class="px-3 py-2 text-sm text-faint">No matches</p>
188
+ {/each}
189
+ </div>
190
+ {/if}
191
+ </div>
192
+
193
+ {#if error}
194
+ <p class="text-xs text-danger">{error}</p>
195
+ {:else if hint}
196
+ <p class="text-xs text-faint">{hint}</p>
197
+ {/if}
198
+ </div>
@@ -0,0 +1,23 @@
1
+ interface Option {
2
+ value: string;
3
+ label: string;
4
+ disabled?: boolean;
5
+ }
6
+ type $$ComponentProps = {
7
+ value?: string;
8
+ options: Option[];
9
+ label?: string;
10
+ hint?: string;
11
+ error?: string;
12
+ placeholder?: string;
13
+ disabled?: boolean;
14
+ required?: boolean;
15
+ id?: string;
16
+ name?: string;
17
+ allowClear?: boolean;
18
+ class?: string;
19
+ onchange?: (value: string) => void;
20
+ };
21
+ declare const Autocomplete: import("svelte").Component<$$ComponentProps, {}, "value">;
22
+ type Autocomplete = ReturnType<typeof Autocomplete>;
23
+ export default Autocomplete;