@classic-homes/theme-svelte 1.38.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.
@@ -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;
@@ -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">;
@@ -0,0 +1,80 @@
1
+ <!--
2
+ @component
3
+ A keyboard key or shortcut. Pass `keys` for a combination ("Mod" shows as
4
+ ⌘ on Apple devices and Ctrl elsewhere), or `children` for a single key.
5
+ -->
6
+ <script lang="ts" module>
7
+ const SYMBOLS: Record<string, { apple: string; other: string; name: string }> = {
8
+ mod: { apple: '⌘', other: 'Ctrl', name: 'Command' },
9
+ ctrl: { apple: '⌃', other: 'Ctrl', name: 'Control' },
10
+ alt: { apple: '⌥', other: 'Alt', name: 'Option' },
11
+ shift: { apple: '⇧', other: 'Shift', name: 'Shift' },
12
+ enter: { apple: '↵', other: 'Enter', name: 'Enter' },
13
+ escape: { apple: 'Esc', other: 'Esc', name: 'Escape' },
14
+ esc: { apple: 'Esc', other: 'Esc', name: 'Escape' },
15
+ };
16
+
17
+ /** Whether shortcuts should use Apple modifier symbols (⌘, ⌥, ⇧). */
18
+ export function isApplePlatform(): boolean {
19
+ if (typeof navigator === 'undefined') return false;
20
+ const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
21
+ const platform = nav.userAgentData?.platform ?? navigator.platform ?? '';
22
+ return /mac|iphone|ipad|ipod/i.test(platform);
23
+ }
24
+
25
+ /** The text shown for one key, and the name read out for it. */
26
+ export function formatKey(
27
+ key: string,
28
+ apple = isApplePlatform()
29
+ ): { label: string; name: string } {
30
+ const known = SYMBOLS[key.toLowerCase()];
31
+ if (!known) return { label: key.length === 1 ? key.toUpperCase() : key, name: key };
32
+ const name = key.toLowerCase() === 'mod' && !apple ? 'Control' : known.name;
33
+ return { label: apple ? known.apple : known.other, name };
34
+ }
35
+ </script>
36
+
37
+ <script lang="ts">
38
+ import type { Snippet } from 'svelte';
39
+ import { cn } from '../utils.js';
40
+
41
+ interface Props {
42
+ /** Keys of a combination, e.g. ['Mod', 'K'] */
43
+ keys?: string[];
44
+ size?: 'sm' | 'md';
45
+ class?: string;
46
+ children?: Snippet;
47
+ }
48
+
49
+ let { keys, size = 'sm', class: className, children }: Props = $props();
50
+
51
+ // Resolved after mount so server and client render the same markup
52
+ let apple = $state(false);
53
+ $effect(() => {
54
+ apple = isApplePlatform();
55
+ });
56
+
57
+ const keyClass = $derived(
58
+ cn(
59
+ 'inline-flex items-center justify-center rounded border border-border bg-muted font-sans font-medium text-muted-foreground shadow-[inset_0_-1px_0_hsl(var(--border))]',
60
+ size === 'sm' ? 'h-5 min-w-5 px-1 text-[11px]' : 'h-6 min-w-6 px-1.5 text-xs'
61
+ )
62
+ );
63
+ const formatted = $derived((keys ?? []).map((k) => formatKey(k, apple)));
64
+ </script>
65
+
66
+ {#if keys?.length}
67
+ <span
68
+ class={cn('inline-flex items-center gap-0.5', className)}
69
+ aria-label={formatted.map((k) => k.name).join(' + ')}
70
+ role="img"
71
+ >
72
+ {#each formatted as key, i (i)}
73
+ <kbd class={keyClass} aria-hidden="true">{key.label}</kbd>
74
+ {/each}
75
+ </span>
76
+ {:else}
77
+ <kbd class={cn(keyClass, className)}
78
+ >{#if children}{@render children()}{/if}</kbd
79
+ >
80
+ {/if}
@@ -0,0 +1,22 @@
1
+ /** Whether shortcuts should use Apple modifier symbols (⌘, ⌥, ⇧). */
2
+ export declare function isApplePlatform(): boolean;
3
+ /** The text shown for one key, and the name read out for it. */
4
+ export declare function formatKey(key: string, apple?: boolean): {
5
+ label: string;
6
+ name: string;
7
+ };
8
+ import type { Snippet } from 'svelte';
9
+ interface Props {
10
+ /** Keys of a combination, e.g. ['Mod', 'K'] */
11
+ keys?: string[];
12
+ size?: 'sm' | 'md';
13
+ class?: string;
14
+ children?: Snippet;
15
+ }
16
+ /**
17
+ * A keyboard key or shortcut. Pass `keys` for a combination ("Mod" shows as
18
+ * ⌘ on Apple devices and Ctrl elsewhere), or `children` for a single key.
19
+ */
20
+ declare const Kbd: import("svelte").Component<Props, {}, "">;
21
+ type Kbd = ReturnType<typeof Kbd>;
22
+ export default Kbd;
@@ -0,0 +1,90 @@
1
+ <!--
2
+ @component
3
+ Floating content anchored to a trigger (Bits UI Popover). The `trigger`
4
+ snippet receives `{ props }`: spread them on your own focusable element
5
+ (`<Button {...props}>`). Without a trigger, anchor it with `anchor` (an
6
+ element or selector) and control it through `open`, e.g. a suggestion list
7
+ under a text field that must keep focus (`autoFocus={false}`).
8
+ -->
9
+ <script lang="ts">
10
+ import { Popover as PopoverPrimitive } from 'bits-ui';
11
+ import type { Snippet } from 'svelte';
12
+ import { cn } from '../utils.js';
13
+ import TriggerChild, { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
14
+
15
+ interface Props {
16
+ /** Whether the popover is shown (bindable) */
17
+ open?: boolean;
18
+ onOpenChange?: (open: boolean) => void;
19
+ side?: 'top' | 'right' | 'bottom' | 'left';
20
+ align?: 'start' | 'center' | 'end';
21
+ sideOffset?: number;
22
+ /** Element (or selector) to position against instead of the trigger */
23
+ anchor?: HTMLElement | string | null;
24
+ /**
25
+ * Move focus into the content when it opens (default true). Set false to
26
+ * keep focus where it is, e.g. in a text field driving the popover.
27
+ */
28
+ autoFocus?: boolean;
29
+ /** Keep Tab inside the content while open (default false) */
30
+ trapFocus?: boolean;
31
+ /** Accessible name for the content when it has no visible heading */
32
+ 'aria-label'?: string;
33
+ class?: string;
34
+ trigger?: Snippet<[TriggerSnippetProps]>;
35
+ children: Snippet;
36
+ }
37
+
38
+ let {
39
+ open = $bindable(false),
40
+ onOpenChange,
41
+ side = 'bottom',
42
+ align = 'center',
43
+ sideOffset = 6,
44
+ anchor = null,
45
+ autoFocus = true,
46
+ trapFocus = false,
47
+ 'aria-label': ariaLabel,
48
+ class: className,
49
+ trigger,
50
+ children,
51
+ }: Props = $props();
52
+
53
+ const uid = $props.id();
54
+ let triggerId = $state(`${uid}-trigger`);
55
+ </script>
56
+
57
+ <PopoverPrimitive.Root bind:open {onOpenChange}>
58
+ {#if trigger}
59
+ <PopoverPrimitive.Trigger id={triggerId}>
60
+ {#snippet child({ props })}
61
+ <TriggerChild
62
+ {props}
63
+ {trigger}
64
+ fallbackClass="inline-flex"
65
+ onfallback={() => (triggerId = `${uid}-trigger-wrap`)}
66
+ />
67
+ {/snippet}
68
+ </PopoverPrimitive.Trigger>
69
+ {/if}
70
+ <PopoverPrimitive.Portal>
71
+ <PopoverPrimitive.Content
72
+ {side}
73
+ {align}
74
+ {sideOffset}
75
+ {trapFocus}
76
+ customAnchor={anchor}
77
+ collisionPadding={8}
78
+ aria-label={ariaLabel}
79
+ onOpenAutoFocus={(e) => {
80
+ if (!autoFocus) e.preventDefault();
81
+ }}
82
+ class={cn(
83
+ 'z-50 w-72 rounded-lg border border-border bg-popover p-3 text-sm text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 motion-reduce:animate-none',
84
+ className
85
+ )}
86
+ >
87
+ {@render children()}
88
+ </PopoverPrimitive.Content>
89
+ </PopoverPrimitive.Portal>
90
+ </PopoverPrimitive.Root>
@@ -0,0 +1,34 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
3
+ interface Props {
4
+ /** Whether the popover is shown (bindable) */
5
+ open?: boolean;
6
+ onOpenChange?: (open: boolean) => void;
7
+ side?: 'top' | 'right' | 'bottom' | 'left';
8
+ align?: 'start' | 'center' | 'end';
9
+ sideOffset?: number;
10
+ /** Element (or selector) to position against instead of the trigger */
11
+ anchor?: HTMLElement | string | null;
12
+ /**
13
+ * Move focus into the content when it opens (default true). Set false to
14
+ * keep focus where it is, e.g. in a text field driving the popover.
15
+ */
16
+ autoFocus?: boolean;
17
+ /** Keep Tab inside the content while open (default false) */
18
+ trapFocus?: boolean;
19
+ /** Accessible name for the content when it has no visible heading */
20
+ 'aria-label'?: string;
21
+ class?: string;
22
+ trigger?: Snippet<[TriggerSnippetProps]>;
23
+ children: Snippet;
24
+ }
25
+ /**
26
+ * Floating content anchored to a trigger (Bits UI Popover). The `trigger`
27
+ * snippet receives `{ props }`: spread them on your own focusable element
28
+ * (`<Button {...props}>`). Without a trigger, anchor it with `anchor` (an
29
+ * element or selector) and control it through `open`, e.g. a suggestion list
30
+ * under a text field that must keep focus (`autoFocus={false}`).
31
+ */
32
+ declare const Popover: import("svelte").Component<Props, {}, "open">;
33
+ type Popover = ReturnType<typeof Popover>;
34
+ export default Popover;
@@ -0,0 +1,185 @@
1
+ <!--
2
+ @component
3
+ Two panes split by a draggable handle. `size` is the start pane's share in
4
+ percent; the end pane takes the rest. The handle is a focusable
5
+ `separator`: arrow keys move it by `step`, Home/End jump to `min`/`max`.
6
+ With `persistKey` the size is remembered in localStorage.
7
+ -->
8
+ <script lang="ts" module>
9
+ const STORAGE_PREFIX = 'classic-theme:resizable:';
10
+
11
+ export function clampSize(value: number, min: number, max: number): number {
12
+ return Math.min(Math.max(value, min), max);
13
+ }
14
+ </script>
15
+
16
+ <script lang="ts">
17
+ import type { Snippet } from 'svelte';
18
+ import { cn } from '../utils.js';
19
+
20
+ interface Props {
21
+ /** Start pane share in percent (bindable) */
22
+ size?: number;
23
+ /** Smallest start share in percent (default 20) */
24
+ min?: number;
25
+ /** Largest start share in percent (default 80) */
26
+ max?: number;
27
+ /** Keyboard step in percent (default 5) */
28
+ step?: number;
29
+ /** `horizontal`: side by side (default); `vertical`: stacked */
30
+ orientation?: 'horizontal' | 'vertical';
31
+ /** Remember the size under this key */
32
+ persistKey?: string;
33
+ /** Accessible name for the handle (default "Resize panes") */
34
+ handleLabel?: string;
35
+ onResize?: (size: number) => void;
36
+ class?: string;
37
+ startClass?: string;
38
+ endClass?: string;
39
+ start: Snippet;
40
+ end: Snippet;
41
+ }
42
+
43
+ let {
44
+ size = $bindable(50),
45
+ min = 20,
46
+ max = 80,
47
+ step = 5,
48
+ orientation = 'horizontal',
49
+ persistKey,
50
+ handleLabel = 'Resize panes',
51
+ onResize,
52
+ class: className,
53
+ startClass,
54
+ endClass,
55
+ start,
56
+ end,
57
+ }: Props = $props();
58
+
59
+ const uid = $props.id();
60
+ let container = $state<HTMLDivElement | null>(null);
61
+ let dragging = $state(false);
62
+ const horizontal = $derived(orientation === 'horizontal');
63
+
64
+ // Read the remembered size once, after mount (no storage during SSR)
65
+ $effect(() => {
66
+ if (!persistKey) return;
67
+ try {
68
+ const stored = Number(localStorage.getItem(STORAGE_PREFIX + persistKey));
69
+ if (Number.isFinite(stored) && stored > 0) size = clampSize(stored, min, max);
70
+ } catch {
71
+ // Storage blocked: keep the default size
72
+ }
73
+ });
74
+
75
+ function set(next: number, save = true) {
76
+ const value = Math.round(clampSize(next, min, max) * 10) / 10;
77
+ if (value === size) return;
78
+ size = value;
79
+ onResize?.(value);
80
+ if (save) persist();
81
+ }
82
+
83
+ function persist() {
84
+ if (!persistKey) return;
85
+ try {
86
+ localStorage.setItem(STORAGE_PREFIX + persistKey, String(size));
87
+ } catch {
88
+ // Storage blocked: the size just isn't remembered
89
+ }
90
+ }
91
+
92
+ function onkeydown(e: KeyboardEvent) {
93
+ const back = horizontal ? 'ArrowLeft' : 'ArrowUp';
94
+ const forward = horizontal ? 'ArrowRight' : 'ArrowDown';
95
+ if (e.key === back) set(size - step);
96
+ else if (e.key === forward) set(size + step);
97
+ else if (e.key === 'Home') set(min);
98
+ else if (e.key === 'End') set(max);
99
+ else return;
100
+ e.preventDefault();
101
+ }
102
+
103
+ function fromPointer(e: PointerEvent) {
104
+ if (!container) return;
105
+ const rect = container.getBoundingClientRect();
106
+ const total = horizontal ? rect.width : rect.height;
107
+ if (total <= 0) return;
108
+ const offset = horizontal ? e.clientX - rect.left : e.clientY - rect.top;
109
+ set((offset / total) * 100, false);
110
+ }
111
+
112
+ function onpointerdown(e: PointerEvent) {
113
+ if (e.button !== 0) return;
114
+ e.preventDefault();
115
+ dragging = true;
116
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
117
+ }
118
+
119
+ function onpointermove(e: PointerEvent) {
120
+ if (dragging) fromPointer(e);
121
+ }
122
+
123
+ function onpointerup(e: PointerEvent) {
124
+ if (!dragging) return;
125
+ dragging = false;
126
+ (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
127
+ persist();
128
+ }
129
+ </script>
130
+
131
+ <div
132
+ bind:this={container}
133
+ class={cn('flex h-full min-h-0 w-full min-w-0', horizontal ? 'flex-row' : 'flex-col', className)}
134
+ data-dragging={dragging || undefined}
135
+ >
136
+ <div
137
+ id={`${uid}-start`}
138
+ class={cn('min-h-0 min-w-0 shrink-0 overflow-hidden', startClass)}
139
+ style={`flex-basis:${size}%`}
140
+ >
141
+ {@render start()}
142
+ </div>
143
+ <!-- A focusable separator is an interactive widget (WAI-ARIA window splitter) -->
144
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
145
+ <div
146
+ role="separator"
147
+ tabindex="0"
148
+ aria-label={handleLabel}
149
+ aria-orientation={horizontal ? 'vertical' : 'horizontal'}
150
+ aria-controls={`${uid}-start`}
151
+ aria-valuenow={size}
152
+ aria-valuemin={min}
153
+ aria-valuemax={max}
154
+ class={cn(
155
+ 'group relative z-10 flex shrink-0 touch-none select-none items-center justify-center bg-border outline-none transition-colors hover:bg-primary/40 focus-visible:bg-primary data-[dragging]:bg-primary',
156
+ horizontal ? 'w-px cursor-col-resize' : 'h-px cursor-row-resize'
157
+ )}
158
+ data-dragging={dragging || undefined}
159
+ {onkeydown}
160
+ {onpointerdown}
161
+ {onpointermove}
162
+ {onpointerup}
163
+ onpointercancel={onpointerup}
164
+ >
165
+ <!-- Wider invisible hit area around the 1px line -->
166
+ <span
167
+ class={cn(
168
+ 'absolute',
169
+ horizontal ? 'inset-y-0 -left-1.5 -right-1.5' : 'inset-x-0 -top-1.5 -bottom-1.5'
170
+ )}
171
+ aria-hidden="true"
172
+ ></span>
173
+ </div>
174
+ <div class={cn('min-h-0 min-w-0 flex-1 overflow-hidden', endClass)}>
175
+ {@render end()}
176
+ </div>
177
+ </div>
178
+
179
+ {#if dragging}
180
+ <!-- Keeps the resize cursor and stops iframes in the panes from swallowing pointer moves -->
181
+ <div
182
+ class={cn('fixed inset-0 z-50', horizontal ? 'cursor-col-resize' : 'cursor-row-resize')}
183
+ aria-hidden="true"
184
+ ></div>
185
+ {/if}
@@ -0,0 +1,33 @@
1
+ export declare function clampSize(value: number, min: number, max: number): number;
2
+ import type { Snippet } from 'svelte';
3
+ interface Props {
4
+ /** Start pane share in percent (bindable) */
5
+ size?: number;
6
+ /** Smallest start share in percent (default 20) */
7
+ min?: number;
8
+ /** Largest start share in percent (default 80) */
9
+ max?: number;
10
+ /** Keyboard step in percent (default 5) */
11
+ step?: number;
12
+ /** `horizontal`: side by side (default); `vertical`: stacked */
13
+ orientation?: 'horizontal' | 'vertical';
14
+ /** Remember the size under this key */
15
+ persistKey?: string;
16
+ /** Accessible name for the handle (default "Resize panes") */
17
+ handleLabel?: string;
18
+ onResize?: (size: number) => void;
19
+ class?: string;
20
+ startClass?: string;
21
+ endClass?: string;
22
+ start: Snippet;
23
+ end: Snippet;
24
+ }
25
+ /**
26
+ * Two panes split by a draggable handle. `size` is the start pane's share in
27
+ * percent; the end pane takes the rest. The handle is a focusable
28
+ * `separator`: arrow keys move it by `step`, Home/End jump to `min`/`max`.
29
+ * With `persistKey` the size is remembered in localStorage.
30
+ */
31
+ declare const ResizablePanes: import("svelte").Component<Props, {}, "size">;
32
+ type ResizablePanes = ReturnType<typeof ResizablePanes>;
33
+ export default ResizablePanes;
@@ -20,6 +20,17 @@
20
20
  /** Whether the textarea 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 & { currentTarget: HTMLTextAreaElement }) => void;
25
+ /**
26
+ * Grow with the content instead of showing a resize handle. `rows` is the
27
+ * minimum height; past `maxRows` the textarea scrolls.
28
+ */
29
+ autosize?: boolean;
30
+ /** Upper bound for `autosize`, in rows (default 8) */
31
+ maxRows?: number;
32
+ /** The textarea element (bindable), e.g. to focus it */
33
+ ref?: HTMLTextAreaElement | null;
23
34
  [key: string]: unknown;
24
35
  }
25
36
 
@@ -38,17 +49,50 @@
38
49
  'aria-describedby': ariaDescribedby,
39
50
  'aria-invalid': ariaInvalid,
40
51
  onValueChange,
52
+ oninput,
53
+ autosize = false,
54
+ maxRows = 8,
55
+ ref = $bindable(null),
41
56
  ...restProps
42
57
  }: Props = $props();
43
58
 
59
+ /**
60
+ * Fit the height to the content: measure at `auto`, then clamp between
61
+ * `rows` and `maxRows` lines (plus padding and border, read from the
62
+ * computed style so consumer classes are respected).
63
+ */
64
+ function fit(el: HTMLTextAreaElement) {
65
+ const style = getComputedStyle(el);
66
+ const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 20;
67
+ const chrome =
68
+ parseFloat(style.paddingTop) +
69
+ parseFloat(style.paddingBottom) +
70
+ parseFloat(style.borderTopWidth) +
71
+ parseFloat(style.borderBottomWidth);
72
+ const min = rows * lineHeight + chrome;
73
+ const max = Math.max(rows, maxRows) * lineHeight + chrome;
74
+ el.style.height = 'auto';
75
+ const next = Math.min(Math.max(el.scrollHeight, min), max);
76
+ el.style.height = `${next}px`;
77
+ el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden';
78
+ }
79
+
80
+ $effect(() => {
81
+ // Re-fit when the value changes from outside too (cleared after send, prefilled)
82
+ void value;
83
+ if (autosize && ref) fit(ref);
84
+ });
85
+
44
86
  function handleInput(e: Event) {
45
87
  const target = e.target as HTMLTextAreaElement;
46
88
  value = target.value;
47
89
  onValueChange?.(target.value);
90
+ oninput?.(e as Event & { currentTarget: HTMLTextAreaElement });
48
91
  }
49
92
  </script>
50
93
 
51
94
  <textarea
95
+ bind:this={ref}
52
96
  {value}
53
97
  {placeholder}
54
98
  {disabled}
@@ -63,6 +107,7 @@
63
107
  aria-invalid={ariaInvalid}
64
108
  class={cn(
65
109
  'flex min-h-[80px] w-full rounded-md border border-input bg-background text-foreground px-3 py-2 text-base sm:text-sm ring-offset-background placeholder:text-gray-500 dark:placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-y',
110
+ autosize && 'min-h-0 resize-none',
66
111
  ariaInvalid === true || ariaInvalid === 'true'
67
112
  ? 'border-destructive focus-visible:ring-destructive'
68
113
  : '',
@@ -17,8 +17,21 @@ interface Props {
17
17
  /** Whether the textarea has an error */
18
18
  'aria-invalid'?: boolean | 'true' | 'false';
19
19
  onValueChange?: (value: string) => void;
20
+ /** Native input listener; runs after the value is updated */
21
+ oninput?: (e: Event & {
22
+ currentTarget: HTMLTextAreaElement;
23
+ }) => void;
24
+ /**
25
+ * Grow with the content instead of showing a resize handle. `rows` is the
26
+ * minimum height; past `maxRows` the textarea scrolls.
27
+ */
28
+ autosize?: boolean;
29
+ /** Upper bound for `autosize`, in rows (default 8) */
30
+ maxRows?: number;
31
+ /** The textarea element (bindable), e.g. to focus it */
32
+ ref?: HTMLTextAreaElement | null;
20
33
  [key: string]: unknown;
21
34
  }
22
- declare const Textarea: import("svelte").Component<Props, {}, "value">;
35
+ declare const Textarea: import("svelte").Component<Props, {}, "value" | "ref">;
23
36
  type Textarea = ReturnType<typeof Textarea>;
24
37
  export default Textarea;
@@ -17,6 +17,8 @@ export { default as Checkbox } from './components/Checkbox.svelte';
17
17
  export { default as Switch } from './components/Switch.svelte';
18
18
  export { default as Separator } from './components/Separator.svelte';
19
19
  export { default as Icon, type IconName } from './components/Icon.svelte';
20
+ export { default as CopyButton } from './components/CopyButton.svelte';
21
+ export { default as Kbd, formatKey, isApplePlatform } from './components/Kbd.svelte';
20
22
  export { default as Card } from './components/Card.svelte';
21
23
  export { default as CardHeader } from './components/CardHeader.svelte';
22
24
  export { default as CardTitle } from './components/CardTitle.svelte';
@@ -35,15 +37,19 @@ export { default as ProgressBar } from './components/ProgressBar.svelte';
35
37
  export { default as LoadingOverlay } from './components/LoadingOverlay.svelte';
36
38
  export { default as LoadingPage } from './components/LoadingPage.svelte';
37
39
  export { default as LoadingSection } from './components/LoadingSection.svelte';
40
+ export { default as EmptyState } from './components/EmptyState.svelte';
38
41
  export { default as LiveRegion } from './components/LiveRegion.svelte';
39
42
  export { default as SkipLink } from './components/SkipLink.svelte';
40
43
  export { default as Dialog } from './components/Dialog.svelte';
41
44
  export { default as AlertDialog } from './components/AlertDialog.svelte';
42
45
  export { default as Tooltip } from './components/Tooltip.svelte';
46
+ export { default as Popover } from './components/Popover.svelte';
47
+ export { default as Collapsible } from './components/Collapsible.svelte';
43
48
  export { default as DropdownMenu } from './components/DropdownMenu.svelte';
44
49
  export { default as ContextMenu } from './components/ContextMenu.svelte';
45
50
  export { default as AppLauncher, type AppLauncherItem } from './components/AppLauncher.svelte';
46
51
  export { default as AppLauncherGrid } from './components/AppLauncherGrid.svelte';
52
+ export { default as ResizablePanes, clampSize } from './components/ResizablePanes.svelte';
47
53
  export { default as DataPanel } from './components/DataPanel.svelte';
48
54
  export { default as DataPanelHeader } from './components/DataPanelHeader.svelte';
49
55
  export { default as DataPanelContent } from './components/DataPanelContent.svelte';
package/dist/lib/index.js CHANGED
@@ -18,6 +18,8 @@ export { default as Checkbox } from './components/Checkbox.svelte';
18
18
  export { default as Switch } from './components/Switch.svelte';
19
19
  export { default as Separator } from './components/Separator.svelte';
20
20
  export { default as Icon } from './components/Icon.svelte';
21
+ export { default as CopyButton } from './components/CopyButton.svelte';
22
+ export { default as Kbd, formatKey, isApplePlatform } from './components/Kbd.svelte';
21
23
  // Card Components
22
24
  export { default as Card } from './components/Card.svelte';
23
25
  export { default as CardHeader } from './components/CardHeader.svelte';
@@ -41,6 +43,7 @@ export { default as ProgressBar } from './components/ProgressBar.svelte';
41
43
  export { default as LoadingOverlay } from './components/LoadingOverlay.svelte';
42
44
  export { default as LoadingPage } from './components/LoadingPage.svelte';
43
45
  export { default as LoadingSection } from './components/LoadingSection.svelte';
46
+ export { default as EmptyState } from './components/EmptyState.svelte';
44
47
  // Accessibility Components
45
48
  export { default as LiveRegion } from './components/LiveRegion.svelte';
46
49
  export { default as SkipLink } from './components/SkipLink.svelte';
@@ -48,10 +51,14 @@ export { default as SkipLink } from './components/SkipLink.svelte';
48
51
  export { default as Dialog } from './components/Dialog.svelte';
49
52
  export { default as AlertDialog } from './components/AlertDialog.svelte';
50
53
  export { default as Tooltip } from './components/Tooltip.svelte';
54
+ export { default as Popover } from './components/Popover.svelte';
55
+ export { default as Collapsible } from './components/Collapsible.svelte';
51
56
  export { default as DropdownMenu } from './components/DropdownMenu.svelte';
52
57
  export { default as ContextMenu } from './components/ContextMenu.svelte';
53
58
  export { default as AppLauncher } from './components/AppLauncher.svelte';
54
59
  export { default as AppLauncherGrid } from './components/AppLauncherGrid.svelte';
60
+ // Layout primitives
61
+ export { default as ResizablePanes, clampSize } from './components/ResizablePanes.svelte';
55
62
  // DataPanel Components
56
63
  export { default as DataPanel } from './components/DataPanel.svelte';
57
64
  export { default as DataPanelHeader } from './components/DataPanelHeader.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.38.0",
3
+ "version": "1.39.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",