@r2digisolutions/components 0.5.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.
Files changed (36) hide show
  1. package/dist/components/atoms/Button/Button.stories.d.ts +6 -0
  2. package/dist/components/atoms/Button/Button.stories.js +7 -0
  3. package/dist/components/atoms/Button/Button.svelte +15 -17
  4. package/dist/components/atoms/Button/Button.svelte.d.ts +1 -0
  5. package/dist/components/atoms/Button/ButtonStory.svelte +2 -0
  6. package/dist/components/atoms/Button/ButtonStory.svelte.d.ts +1 -0
  7. package/dist/components/molecules/Combobox/Combobox.stories.d.ts +6 -0
  8. package/dist/components/molecules/Combobox/Combobox.stories.js +5 -2
  9. package/dist/components/molecules/Combobox/Combobox.svelte +155 -64
  10. package/dist/components/molecules/Combobox/Combobox.svelte.d.ts +15 -0
  11. package/dist/components/molecules/Combobox/ComboboxStory.svelte +25 -12
  12. package/dist/components/molecules/Combobox/ComboboxStory.svelte.d.ts +1 -0
  13. package/dist/components/molecules/Combobox/combobox-context.d.ts +12 -0
  14. package/dist/components/molecules/Combobox/combobox-context.js +10 -0
  15. package/dist/components/molecules/ComboboxItem/ComboboxItem.stories.d.ts +9 -0
  16. package/dist/components/molecules/ComboboxItem/ComboboxItem.stories.js +8 -0
  17. package/dist/components/molecules/ComboboxItem/ComboboxItem.svelte +121 -0
  18. package/dist/components/molecules/ComboboxItem/ComboboxItem.svelte.d.ts +33 -0
  19. package/dist/components/molecules/ComboboxItem/ComboboxItemStory.svelte +54 -0
  20. package/dist/components/molecules/ComboboxItem/ComboboxItemStory.svelte.d.ts +3 -0
  21. package/dist/components/molecules/Form/Form.svelte +36 -10
  22. package/dist/components/molecules/FormField/FormField.svelte +56 -19
  23. package/dist/components/molecules/FormField/FormField.svelte.d.ts +21 -6
  24. package/dist/components/molecules/FormPasswordInput/FormPasswordInput.svelte +38 -9
  25. package/dist/components/molecules/FormPasswordInput/FormPasswordInput.svelte.d.ts +8 -1
  26. package/dist/components/organisms/BlogEditor/BlogEditorStory.svelte +1 -1
  27. package/dist/components/organisms/FileUploader/FileUploader.svelte +7 -3
  28. package/dist/components/organisms/InvoicePage/InvoicePage.svelte +1 -1
  29. package/dist/components/organisms/ProfileHeaderUploader/ProfileHeaderUploader.svelte +30 -10
  30. package/dist/components/organisms/ProfileHeaderUploader/ProfileHeaderUploader.svelte.d.ts +4 -0
  31. package/dist/index.d.ts +6 -3
  32. package/dist/index.js +3 -1
  33. package/dist/styles.css +6 -0
  34. package/dist/utils/formContext.d.ts +58 -0
  35. package/dist/utils/formContext.js +79 -1
  36. package/package.json +16 -16
@@ -7,6 +7,7 @@ declare const meta: {
7
7
  loading?: boolean;
8
8
  disabled?: boolean;
9
9
  fullWidth?: boolean;
10
+ href?: string;
10
11
  label?: string;
11
12
  }, {}, "">;
12
13
  tags: string[];
@@ -33,6 +34,10 @@ declare const meta: {
33
34
  control: "boolean";
34
35
  description: string;
35
36
  };
