@r2digisolutions/components 0.6.0 → 0.6.1
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/dist/components/molecules/Combobox/Combobox.stories.d.ts +6 -0
- package/dist/components/molecules/Combobox/Combobox.stories.js +5 -2
- package/dist/components/molecules/Combobox/Combobox.svelte +155 -64
- package/dist/components/molecules/Combobox/Combobox.svelte.d.ts +15 -0
- package/dist/components/molecules/Combobox/ComboboxStory.svelte +25 -12
- package/dist/components/molecules/Combobox/ComboboxStory.svelte.d.ts +1 -0
- package/dist/components/molecules/Combobox/combobox-context.d.ts +12 -0
- package/dist/components/molecules/Combobox/combobox-context.js +10 -0
- package/dist/components/molecules/ComboboxItem/ComboboxItem.stories.d.ts +9 -0
- package/dist/components/molecules/ComboboxItem/ComboboxItem.stories.js +8 -0
- package/dist/components/molecules/ComboboxItem/ComboboxItem.svelte +121 -0
- package/dist/components/molecules/ComboboxItem/ComboboxItem.svelte.d.ts +33 -0
- package/dist/components/molecules/ComboboxItem/ComboboxItemStory.svelte +54 -0
- package/dist/components/molecules/ComboboxItem/ComboboxItemStory.svelte.d.ts +3 -0
- package/dist/components/molecules/Form/Form.svelte +36 -10
- package/dist/components/molecules/FormField/FormField.svelte +56 -19
- package/dist/components/molecules/FormField/FormField.svelte.d.ts +21 -6
- package/dist/components/molecules/FormPasswordInput/FormPasswordInput.svelte +38 -9
- package/dist/components/molecules/FormPasswordInput/FormPasswordInput.svelte.d.ts +8 -1
- package/dist/components/organisms/BlogEditor/BlogEditorStory.svelte +1 -1
- package/dist/components/organisms/FileUploader/FileUploader.svelte +7 -3
- package/dist/components/organisms/InvoicePage/InvoicePage.svelte +1 -1
- package/dist/components/organisms/ProfileHeaderUploader/ProfileHeaderUploader.svelte +30 -10
- package/dist/components/organisms/ProfileHeaderUploader/ProfileHeaderUploader.svelte.d.ts +4 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +3 -1
- package/dist/styles.css +6 -0
- package/dist/utils/formContext.d.ts +58 -0
- package/dist/utils/formContext.js +79 -1
- package/package.json +16 -16
|
@@ -5,6 +5,7 @@ declare const meta: {
|
|
|
5
5
|
creatable?: boolean;
|
|
6
6
|
label?: string;
|
|
7
7
|
disabled?: boolean;
|
|
8
|
+
compound?: boolean;
|
|
8
9
|
}, {}, "">;
|
|
9
10
|
tags: string[];
|
|
10
11
|
argTypes: {
|
|
@@ -14,11 +15,15 @@ declare const meta: {
|
|
|
14
15
|
disabled: {
|
|
15
16
|
control: "boolean";
|
|
16
17
|
};
|
|
18
|
+
compound: {
|
|
19
|
+
control: "boolean";
|
|
20
|
+
};
|
|
17
21
|
};
|
|
18
22
|
args: {
|
|
19
23
|
creatable: false;
|
|
20
24
|
label: string;
|
|
21
25
|
disabled: false;
|
|
26
|
+
compound: false;
|
|
22
27
|
};
|
|
23
28
|
};
|
|
24
29
|
export default meta;
|
|
@@ -26,3 +31,4 @@ type Story = StoryObj<typeof meta>;
|
|
|
26
31
|
export declare const Default: Story;
|
|
27
32
|
export declare const Creatable: Story;
|
|
28
33
|
export declare const Disabled: Story;
|
|
34
|
+
export declare const WithComboboxItem: Story;
|
|
@@ -5,15 +5,18 @@ const meta = {
|
|
|
5
5
|
tags: ['autodocs'],
|
|
6
6
|
argTypes: {
|
|
7
7
|
creatable: { control: 'boolean' },
|
|
8
|
-
disabled: { control: 'boolean' }
|
|
8
|
+
disabled: { control: 'boolean' },
|
|
9
|
+
compound: { control: 'boolean' }
|
|
9
10
|
},
|
|
10
11
|
args: {
|
|
11
12
|
creatable: false,
|
|
12
13
|
label: 'Framework',
|
|
13
|
-
disabled: false
|
|
14
|
+
disabled: false,
|
|
15
|
+
compound: false
|
|
14
16
|
}
|
|
15
17
|
};
|
|
16
18
|
export default meta;
|
|
17
19
|
export const Default = {};
|
|
18
20
|
export const Creatable = { args: { creatable: true } };
|
|
19
21
|
export const Disabled = { args: { disabled: true } };
|
|
22
|
+
export const WithComboboxItem = { args: { compound: true } };
|
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import ComboboxItem from '../ComboboxItem/ComboboxItem.svelte';
|
|
4
|
+
import {
|
|
5
|
+
COMBOBOX_CREATE_KEY,
|
|
6
|
+
setComboboxContext,
|
|
7
|
+
type ComboboxContext
|
|
8
|
+
} from './combobox-context.js';
|
|
9
|
+
|
|
2
10
|
export interface ComboboxOption {
|
|
3
11
|
value: string;
|
|
4
12
|
label: string;
|
|
5
13
|
disabled?: boolean;
|
|
6
14
|
}
|
|
7
15
|
|
|
16
|
+
export interface ComboboxItemState {
|
|
17
|
+
selected: boolean;
|
|
18
|
+
highlighted: boolean;
|
|
19
|
+
index: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
interface ComboboxProps {
|
|
9
23
|
options?: ComboboxOption[];
|
|
10
24
|
value?: string;
|
|
@@ -19,6 +33,15 @@
|
|
|
19
33
|
emptyText?: string;
|
|
20
34
|
createText?: (query: string) => string;
|
|
21
35
|
class?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Custom row for each `options` entry. If omitted, rows use `ComboboxItem`.
|
|
38
|
+
*/
|
|
39
|
+
item?: Snippet<[ComboboxOption, ComboboxItemState]>;
|
|
40
|
+
/**
|
|
41
|
+
* Extra (or fully custom) `ComboboxItem` rows rendered in the listbox.
|
|
42
|
+
* Nested items register for keyboard nav and filter against `query`.
|
|
43
|
+
*/
|
|
44
|
+
children?: Snippet;
|
|
22
45
|
onchange?: (value: string) => void;
|
|
23
46
|
oncreate?: (value: string) => void;
|
|
24
47
|
}
|
|
@@ -35,6 +58,8 @@
|
|
|
35
58
|
emptyText = 'No results',
|
|
36
59
|
createText = (q) => `Create “${q}”`,
|
|
37
60
|
class: className = '',
|
|
61
|
+
item: itemSnippet,
|
|
62
|
+
children,
|
|
38
63
|
onchange,
|
|
39
64
|
oncreate
|
|
40
65
|
}: ComboboxProps = $props();
|
|
@@ -42,6 +67,10 @@
|
|
|
42
67
|
let rootEl = $state<HTMLDivElement | null>(null);
|
|
43
68
|
let inputEl = $state<HTMLInputElement | null>(null);
|
|
44
69
|
let highlighted = $state(0);
|
|
70
|
+
let childEntries = $state<{ id: number; value: string; disabled: boolean; label: string }[]>([]);
|
|
71
|
+
let nextChildId = 0;
|
|
72
|
+
const uid = $props.id();
|
|
73
|
+
const listboxId = `${uid}-listbox`;
|
|
45
74
|
|
|
46
75
|
const selected = $derived(options.find((o) => o.value === value));
|
|
47
76
|
|
|
@@ -61,12 +90,33 @@
|
|
|
61
90
|
});
|
|
62
91
|
|
|
63
92
|
const items = $derived.by(() => {
|
|
64
|
-
const list: Array<
|
|
65
|
-
|
|
93
|
+
const list: Array<
|
|
94
|
+
{ kind: 'option'; option: ComboboxOption } | { kind: 'create'; label: string }
|
|
95
|
+
> = filtered.map((option) => ({ kind: 'option' as const, option }));
|
|
66
96
|
if (canCreate) list.push({ kind: 'create', label: createText(query.trim()) });
|
|
67
97
|
return list;
|
|
68
98
|
});
|
|
69
99
|
|
|
100
|
+
function itemKey(
|
|
101
|
+
item: { kind: 'option'; option: ComboboxOption } | { kind: 'create'; label: string }
|
|
102
|
+
): string {
|
|
103
|
+
return item.kind === 'create' ? COMBOBOX_CREATE_KEY : item.option.value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const navKeys = $derived.by(() => {
|
|
107
|
+
const keys = items.map(itemKey);
|
|
108
|
+
for (const entry of childEntries) {
|
|
109
|
+
if (!entry.disabled && !keys.includes(entry.value)) keys.push(entry.value);
|
|
110
|
+
}
|
|
111
|
+
return keys;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const activeIndex = $derived(
|
|
115
|
+
navKeys.length === 0 ? 0 : Math.min(highlighted, navKeys.length - 1)
|
|
116
|
+
);
|
|
117
|
+
const highlightedKey = $derived(navKeys[activeIndex] ?? null);
|
|
118
|
+
const hasRows = $derived(items.length > 0 || childEntries.length > 0 || Boolean(children));
|
|
119
|
+
|
|
70
120
|
function setOpen(next: boolean) {
|
|
71
121
|
if (disabled) return;
|
|
72
122
|
open = next;
|
|
@@ -93,6 +143,28 @@
|
|
|
93
143
|
setOpen(false);
|
|
94
144
|
}
|
|
95
145
|
|
|
146
|
+
function selectByKey(key: string) {
|
|
147
|
+
if (key === COMBOBOX_CREATE_KEY) {
|
|
148
|
+
createOption();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const option = options.find((o) => o.value === key);
|
|
152
|
+
if (option) {
|
|
153
|
+
selectOption(option);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const child = childEntries.find((entry) => entry.value === key);
|
|
157
|
+
value = key;
|
|
158
|
+
query = child?.label || key;
|
|
159
|
+
onchange?.(key);
|
|
160
|
+
setOpen(false);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function highlightByKey(key: string) {
|
|
164
|
+
const index = navKeys.indexOf(key);
|
|
165
|
+
if (index >= 0) highlighted = index;
|
|
166
|
+
}
|
|
167
|
+
|
|
96
168
|
function onInput() {
|
|
97
169
|
setOpen(true);
|
|
98
170
|
highlighted = 0;
|
|
@@ -113,21 +185,20 @@
|
|
|
113
185
|
if (e.key === 'ArrowDown') {
|
|
114
186
|
e.preventDefault();
|
|
115
187
|
if (!open) setOpen(true);
|
|
116
|
-
else if (
|
|
188
|
+
else if (navKeys.length) highlighted = (activeIndex + 1) % navKeys.length;
|
|
117
189
|
return;
|
|
118
190
|
}
|
|
119
191
|
if (e.key === 'ArrowUp') {
|
|
120
192
|
e.preventDefault();
|
|
121
193
|
if (!open) setOpen(true);
|
|
122
|
-
else if (
|
|
194
|
+
else if (navKeys.length) highlighted = (activeIndex - 1 + navKeys.length) % navKeys.length;
|
|
123
195
|
return;
|
|
124
196
|
}
|
|
125
197
|
if (e.key === 'Enter') {
|
|
126
198
|
e.preventDefault();
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
129
|
-
|
|
130
|
-
else selectOption(item.option);
|
|
199
|
+
const key = navKeys[activeIndex];
|
|
200
|
+
if (!key) return;
|
|
201
|
+
selectByKey(key);
|
|
131
202
|
return;
|
|
132
203
|
}
|
|
133
204
|
if (e.key === 'Escape') {
|
|
@@ -150,24 +221,44 @@
|
|
|
150
221
|
query = selected.label;
|
|
151
222
|
}
|
|
152
223
|
});
|
|
224
|
+
|
|
225
|
+
const ctx: ComboboxContext = {
|
|
226
|
+
getValue: () => value,
|
|
227
|
+
getQuery: () => query,
|
|
228
|
+
getHighlighted: () => highlightedKey,
|
|
229
|
+
select: selectByKey,
|
|
230
|
+
highlight: highlightByKey,
|
|
231
|
+
register: (childValue, childDisabled, childLabel = '') => {
|
|
232
|
+
const id = ++nextChildId;
|
|
233
|
+
childEntries = [
|
|
234
|
+
...childEntries,
|
|
235
|
+
{ id, value: childValue, disabled: childDisabled, label: childLabel }
|
|
236
|
+
];
|
|
237
|
+
return () => {
|
|
238
|
+
childEntries = childEntries.filter((entry) => entry.id !== id);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
setComboboxContext(ctx);
|
|
153
244
|
</script>
|
|
154
245
|
|
|
155
246
|
<svelte:document onpointerdown={onDocPointerDown} />
|
|
156
247
|
|
|
157
|
-
<div class={['relative w-full
|
|
248
|
+
<div class={['max-w-sm relative w-full', className]} bind:this={rootEl}>
|
|
158
249
|
{#if label}
|
|
159
|
-
<span class="mb-1.5
|
|
250
|
+
<span class="mb-1.5 text-sm font-medium text-primary block">{label}</span>
|
|
160
251
|
{/if}
|
|
161
252
|
|
|
162
253
|
<div
|
|
163
254
|
class={[
|
|
164
|
-
'
|
|
165
|
-
open && 'border-brand-500 ring-
|
|
255
|
+
'h-10 gap-2 rounded-xl border-border bg-surface-elevated px-3 flex items-center border transition-colors',
|
|
256
|
+
open && 'border-brand-500 ring-brand-500/20 ring-2',
|
|
166
257
|
disabled && 'cursor-not-allowed opacity-60'
|
|
167
258
|
]}
|
|
168
259
|
>
|
|
169
260
|
<svg
|
|
170
|
-
class="h-4 w-4 shrink-0
|
|
261
|
+
class="h-4 w-4 text-muted shrink-0"
|
|
171
262
|
viewBox="0 0 24 24"
|
|
172
263
|
fill="none"
|
|
173
264
|
stroke="currentColor"
|
|
@@ -187,12 +278,13 @@
|
|
|
187
278
|
{disabled}
|
|
188
279
|
role="combobox"
|
|
189
280
|
aria-expanded={open}
|
|
281
|
+
aria-controls={listboxId}
|
|
190
282
|
aria-autocomplete="list"
|
|
191
283
|
autocomplete="off"
|
|
192
284
|
oninput={onInput}
|
|
193
285
|
onfocus={onFocus}
|
|
194
286
|
onkeydown={onKeydown}
|
|
195
|
-
class="min-w-0
|
|
287
|
+
class="min-w-0 text-sm text-primary placeholder:text-muted flex-1 bg-transparent outline-none"
|
|
196
288
|
/>
|
|
197
289
|
{#if query && !disabled}
|
|
198
290
|
<button
|
|
@@ -207,7 +299,13 @@
|
|
|
207
299
|
}}
|
|
208
300
|
class="text-muted hover:text-primary"
|
|
209
301
|
>
|
|
210
|
-
<svg
|
|
302
|
+
<svg
|
|
303
|
+
class="h-3.5 w-3.5"
|
|
304
|
+
viewBox="0 0 24 24"
|
|
305
|
+
fill="none"
|
|
306
|
+
stroke="currentColor"
|
|
307
|
+
stroke-width="2"
|
|
308
|
+
>
|
|
211
309
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
212
310
|
</svg>
|
|
213
311
|
</button>
|
|
@@ -216,67 +314,60 @@
|
|
|
216
314
|
|
|
217
315
|
{#if open}
|
|
218
316
|
<div
|
|
317
|
+
id={listboxId}
|
|
219
318
|
role="listbox"
|
|
220
|
-
class="
|
|
319
|
+
class="left-0 right-0 mt-2 max-h-60 rounded-xl border-border bg-surface-elevated p-1.5 shadow-xl absolute z-50 overflow-y-auto border"
|
|
221
320
|
>
|
|
222
|
-
{#if
|
|
223
|
-
<div class="px-3 py-2.5 text-
|
|
321
|
+
{#if !hasRows}
|
|
322
|
+
<div class="px-3 py-2.5 text-xs text-muted text-center">{emptyText}</div>
|
|
224
323
|
{:else}
|
|
225
|
-
{#each items as item, index (item
|
|
324
|
+
{#each items as item, index (itemKey(item))}
|
|
226
325
|
{#if item.kind === 'create'}
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
326
|
+
<ComboboxItem
|
|
327
|
+
value={COMBOBOX_CREATE_KEY}
|
|
328
|
+
label={item.label}
|
|
329
|
+
highlighted={activeIndex === index}
|
|
330
|
+
register={false}
|
|
232
331
|
onclick={createOption}
|
|
233
|
-
class={[
|
|
234
|
-
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm',
|
|
235
|
-
highlighted === index ? 'bg-brand-500 text-white' : 'text-primary hover:bg-surface-overlay'
|
|
236
|
-
]}
|
|
237
332
|
>
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
333
|
+
{#snippet leading()}
|
|
334
|
+
<svg
|
|
335
|
+
class="h-4 w-4 shrink-0"
|
|
336
|
+
viewBox="0 0 24 24"
|
|
337
|
+
fill="none"
|
|
338
|
+
stroke="currentColor"
|
|
339
|
+
stroke-width="2"
|
|
340
|
+
aria-hidden="true"
|
|
341
|
+
>
|
|
342
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
|
343
|
+
</svg>
|
|
344
|
+
{/snippet}
|
|
248
345
|
{item.label}
|
|
249
|
-
</
|
|
346
|
+
</ComboboxItem>
|
|
250
347
|
{:else}
|
|
251
348
|
{@const option = item.option}
|
|
252
349
|
{@const isSelected = value === option.value}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
option.disabled
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
|
272
|
-
{#if isSelected}
|
|
273
|
-
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
274
|
-
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
|
275
|
-
</svg>
|
|
276
|
-
{/if}
|
|
277
|
-
</button>
|
|
350
|
+
{@const isHighlighted = activeIndex === index}
|
|
351
|
+
{#if itemSnippet}
|
|
352
|
+
{@render itemSnippet(option, {
|
|
353
|
+
selected: isSelected,
|
|
354
|
+
highlighted: isHighlighted,
|
|
355
|
+
index
|
|
356
|
+
})}
|
|
357
|
+
{:else}
|
|
358
|
+
<ComboboxItem
|
|
359
|
+
value={option.value}
|
|
360
|
+
label={option.label}
|
|
361
|
+
disabled={option.disabled}
|
|
362
|
+
selected={isSelected}
|
|
363
|
+
highlighted={isHighlighted}
|
|
364
|
+
register={false}
|
|
365
|
+
onclick={() => selectOption(option)}
|
|
366
|
+
/>
|
|
367
|
+
{/if}
|
|
278
368
|
{/if}
|
|
279
369
|
{/each}
|
|
370
|
+
{@render children?.()}
|
|
280
371
|
{/if}
|
|
281
372
|
</div>
|
|
282
373
|
{/if}
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
export interface ComboboxOption {
|
|
2
3
|
value: string;
|
|
3
4
|
label: string;
|
|
4
5
|
disabled?: boolean;
|
|
5
6
|
}
|
|
7
|
+
export interface ComboboxItemState {
|
|
8
|
+
selected: boolean;
|
|
9
|
+
highlighted: boolean;
|
|
10
|
+
index: number;
|
|
11
|
+
}
|
|
6
12
|
interface ComboboxProps {
|
|
7
13
|
options?: ComboboxOption[];
|
|
8
14
|
value?: string;
|
|
@@ -17,6 +23,15 @@ interface ComboboxProps {
|
|
|
17
23
|
emptyText?: string;
|
|
18
24
|
createText?: (query: string) => string;
|
|
19
25
|
class?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Custom row for each `options` entry. If omitted, rows use `ComboboxItem`.
|
|
28
|
+
*/
|
|
29
|
+
item?: Snippet<[ComboboxOption, ComboboxItemState]>;
|
|
30
|
+
/**
|
|
31
|
+
* Extra (or fully custom) `ComboboxItem` rows rendered in the listbox.
|
|
32
|
+
* Nested items register for keyboard nav and filter against `query`.
|
|
33
|
+
*/
|
|
34
|
+
children?: Snippet;
|
|
20
35
|
onchange?: (value: string) => void;
|
|
21
36
|
oncreate?: (value: string) => void;
|
|
22
37
|
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import Combobox, { type ComboboxOption } from './Combobox.svelte';
|
|
3
|
+
import ComboboxItem from '../ComboboxItem/ComboboxItem.svelte';
|
|
3
4
|
|
|
4
5
|
let {
|
|
5
6
|
creatable = false,
|
|
6
7
|
label = 'Framework',
|
|
7
|
-
disabled = false
|
|
8
|
+
disabled = false,
|
|
9
|
+
compound = false
|
|
8
10
|
}: {
|
|
9
11
|
creatable?: boolean;
|
|
10
12
|
label?: string;
|
|
11
13
|
disabled?: boolean;
|
|
14
|
+
compound?: boolean;
|
|
12
15
|
} = $props();
|
|
13
16
|
|
|
14
17
|
const options: ComboboxOption[] = [
|
|
@@ -25,17 +28,27 @@
|
|
|
25
28
|
let open = $state(false);
|
|
26
29
|
</script>
|
|
27
30
|
|
|
28
|
-
<div class="
|
|
29
|
-
|
|
30
|
-
{
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
<div class="w-80 gap-3 flex flex-col">
|
|
32
|
+
{#if compound}
|
|
33
|
+
<Combobox {label} {disabled} bind:value bind:query bind:open placeholder="Search frameworks…">
|
|
34
|
+
{#each options as option (option.value)}
|
|
35
|
+
<ComboboxItem value={option.value} label={option.label} disabled={option.disabled}>
|
|
36
|
+
{option.label}
|
|
37
|
+
</ComboboxItem>
|
|
38
|
+
{/each}
|
|
39
|
+
</Combobox>
|
|
40
|
+
{:else}
|
|
41
|
+
<Combobox
|
|
42
|
+
{options}
|
|
43
|
+
{label}
|
|
44
|
+
{creatable}
|
|
45
|
+
{disabled}
|
|
46
|
+
bind:value
|
|
47
|
+
bind:query
|
|
48
|
+
bind:open
|
|
49
|
+
placeholder="Search frameworks…"
|
|
50
|
+
/>
|
|
51
|
+
{/if}
|
|
39
52
|
<p class="text-xs text-muted">
|
|
40
53
|
Value: <span class="text-primary">{value || '—'}</span>
|
|
41
54
|
</p>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const COMBOBOX_CONTEXT: unique symbol;
|
|
2
|
+
export declare const COMBOBOX_CREATE_KEY = "__create__";
|
|
3
|
+
export interface ComboboxContext {
|
|
4
|
+
getValue: () => string;
|
|
5
|
+
getQuery: () => string;
|
|
6
|
+
getHighlighted: () => string | null;
|
|
7
|
+
select: (value: string) => void;
|
|
8
|
+
highlight: (value: string) => void;
|
|
9
|
+
register: (value: string, disabled: boolean, label?: string) => () => void;
|
|
10
|
+
}
|
|
11
|
+
export declare function setComboboxContext(ctx: ComboboxContext): ComboboxContext;
|
|
12
|
+
export declare function getComboboxContext(): ComboboxContext | null;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
export const COMBOBOX_CONTEXT = Symbol('r2-combobox');
|
|
3
|
+
export const COMBOBOX_CREATE_KEY = '__create__';
|
|
4
|
+
export function setComboboxContext(ctx) {
|
|
5
|
+
setContext(COMBOBOX_CONTEXT, ctx);
|
|
6
|
+
return ctx;
|
|
7
|
+
}
|
|
8
|
+
export function getComboboxContext() {
|
|
9
|
+
return getContext(COMBOBOX_CONTEXT) ?? null;
|
|
10
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { StoryObj } from '@storybook/svelte';
|
|
2
|
+
declare const meta: {
|
|
3
|
+
title: string;
|
|
4
|
+
component: import("svelte").Component<Record<string, never>, {}, "">;
|
|
5
|
+
tags: string[];
|
|
6
|
+
};
|
|
7
|
+
export default meta;
|
|
8
|
+
type Story = StoryObj<typeof meta>;
|
|
9
|
+
export declare const Default: Story;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Check from '@lucide/svelte/icons/check';
|
|
4
|
+
import { getComboboxContext } from '../Combobox/combobox-context.js';
|
|
5
|
+
|
|
6
|
+
interface ComboboxItemProps {
|
|
7
|
+
value: string;
|
|
8
|
+
/** Used for default text and query filtering when nested in `Combobox`. */
|
|
9
|
+
label?: string;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
/** Extra strings matched against the parent `Combobox` query. */
|
|
12
|
+
keywords?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* When omitted, selection comes from the parent `Combobox`.
|
|
15
|
+
* Pass explicitly when using the item standalone.
|
|
16
|
+
*/
|
|
17
|
+
selected?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* When omitted, highlight comes from the parent `Combobox`.
|
|
20
|
+
* Pass explicitly when using the item standalone.
|
|
21
|
+
*/
|
|
22
|
+
highlighted?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Register this row for keyboard navigation / query filtering.
|
|
25
|
+
* Internal `Combobox` rows set this to `false` (parent already filters).
|
|
26
|
+
*/
|
|
27
|
+
register?: boolean;
|
|
28
|
+
class?: string;
|
|
29
|
+
children?: Snippet;
|
|
30
|
+
leading?: Snippet;
|
|
31
|
+
trailing?: Snippet;
|
|
32
|
+
onclick?: () => void;
|
|
33
|
+
onhighlight?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let {
|
|
37
|
+
value,
|
|
38
|
+
label = '',
|
|
39
|
+
disabled = false,
|
|
40
|
+
keywords = [],
|
|
41
|
+
selected,
|
|
42
|
+
highlighted,
|
|
43
|
+
register = true,
|
|
44
|
+
class: className = '',
|
|
45
|
+
children,
|
|
46
|
+
leading,
|
|
47
|
+
trailing,
|
|
48
|
+
onclick,
|
|
49
|
+
onhighlight
|
|
50
|
+
}: ComboboxItemProps = $props();
|
|
51
|
+
|
|
52
|
+
const ctx = getComboboxContext();
|
|
53
|
+
|
|
54
|
+
const matchesQuery = $derived.by(() => {
|
|
55
|
+
if (!register || !ctx) return true;
|
|
56
|
+
const q = ctx.getQuery().trim().toLowerCase();
|
|
57
|
+
if (!q) return true;
|
|
58
|
+
const blob = [label, value, ...keywords].join(' ').toLowerCase();
|
|
59
|
+
return blob.includes(q);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const isSelected = $derived(selected ?? (ctx ? ctx.getValue() === value : false));
|
|
63
|
+
const isHighlighted = $derived(highlighted ?? (ctx ? ctx.getHighlighted() === value : false));
|
|
64
|
+
const active = $derived(!disabled && (isHighlighted || isSelected));
|
|
65
|
+
|
|
66
|
+
$effect(() => {
|
|
67
|
+
if (!register || !ctx || disabled || !matchesQuery) return;
|
|
68
|
+
return ctx.register(value, disabled, label);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
function activate() {
|
|
72
|
+
if (disabled) return;
|
|
73
|
+
if (onclick) {
|
|
74
|
+
onclick();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
ctx?.select(value);
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
{#if matchesQuery}
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
role="option"
|
|
85
|
+
data-value={value}
|
|
86
|
+
{disabled}
|
|
87
|
+
aria-selected={isSelected}
|
|
88
|
+
aria-disabled={disabled}
|
|
89
|
+
onpointerenter={() => {
|
|
90
|
+
if (disabled) return;
|
|
91
|
+
onhighlight?.();
|
|
92
|
+
ctx?.highlight(value);
|
|
93
|
+
}}
|
|
94
|
+
onclick={activate}
|
|
95
|
+
class={[
|
|
96
|
+
'gap-2 rounded-lg px-2.5 py-2 text-sm flex w-full items-center text-left transition-colors',
|
|
97
|
+
'focus-visible:ring-brand-500/30 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset',
|
|
98
|
+
disabled && 'cursor-not-allowed opacity-40',
|
|
99
|
+
!disabled && (active ? 'bg-brand-500 text-white' : 'text-primary hover:bg-surface-overlay'),
|
|
100
|
+
className
|
|
101
|
+
]}
|
|
102
|
+
>
|
|
103
|
+
{#if leading}
|
|
104
|
+
<span class="flex shrink-0 items-center">{@render leading()}</span>
|
|
105
|
+
{/if}
|
|
106
|
+
|
|
107
|
+
<span class={['min-w-0 flex-1', !children && 'truncate']}>
|
|
108
|
+
{#if children}
|
|
109
|
+
{@render children()}
|
|
110
|
+
{:else}
|
|
111
|
+
{label || value}
|
|
112
|
+
{/if}
|
|
113
|
+
</span>
|
|
114
|
+
|
|
115
|
+
{#if trailing}
|
|
116
|
+
<span class="flex shrink-0 items-center">{@render trailing()}</span>
|
|
117
|
+
{:else if isSelected}
|
|
118
|
+
<Check class="h-4 w-4 shrink-0" strokeWidth={2.5} />
|
|
119
|
+
{/if}
|
|
120
|
+
</button>
|
|
121
|
+
{/if}
|