@classic-homes/theme-svelte 1.37.0 → 1.39.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.
@@ -51,6 +51,12 @@
51
51
  trigger?: Snippet<[TriggerSnippetProps]>;
52
52
  /** Optional custom content */
53
53
  children?: Snippet;
54
+ /**
55
+ * Called before focus returns to the trigger (or the previously focused
56
+ * element) on close; `preventDefault()` keeps focus where you put it, e.g.
57
+ * when the confirmed action removed that element.
58
+ */
59
+ onCloseAutoFocus?: (event: Event) => void;
54
60
  }
55
61
 
56
62
  let {
@@ -66,6 +72,7 @@
66
72
  class: className,
67
73
  trigger,
68
74
  children,
75
+ onCloseAutoFocus,
69
76
  }: Props = $props();
70
77
 
71
78
  const uid = $props.id();
@@ -121,6 +128,7 @@
121
128
  class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
122
129
  />
123
130
  <AlertDialogPrimitive.Content
131
+ {onCloseAutoFocus}
124
132
  class={cn(
125
133
  'fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100vw-2rem)] sm:max-w-lg max-h-[calc(100dvh-2rem)] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border border-black bg-background p-4 sm:p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
126
134
  className
@@ -80,6 +80,12 @@ declare const AlertDialog: import("svelte").Component<{
80
80
  trigger?: Snippet<[TriggerSnippetProps]>;
81
81
  /** Optional custom content */
82
82
  children?: Snippet;
83
+ /**
84
+ * Called before focus returns to the trigger (or the previously focused
85
+ * element) on close; `preventDefault()` keeps focus where you put it, e.g.
86
+ * when the confirmed action removed that element.
87
+ */
88
+ onCloseAutoFocus?: (event: Event) => void;
83
89
  }, {}, "open">;
84
90
  type AlertDialog = ReturnType<typeof AlertDialog>;
85
91
  export default AlertDialog;
@@ -0,0 +1,68 @@
1
+ <!--
2
+ @component
3
+ A region that opens and closes from a trigger button (Bits UI Collapsible).
4
+ Pass `label` for a plain trigger with a chevron, or a `trigger` snippet for
5
+ custom trigger content (it receives `{ open }`; the button wraps it).
6
+ -->
7
+ <script lang="ts">
8
+ import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
9
+ import type { Snippet } from 'svelte';
10
+ import { cn } from '../utils.js';
11
+ import Icon from './Icon.svelte';
12
+
13
+ interface Props {
14
+ /** Whether the content is shown (bindable) */
15
+ open?: boolean;
16
+ onOpenChange?: (open: boolean) => void;
17
+ disabled?: boolean;
18
+ /** Trigger text; ignored when `trigger` is given */
19
+ label?: string;
20
+ /** Custom trigger content, rendered inside the trigger button */
21
+ trigger?: Snippet<[{ open: boolean }]>;
22
+ /** Show the chevron before the trigger content (default true) */
23
+ chevron?: boolean;
24
+ class?: string;
25
+ triggerClass?: string;
26
+ contentClass?: string;
27
+ children: Snippet;
28
+ }
29
+
30
+ let {
31
+ open = $bindable(false),
32
+ onOpenChange,
33
+ disabled = false,
34
+ label,
35
+ trigger,
36
+ chevron = true,
37
+ class: className,
38
+ triggerClass,
39
+ contentClass,
40
+ children,
41
+ }: Props = $props();
42
+ </script>
43
+
44
+ <CollapsiblePrimitive.Root bind:open {onOpenChange} {disabled} class={className}>
45
+ <CollapsiblePrimitive.Trigger
46
+ class={cn(
47
+ 'group inline-flex items-center gap-1.5 rounded-md text-sm font-medium text-foreground hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
48
+ triggerClass
49
+ )}
50
+ >
51
+ {#if chevron}
52
+ <Icon
53
+ name="chevron-right"
54
+ size={14}
55
+ class="shrink-0 transition-transform duration-200 group-data-[state=open]:rotate-90 motion-reduce:transition-none"
56
+ />
57
+ {/if}
58
+ {#if trigger}{@render trigger({ open })}{:else}{label}{/if}
59
+ </CollapsiblePrimitive.Trigger>
60
+ <CollapsiblePrimitive.Content
61
+ class={cn(
62
+ 'overflow-hidden data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up motion-reduce:animate-none',
63
+ contentClass
64
+ )}
65
+ >
66
+ {@render children()}
67
+ </CollapsiblePrimitive.Content>
68
+ </CollapsiblePrimitive.Root>
@@ -0,0 +1,27 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** Whether the content is shown (bindable) */
4
+ open?: boolean;
5
+ onOpenChange?: (open: boolean) => void;
6
+ disabled?: boolean;
7
+ /** Trigger text; ignored when `trigger` is given */
8
+ label?: string;
9
+ /** Custom trigger content, rendered inside the trigger button */
10
+ trigger?: Snippet<[{
11
+ open: boolean;
12
+ }]>;
13
+ /** Show the chevron before the trigger content (default true) */
14
+ chevron?: boolean;
15
+ class?: string;
16
+ triggerClass?: string;
17
+ contentClass?: string;
18
+ children: Snippet;
19
+ }
20
+ /**
21
+ * A region that opens and closes from a trigger button (Bits UI Collapsible).
22
+ * Pass `label` for a plain trigger with a chevron, or a `trigger` snippet for
23
+ * custom trigger content (it receives `{ open }`; the button wraps it).
24
+ */
25
+ declare const Collapsible: import("svelte").Component<Props, {}, "open">;
26
+ type Collapsible = ReturnType<typeof Collapsible>;
27
+ export default Collapsible;
@@ -25,6 +25,12 @@
25
25
  placeholder?: string;
26
26
  /** Message to display when no results found */
27
27
  emptyMessage?: string;
28
+ /**
29
+ * Render at most this many matching options, with a note to type to
30
+ * narrow the rest. Filtering still searches every option. Keeps long
31
+ * lists (thousands of options) quick to open.
32
+ */
33
+ maxResults?: number;
28
34
  /** Whether the combobox is disabled */
29
35
  disabled?: boolean;
30
36
  /** Whether selection is required */
@@ -69,6 +75,7 @@
69
75
  loading = false,
70
76
  onSearch,
71
77
  debounceMs = 300,
78
+ maxResults,
72
79
  class: className,
73
80
  'aria-label': ariaLabelProp,
74
81
  'aria-labelledby': ariaLabelledby,
@@ -129,6 +136,28 @@
129
136
  filteredOptions.reduce((n: number, item) => n + (isGroup(item) ? item.options.length : 1), 0)
130
137
  );
131
138
 
139
+ // The first `maxResults` matches, keeping their groups.
140
+ const shownOptions = $derived.by((): (ComboboxOption | SelectGroup)[] => {
141
+ if (maxResults === undefined || filteredCount <= maxResults) return filteredOptions;
142
+ let left = maxResults;
143
+ const out: (ComboboxOption | SelectGroup)[] = [];
144
+ for (const item of filteredOptions) {
145
+ if (left <= 0) break;
146
+ if (isGroup(item)) {
147
+ const opts = item.options.slice(0, left);
148
+ out.push({ ...item, options: opts });
149
+ left -= opts.length;
150
+ } else {
151
+ out.push(item);
152
+ left--;
153
+ }
154
+ }
155
+ return out;
156
+ });
157
+ const hiddenCount = $derived(
158
+ maxResults === undefined ? 0 : Math.max(0, filteredCount - maxResults)
159
+ );
160
+
132
161
  // Closed, the input shows the selected label; open, it holds what the user
133
162
  // types, with the selected label as the placeholder so the full list shows.
134
163
  const inputValue = $derived(open ? searchQuery : (selectedLabel ?? ''));
@@ -174,7 +203,8 @@
174
203
  if (!open) return '';
175
204
  if (filteredCount === 0) return emptyMessage;
176
205
  const count = filteredCount;
177
- return `${count} ${count === 1 ? 'option' : 'options'} available`;
206
+ const shown = hiddenCount ? `, showing the first ${count - hiddenCount}` : '';
207
+ return `${count} ${count === 1 ? 'option' : 'options'} available${shown}`;
178
208
  });