37
+ href: {
38
+ control: "text";
39
+ description: string;
40
+ };
36
41
  label: {
37
42
  control: "text";
38
43
  description: string;
@@ -59,3 +64,4 @@ export declare const Disabled: Story;
59
64
  export declare const ExtraSmall: Story;
60
65
  export declare const Large: Story;
61
66
  export declare const FullWidth: Story;
67
+ export declare const AsLink: Story;
@@ -26,6 +26,10 @@ const meta = {
26
26
  control: 'boolean',
27
27
  description: 'Makes the button take full container width'
28
28
  },
29
+ href: {
30
+ control: 'text',
31
+ description: 'If set, renders as an anchor instead of a button'
32
+ },
29
33
  label: {
30
34
  control: 'text',
31
35
  description: 'Button text content'
@@ -71,3 +75,6 @@ export const Large = {
71
75
  export const FullWidth = {
72
76
  args: { fullWidth: true, label: 'Full Width' }
73
77
  };
78
+ export const AsLink = {
79
+ args: { href: '#', label: 'Go to page' }
80
+ };
@@ -10,6 +10,7 @@
10
10
  loading?: boolean;
11
11
  disabled?: boolean;
12
12
  fullWidth?: boolean;
13
+ href?: string;
13
14
  type?: 'button' | 'submit' | 'reset';
14
15
  class?: string;
15
16
  children?: Snippet;
@@ -23,6 +24,7 @@
23
24
  loading = false,
24
25
  disabled = false,
25
26
  fullWidth = false,
27
+ href,
26
28
  type = 'button',
27
29
  class: className = '',
28
30
  children,
@@ -31,6 +33,7 @@
31
33
  }: ButtonProps = $props();
32
34
 
33
35
  const isDisabled = $derived(disabled || loading);
36
+ const tag = $derived(href ? 'a' : 'button');
34
37
 
35
38
  const baseClasses =
36
39
  'relative inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 select-none cursor-pointer disabled:cursor-not-allowed disabled:opacity-50 overflow-hidden';
@@ -65,24 +68,28 @@
65
68
  };
66
69
  </script>
67
70
 
68
- <button
69
- {type}
71
+ <svelte:element
72
+ this={tag}
73
+ href={isDisabled ? undefined : href}
74
+ type={href ? undefined : type}
75
+ disabled={href ? undefined : isDisabled}
76
+ aria-disabled={href && isDisabled ? true : undefined}
77
+ aria-busy={loading}
70
78
  class={[
71
79
  baseClasses,
72
80
  variantClasses[variant],
73
81
  sizeClasses[size],
74
82
  (size === 'md' || size === 'lg' || size === 'xl') && 'touch-target',
75
83
  fullWidth && 'w-full',
84
+ href && isDisabled && 'pointer-events-none cursor-not-allowed opacity-50',
76
85
  className
77
86
  ]}
78
- disabled={isDisabled}
79
- aria-busy={loading}
80
87
  {onclick}
81
88
  {...rest}
82
89
  >
83
90
  {#if loading}
84
91
  <!-- Spinner overlay -->
85
- <span class="absolute inset-0 flex items-center justify-center">
92
+ <span class="inset-0 absolute flex items-center justify-center">
86
93
  <svg
87
94
  class={['animate-spin', iconSizeClasses[size]]}
88
95
  xmlns="http://www.w3.org/2000/svg"
@@ -90,18 +97,9 @@
90
97
  viewBox="0 0 24 24"
91
98
  aria-hidden="true"
92
99
  >
93
- <circle
94
- class="opacity-25"
95
- cx="12"
96
- cy="12"
97
- r="10"
98
- stroke="currentColor"
99
- stroke-width="4"
100
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
100
101
  ></circle>
101
- <path
102
- class="opacity-75"
103
- fill="currentColor"
104
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
102
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
105
103
  ></path>
106
104
  </svg>
107
105
  </span>
@@ -112,4 +110,4 @@
112
110
  {:else}
113
111
  {@render children?.()}
114
112
  {/if}
115
- </button>
113
+ </svelte:element>
@@ -7,6 +7,7 @@ interface ButtonProps {
7
7
  loading?: boolean;
8
8
  disabled?: boolean;
9
9
  fullWidth?: boolean;
10
+ href?: string;
10
11
  type?: 'button' | 'submit' | 'reset';
11
12
  class?: string;
12
13
  children?: Snippet;
@@ -7,6 +7,7 @@
7
7
  loading?: boolean;
8
8
  disabled?: boolean;
9
9
  fullWidth?: boolean;
10
+ href?: string;
10
11
  label?: string;
11
12
  }>();
12
13
  </script>
@@ -18,6 +19,7 @@
18
19
  loading={props.loading ?? false}
19
20
  disabled={props.disabled ?? false}
20
21
  fullWidth={props.fullWidth ?? false}
22
+ href={props.href}
21
23
  >
22
24
  {props.label ?? 'Button'}
23
25
  </Button>
@@ -4,6 +4,7 @@ type $$ComponentProps = {
4
4
  loading?: boolean;
5
5
  disabled?: boolean;
6
6
  fullWidth?: boolean;
7
+ href?: string;
7
8
  label?: string;
8
9
  };
9
10
  declare const ButtonStory: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -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<{ kind: 'option'; option: ComboboxOption } | { kind: 'create'; label: string }> =
65
- filtered.map((option) => ({ kind: 'option' as const, option }));
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 (items.length) highlighted = (highlighted + 1) % items.length;
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 (items.length) highlighted = (highlighted - 1 + items.length) % items.length;
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 item = items[highlighted];
128
- if (!item) return;
129
- if (item.kind === 'create') createOption();
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 max-w-sm', className]} bind:this={rootEl}>
248
+ <div class={['max-w-sm relative w-full', className]} bind:this={rootEl}>
158
249
  {#if label}
159
- <span class="mb-1.5 block text-sm font-medium text-primary">{label}</span>
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
- 'flex h-10 items-center gap-2 rounded-xl border border-border bg-surface-elevated px-3 transition-colors',
165
- open && 'border-brand-500 ring-2 ring-brand-500/20',
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 text-muted"
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 flex-1 bg-transparent text-sm text-primary outline-none placeholder:text-muted"
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 class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
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="absolute left-0 right-0 z-50 mt-2 max-h-60 overflow-y-auto rounded-xl border border-border bg-surface-elevated p-1.5 shadow-xl"
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 items.length === 0}
223
- <div class="px-3 py-2.5 text-center text-xs text-muted">{emptyText}</div>
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.kind === 'option' ? item.option.value : 'create')}
324
+ {#each items as item, index (itemKey(item))}
226
325
  {#if item.kind === 'create'}
227
- <button
228
- type="button"
229
- role="option"
230
- aria-selected={highlighted === index}
231
- onpointerenter={() => (highlighted = index)}
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
- <svg
239
- class="h-4 w-4 shrink-0"
240
- viewBox="0 0 24 24"
241
- fill="none"
242
- stroke="currentColor"
243
- stroke-width="2"
244
- aria-hidden="true"
245
- >
246
- <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
247
- </svg>
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
- </button>
346
+ </ComboboxItem>
250
347
  {:else}
251
348
  {@const option = item.option}
252
349
  {@const isSelected = value === option.value}
253
- <button
254
- type="button"
255
- role="option"
256
- disabled={option.disabled}
257
- aria-selected={isSelected}
258
- onpointerenter={() => {
259
- if (!option.disabled) highlighted = index;
260
- }}
261
- onclick={() => selectOption(option)}
262
- class={[
263
- 'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm',
264
- option.disabled && 'cursor-not-allowed opacity-40',
265
- !option.disabled &&
266
- (highlighted === index || isSelected
267
- ? 'bg-brand-500 text-white'
268
- : 'text-primary hover:bg-surface-overlay')
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="flex w-80 flex-col gap-3">
29
- <Combobox
30
- {options}
31
- {label}
32
- {creatable}
33
- {disabled}
34
- bind:value
35
- bind:query
36
- bind:open
37
- placeholder="Search frameworks…"
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>
@@ -2,6 +2,7 @@ type $$ComponentProps = {
2
2
  creatable?: boolean;
3
3
  label?: string;
4
4
  disabled?: boolean;
5
+ compound?: boolean;
5
6
  };
6
7
  declare const ComboboxStory: import("svelte").Component<$$ComponentProps, {}, "">;
7
8
  type ComboboxStory = ReturnType<typeof ComboboxStory>;
@@ -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;