179
209
  </script>
180
210
 
@@ -276,7 +306,7 @@
276
306
  {emptyMessage}
277
307
  </div>
278
308
  {:else}
279
- {#each filteredOptions as item}
309
+ {#each shownOptions as item}
280
310
  {#if isGroup(item)}
281
311
  <ComboboxPrimitive.Group>
282
312
  <ComboboxPrimitive.GroupHeading class="py-1.5 pl-8 pr-2 text-sm font-semibold">
@@ -290,6 +320,11 @@
290
320
  {@render comboboxItem(item)}
291
321
  {/if}
292
322
  {/each}
323
+ {#if hiddenCount}
324
+ <div class="px-2 py-1.5 text-xs text-muted-foreground" data-combobox-more>
325
+ {hiddenCount} more. Type to narrow the list.
326
+ </div>
327
+ {/if}
293
328
  {/if}
294
329
  </div>
295
330
  </ComboboxPrimitive.Content>
@@ -15,6 +15,12 @@ interface Props {
15
15
  placeholder?: string;
16
16
  /** Message to display when no results found */
17
17
  emptyMessage?: string;
18
+ /**
19
+ * Render at most this many matching options, with a note to type to
20
+ * narrow the rest. Filtering still searches every option. Keeps long
21
+ * lists (thousands of options) quick to open.
22
+ */
23
+ maxResults?: number;
18
24
  /** Whether the combobox is disabled */
19
25
  disabled?: boolean;
20
26
  /** Whether selection is required */
@@ -0,0 +1,135 @@
1
+ <script lang="ts">
2
+ import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
3
+ import type { Snippet } from 'svelte';
4
+ import { cn } from '../utils.js';
5
+ import Icon from './Icon.svelte';
6
+ import type { DropdownMenuGroup, DropdownMenuItem } from './DropdownMenu.svelte';
7
+
8
+ interface Props {
9
+ /** Menu items, the same shape as DropdownMenu's */
10
+ items: (DropdownMenuItem | DropdownMenuGroup)[];
11
+ /** The area that opens the menu on right-click (or long-press) */
12
+ children: Snippet;
13
+ /** Whether the menu is open (bindable) */
14
+ open?: boolean;
15
+ /** Called when the menu opens or closes */
16
+ onOpenChange?: (open: boolean) => void;
17
+ /** Leave the browser's own menu in place */
18
+ disabled?: boolean;
19
+ /** Classes for the trigger area */
20
+ triggerClass?: string;
21
+ /** Classes for the menu */
22
+ class?: string;
23
+ /**
24
+ * Called as the menu closes, before focus returns to where it was;
25
+ * `preventDefault()` keeps focus where a chosen item put it.
26
+ */
27
+ onCloseAutoFocus?: (event: Event) => void;
28
+ }
29
+
30
+ let {
31
+ items,
32
+ children,
33
+ open = $bindable(false),
34
+ onOpenChange,
35
+ disabled = false,
36
+ triggerClass,
37
+ class: className,
38
+ onCloseAutoFocus,
39
+ }: Props = $props();
40
+
41
+ function isGroup(item: DropdownMenuItem | DropdownMenuGroup): item is DropdownMenuGroup {
42
+ return 'items' in item && Array.isArray(item.items);
43
+ }
44
+
45
+ function handleOpenChange(next: boolean) {
46
+ open = next;
47
+ onOpenChange?.(next);
48
+ }
49
+
50
+ const itemClass =
51
+ 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none bg-popover data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[disabled]:cursor-default';
52
+ </script>
53
+
54
+ {#snippet itemContent(item: DropdownMenuItem)}
55
+ {#if item.icon}
56
+ <Icon name={item.icon} size={16} class="mr-2 shrink-0" aria-hidden="true" />
57
+ {/if}
58
+ {item.label}
59
+ {#if item.shortcut}
60
+ <span class="ml-auto pl-4 text-xs tracking-widest opacity-60" aria-hidden="true">
61
+ {item.shortcut}
62
+ </span>
63
+ <span class="sr-only">, keyboard shortcut {item.shortcut}</span>
64
+ {/if}
65
+ {/snippet}
66
+
67
+ {#snippet entry(item: DropdownMenuItem)}
68
+ {#if item.type === 'separator'}
69
+ <ContextMenuPrimitive.Separator class="-mx-1 my-1 h-px bg-muted" />
70
+ {:else if item.type === 'label'}
71
+ <div role="presentation" class="px-2 py-1.5 text-sm font-semibold">{item.label}</div>
72
+ {:else if item.checked !== undefined}
73
+ <ContextMenuPrimitive.CheckboxItem
74
+ checked={item.checked}
75
+ disabled={item.disabled}
76
+ closeOnSelect={false}
77
+ onSelect={() => item.onSelect?.()}
78
+ class={cn(itemClass, 'pl-8')}
79
+ >
80
+ {#if item.checked}
81
+ <Icon name="check" size={16} class="absolute left-2 shrink-0" aria-hidden="true" />
82
+ {/if}
83
+ {@render itemContent(item)}
84
+ </ContextMenuPrimitive.CheckboxItem>
85
+ {:else}
86
+ <ContextMenuPrimitive.Item
87
+ disabled={item.disabled}
88
+ onSelect={() => item.onSelect?.()}
89
+ class={cn(
90
+ itemClass,
91
+ item.destructive && 'text-destructive data-[highlighted]:text-destructive'
92
+ )}
93
+ aria-keyshortcuts={item.shortcut || undefined}
94
+ >
95
+ {@render itemContent(item)}
96
+ </ContextMenuPrimitive.Item>
97
+ {/if}
98
+ {/snippet}
99
+
100
+ <ContextMenuPrimitive.Root bind:open onOpenChange={handleOpenChange}>
101
+ <ContextMenuPrimitive.Trigger {disabled} class={triggerClass}>
102
+ {@render children()}
103
+ </ContextMenuPrimitive.Trigger>
104
+
105
+ <!-- Portal so the menu is not clipped by an overflow/transformed ancestor. -->
106
+ <ContextMenuPrimitive.Portal>
107
+ <ContextMenuPrimitive.Content
108
+ collisionPadding={8}
109
+ {onCloseAutoFocus}
110
+ class={cn(
111
+ 'isolate z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
112
+ className
113
+ )}
114
+ >
115
+ <div class="max-h-80 overflow-y-auto overflow-x-hidden">
116
+ {#each items as item, i (i)}
117
+ {#if isGroup(item)}
118
+ <ContextMenuPrimitive.Group>
119
+ {#if item.label}
120
+ <ContextMenuPrimitive.GroupHeading class="px-2 py-1.5 text-sm font-semibold">
121
+ {item.label}
122
+ </ContextMenuPrimitive.GroupHeading>
123
+ {/if}
124
+ {#each item.items as sub, j (j)}
125
+ {@render entry(sub)}
126
+ {/each}
127
+ </ContextMenuPrimitive.Group>
128
+ {:else}
129
+ {@render entry(item)}
130
+ {/if}
131
+ {/each}
132
+ </div>
133
+ </ContextMenuPrimitive.Content>
134
+ </ContextMenuPrimitive.Portal>
135
+ </ContextMenuPrimitive.Root>
@@ -0,0 +1,26 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { DropdownMenuGroup, DropdownMenuItem } from './DropdownMenu.svelte';
3
+ interface Props {
4
+ /** Menu items, the same shape as DropdownMenu's */
5
+ items: (DropdownMenuItem | DropdownMenuGroup)[];
6
+ /** The area that opens the menu on right-click (or long-press) */
7
+ children: Snippet;
8
+ /** Whether the menu is open (bindable) */
9
+ open?: boolean;
10
+ /** Called when the menu opens or closes */
11
+ onOpenChange?: (open: boolean) => void;
12
+ /** Leave the browser's own menu in place */
13
+ disabled?: boolean;
14
+ /** Classes for the trigger area */
15
+ triggerClass?: string;
16
+ /** Classes for the menu */
17
+ class?: string;
18
+ /**
19
+ * Called as the menu closes, before focus returns to where it was;
20
+ * `preventDefault()` keeps focus where a chosen item put it.
21
+ */
22
+ onCloseAutoFocus?: (event: Event) => void;
23
+ }
24
+ declare const ContextMenu: import("svelte").Component<Props, {}, "open">;
25
+ type ContextMenu = ReturnType<typeof ContextMenu>;
26
+ export default ContextMenu;
@@ -0,0 +1,101 @@
1
+ <!--
2
+ @component
3
+ Copies text to the clipboard and confirms it: the icon turns into a check
4
+ and screen readers hear "Copied" for `timeout` ms. Icon-only by default
5
+ (with a tooltip); pass `children` for a labelled button.
6
+ -->
7
+ <script lang="ts">
8
+ import type { Snippet } from 'svelte';
9
+ import Button from './Button.svelte';
10
+ import Icon from './Icon.svelte';
11
+ import Tooltip from './Tooltip.svelte';
12
+ import type { ButtonVariants } from './button-variants.js';
13
+
14
+ interface Props {
15
+ /** Text to copy, or a function returning it (read when clicked) */
16
+ value: string | (() => string | Promise<string>);
17
+ /** Accessible name and tooltip before copying (default "Copy") */
18
+ label?: string;
19
+ /** Accessible name, tooltip and announcement after copying (default "Copied") */
20
+ copiedLabel?: string;
21
+ /** How long the copied state lasts, in ms (default 2000) */
22
+ timeout?: number;
23
+ variant?: ButtonVariants['variant'];
24
+ size?: ButtonVariants['size'];
25
+ /** Show a tooltip on the icon-only button (default true) */
26
+ tooltip?: boolean;
27
+ disabled?: boolean;
28
+ class?: string;
29
+ /** Called after the text was written to the clipboard */
30
+ oncopy?: (text: string) => void;
31
+ /** Called when the clipboard is unavailable or the write was refused */
32
+ onerror?: (error: unknown) => void;
33
+ /** Visible label; omit for an icon-only button */
34
+ children?: Snippet;
35
+ }
36
+
37
+ let {
38
+ value,
39
+ label = 'Copy',
40
+ copiedLabel = 'Copied',
41
+ timeout = 2000,
42
+ variant = 'ghost',
43
+ size,
44
+ tooltip = true,
45
+ disabled = false,
46
+ class: className,
47
+ oncopy,
48
+ onerror,
49
+ children,
50
+ }: Props = $props();
51
+
52
+ let copied = $state(false);
53
+ let timer: ReturnType<typeof setTimeout> | undefined;
54
+ $effect(() => () => clearTimeout(timer));
55
+
56
+ async function copy() {
57
+ try {
58
+ const text = typeof value === 'function' ? await value() : value;
59
+ if (!navigator.clipboard?.writeText) throw new Error('Clipboard is not available');
60
+ await navigator.clipboard.writeText(text);
61
+ copied = true;
62
+ clearTimeout(timer);
63
+ timer = setTimeout(() => (copied = false), timeout);
64
+ oncopy?.(text);
65
+ } catch (error) {
66
+ onerror?.(error);
67
+ }
68
+ }
69
+
70
+ const name = $derived(copied ? copiedLabel : label);
71
+ const iconSize = $derived(size === 'sm' ? 14 : 16);
72
+ </script>
73
+
74
+ {#snippet button(props: Record<string, unknown> = {})}
75
+ <Button
76
+ {...props}
77
+ type="button"
78
+ {variant}
79
+ size={size ?? (children ? 'sm' : 'icon')}
80
+ {disabled}
81
+ class={className}
82
+ aria-label={children ? undefined : name}
83
+ data-copied={copied || undefined}
84
+ onclick={(e: MouseEvent) => {
85
+ (props.onclick as ((e: MouseEvent) => void) | undefined)?.(e);
86
+ void copy();
87
+ }}
88
+ >
89
+ <Icon name={copied ? 'check' : 'copy'} size={iconSize} />
90
+ {#if children}{@render children()}{/if}
91
+ </Button>
92
+ {/snippet}
93
+
94
+ {#if tooltip && !children}
95
+ <Tooltip content={name}>
96
+ {#snippet children({ props })}{@render button(props)}{/snippet}
97
+ </Tooltip>
98
+ {:else}
99
+ {@render button()}
100
+ {/if}
101
+ <span class="sr-only" role="status">{copied ? copiedLabel : ''}</span>
@@ -0,0 +1,32 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { ButtonVariants } from './button-variants.js';
3
+ interface Props {
4
+ /** Text to copy, or a function returning it (read when clicked) */
5
+ value: string | (() => string | Promise<string>);
6
+ /** Accessible name and tooltip before copying (default "Copy") */
7
+ label?: string;
8
+ /** Accessible name, tooltip and announcement after copying (default "Copied") */
9
+ copiedLabel?: string;
10
+ /** How long the copied state lasts, in ms (default 2000) */
11
+ timeout?: number;
12
+ variant?: ButtonVariants['variant'];
13
+ size?: ButtonVariants['size'];
14
+ /** Show a tooltip on the icon-only button (default true) */
15
+ tooltip?: boolean;
16
+ disabled?: boolean;
17
+ class?: string;
18
+ /** Called after the text was written to the clipboard */
19
+ oncopy?: (text: string) => void;
20
+ /** Called when the clipboard is unavailable or the write was refused */
21
+ onerror?: (error: unknown) => void;
22
+ /** Visible label; omit for an icon-only button */
23
+ children?: Snippet;
24
+ }
25
+ /**
26
+ * Copies text to the clipboard and confirms it: the icon turns into a check
27
+ * and screen readers hear "Copied" for `timeout` ms. Icon-only by default
28
+ * (with a tooltip); pass `children` for a labelled button.
29
+ */
30
+ declare const CopyButton: import("svelte").Component<Props, {}, "">;
31
+ type CopyButton = ReturnType<typeof CopyButton>;
32
+ export default CopyButton;
@@ -0,0 +1,72 @@
1
+ <!--
2
+ @component
3
+ A centred placeholder for a view with nothing to show yet: an icon, a
4
+ title, an optional description and actions (e.g. a "Create" button).
5
+ -->
6
+ <script lang="ts">
7
+ import type { Snippet } from 'svelte';
8
+ import { cn } from '../utils.js';
9
+ import Icon, { type IconName } from './Icon.svelte';
10
+
11
+ interface Props {
12
+ title: string;
13
+ description?: string;
14
+ /** Icon shown above the title in a muted circle */
15
+ icon?: IconName;
16
+ /** `sm` for panels and lists, `md` for pages (default) */
17
+ size?: 'sm' | 'md';
18
+ /** Heading element for the title (default h3) */
19
+ headingLevel?: 'h2' | 'h3' | 'h4' | 'p';
20
+ class?: string;
21
+ /** Buttons or links under the description */
22
+ actions?: Snippet;
23
+ /** Extra content between the description and the actions */
24
+ children?: Snippet;
25
+ }
26
+
27
+ let {
28
+ title,
29
+ description,
30
+ icon,
31
+ size = 'md',
32
+ headingLevel = 'h3',
33
+ class: className,
34
+ actions,
35
+ children,
36
+ }: Props = $props();
37
+ </script>
38
+
39
+ <div
40
+ class={cn(
41
+ 'mx-auto flex w-full flex-col items-center text-center',
42
+ size === 'sm' ? 'max-w-xs gap-2 px-4 py-6' : 'max-w-md gap-3 px-6 py-12',
43
+ className
44
+ )}
45
+ >
46
+ {#if icon}
47
+ <div
48
+ class={cn(
49
+ 'flex items-center justify-center rounded-full bg-muted text-muted-foreground',
50
+ size === 'sm' ? 'mb-1 h-10 w-10' : 'mb-2 h-12 w-12'
51
+ )}
52
+ aria-hidden="true"
53
+ >
54
+ <Icon name={icon} size={size === 'sm' ? 20 : 24} />
55
+ </div>
56
+ {/if}
57
+ <svelte:element
58
+ this={headingLevel}
59
+ class={cn('font-semibold text-foreground', size === 'sm' ? 'text-sm' : 'text-base')}
60
+ >
61
+ {title}
62
+ </svelte:element>
63
+ {#if description}
64
+ <p class={cn('text-muted-foreground', size === 'sm' ? 'text-xs' : 'text-sm')}>
65
+ {description}
66
+ </p>
67
+ {/if}
68
+ {#if children}{@render children()}{/if}
69
+ {#if actions}
70
+ <div class="mt-2 flex flex-wrap items-center justify-center gap-2">{@render actions()}</div>
71
+ {/if}
72
+ </div>
@@ -0,0 +1,24 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type IconName } from './Icon.svelte';
3
+ interface Props {
4
+ title: string;
5
+ description?: string;
6
+ /** Icon shown above the title in a muted circle */
7
+ icon?: IconName;
8
+ /** `sm` for panels and lists, `md` for pages (default) */
9
+ size?: 'sm' | 'md';
10
+ /** Heading element for the title (default h3) */
11
+ headingLevel?: 'h2' | 'h3' | 'h4' | 'p';
12
+ class?: string;
13
+ /** Buttons or links under the description */
14
+ actions?: Snippet;
15
+ /** Extra content between the description and the actions */
16
+ children?: Snippet;
17
+ }
18
+ /**
19
+ * A centred placeholder for a view with nothing to show yet: an icon, a
20
+ * title, an optional description and actions (e.g. a "Create" button).
21
+ */
22
+ declare const EmptyState: import("svelte").Component<Props, {}, "">;
23
+ type EmptyState = ReturnType<typeof EmptyState>;
24
+ export default EmptyState;
@@ -24,6 +24,8 @@
24
24
  /** Whether the input has an error */
25
25
  'aria-invalid'?: boolean | 'true' | 'false';
26
26
  onValueChange?: (value: string) => void;
27
+ /** Native input listener; runs after the value is updated */
28
+ oninput?: (e: Event & { currentTarget: HTMLInputElement }) => void;
27
29
  [key: string]: unknown;
28
30
  }
29
31
 
@@ -44,6 +46,7 @@
44
46
  'aria-describedby': ariaDescribedby,
45
47
  'aria-invalid': ariaInvalid,
46
48
  onValueChange,
49
+ oninput,
47
50
  ...restProps
48
51
  }: Props = $props();
49
52
 
@@ -62,6 +65,7 @@
62
65
  const target = e.target as HTMLInputElement;
63
66
  value = target.value;
64
67
  onValueChange?.(target.value);
68
+ oninput?.(e as Event & { currentTarget: HTMLInputElement });
65
69
  }
66
70
  </script>
67
71
 
@@ -20,6 +20,10 @@ interface Props {
20
20
  /** Whether the input has an error */
21
21
  'aria-invalid'?: boolean | 'true' | 'false';
22
22
  onValueChange?: (value: string) => void;
23
+ /** Native input listener; runs after the value is updated */
24
+ oninput?: (e: Event & {
25
+ currentTarget: HTMLInputElement;
26
+ }) => void;
23
27
  [key: string]: unknown;
24
28
  }
25
29
  declare const Input: import("svelte").Component<Props, {}, "value">;