@classic-homes/theme-svelte 1.38.0 → 1.40.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.
Files changed (30) hide show
  1. package/dist/lib/components/Collapsible.svelte +68 -0
  2. package/dist/lib/components/Collapsible.svelte.d.ts +27 -0
  3. package/dist/lib/components/CopyButton.svelte +101 -0
  4. package/dist/lib/components/CopyButton.svelte.d.ts +32 -0
  5. package/dist/lib/components/EmptyState.svelte +72 -0
  6. package/dist/lib/components/EmptyState.svelte.d.ts +24 -0
  7. package/dist/lib/components/FilterToolbar.svelte +7 -1
  8. package/dist/lib/components/FilterToolbar.svelte.d.ts +4 -1
  9. package/dist/lib/components/Input.svelte +4 -0
  10. package/dist/lib/components/Input.svelte.d.ts +4 -0
  11. package/dist/lib/components/Kbd.svelte +80 -0
  12. package/dist/lib/components/Kbd.svelte.d.ts +22 -0
  13. package/dist/lib/components/Label.svelte +1 -1
  14. package/dist/lib/components/Popover.svelte +90 -0
  15. package/dist/lib/components/Popover.svelte.d.ts +34 -0
  16. package/dist/lib/components/ResizablePanes.svelte +185 -0
  17. package/dist/lib/components/ResizablePanes.svelte.d.ts +33 -0
  18. package/dist/lib/components/Textarea.svelte +45 -0
  19. package/dist/lib/components/Textarea.svelte.d.ts +14 -1
  20. package/dist/lib/components/Toolbar.svelte +8 -3
  21. package/dist/lib/components/Toolbar.svelte.d.ts +4 -1
  22. package/dist/lib/components/ToolbarCount.svelte +20 -0
  23. package/dist/lib/components/ToolbarCount.svelte.d.ts +4 -0
  24. package/dist/lib/components/ToolbarSearch.svelte +39 -0
  25. package/dist/lib/components/ToolbarSearch.svelte.d.ts +18 -0
  26. package/dist/lib/index.d.ts +9 -0
  27. package/dist/lib/index.js +10 -0
  28. package/dist/lib/utils/result-count.d.ts +34 -0
  29. package/dist/lib/utils/result-count.js +15 -0
  30. package/package.json +1 -1
@@ -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;
@@ -6,6 +6,8 @@
6
6
  import FilterPanel from './FilterPanel.svelte';
7
7
  import Icon from './Icon.svelte';
8
8
  import Toolbar from './Toolbar.svelte';
9
+ import ToolbarCount from './ToolbarCount.svelte';
10
+ import type { ToolbarCountProps } from '../utils/result-count.js';
9
11
 
10
12
  /**
11
13
  * FilterToolbar - a Toolbar that folds its filter controls behind a
@@ -33,7 +35,9 @@
33
35
  filterCount: number;
34
36
  /** Filters currently narrowing results (button badge) */
35
37
  activeCount?: number;
36
- /** Result count / summary text, after the filters or the button */
38
+ /** Result count, after the filters or the button (see `ToolbarCount`) */
39
+ count?: ToolbarCountProps;
40
+ /** Other summary text after the count (inherits the strip color; `text-toolbar-muted` to dim) */
37
41
  summary?: Snippet;
38
42
  /** Right-aligned cluster (view toggles etc.) */
39
43
  end?: Snippet;
@@ -54,6 +58,7 @@
54
58
  filters,
55
59
  filterCount,
56
60
  activeCount = 0,
61
+ count,
57
62
  summary,
58
63
  end: endSnippet,
59
64
  onClear,
@@ -99,6 +104,7 @@
99
104
  {:else}
100
105
  {@render filters({ inline: true })}
101
106
  {/if}
107
+ {#if count}<ToolbarCount {...count} />{/if}
102
108
  {@render summary?.()}
103
109
  </Toolbar>
104
110
 
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { ToolbarCountProps } from '../utils/result-count.js';
2
3
  /**
3
4
  * FilterToolbar - a Toolbar that folds its filter controls behind a
4
5
  * "Filters" button once there are too many to sit inline.
@@ -27,7 +28,9 @@ interface Props {
27
28
  filterCount: number;
28
29
  /** Filters currently narrowing results (button badge) */
29
30
  activeCount?: number;
30
- /** Result count / summary text, after the filters or the button */
31
+ /** Result count, after the filters or the button (see `ToolbarCount`) */
32
+ count?: ToolbarCountProps;
33
+ /** Other summary text after the count (inherits the strip color; `text-toolbar-muted` to dim) */
31
34
  summary?: Snippet;
32
35
  /** Right-aligned cluster (view toggles etc.) */
33
36
  end?: Snippet;
@@ -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;
@@ -23,7 +23,7 @@
23
23
  <LabelPrimitive.Root
24
24
  for={htmlFor}
25
25
  class={cn(
26
- 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground',
26
+ 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-[hsl(var(--label-foreground,var(--foreground)))]',
27
27
  disabled && 'opacity-50 cursor-not-allowed',
28
28
  className
29
29
  )}
@@ -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;
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { cn } from '../utils.js';
4
+ import type { ToolbarCountProps } from '../utils/result-count.js';
5
+ import ToolbarCount from './ToolbarCount.svelte';
4
6
 
5
7
  /**
6
8
  * Toolbar - dark strip for list controls (search, filters, result count,
@@ -17,25 +19,28 @@
17
19
  label: string;
18
20
  /** Leading controls (search, filters, result count) */
19
21
  children?: Snippet;
20
- /** Right-aligned cluster (e.g. SegmentedControl + ViewToggle) */
22
+ /** Result count after the leading controls, e.g. `{ total, noun: 'home', page }` */
23
+ count?: ToolbarCountProps;
24
+ /** Right-aligned cluster: scope pickers (All/Mine), SegmentedControl, ViewToggle */
21
25
  end?: Snippet;
22
26
  class?: string;
23
27
  [key: string]: unknown;
24
28
  }
25
29
 
26
- let { label, children, end, class: className, ...restProps }: Props = $props();
30
+ let { label, children, count, end, class: className, ...restProps }: Props = $props();
27
31
  </script>
28
32
 
29
33
  <div
30
34
  role="group"
31
35
  aria-label={label}
32
36
  class={cn(
33
- 'flex flex-wrap items-center gap-2 rounded-lg bg-toolbar px-3 py-2 text-toolbar-foreground [--secondary-border:var(--toolbar-border)]',
37
+ 'flex flex-wrap items-center gap-2 rounded-lg bg-toolbar px-3 py-2 text-toolbar-foreground [--label-foreground:var(--toolbar-foreground)] [--secondary-border:var(--toolbar-border)]',
34
38
  className
35
39
  )}
36
40
  {...restProps}
37
41
  >
38
42
  {@render children?.()}
43
+ {#if count}<ToolbarCount {...count} />{/if}
39
44
  {#if end}
40
45
  <div class="ml-auto flex flex-wrap items-center gap-2">{@render end()}</div>
41
46
  {/if}
@@ -1,4 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { ToolbarCountProps } from '../utils/result-count.js';
2
3
  /**
3
4
  * Toolbar - dark strip for list controls (search, filters, result count,
4
5
  * grouping and view toggles) under a page header.
@@ -14,7 +15,9 @@ interface Props {
14
15
  label: string;
15
16
  /** Leading controls (search, filters, result count) */
16
17
  children?: Snippet;
17
- /** Right-aligned cluster (e.g. SegmentedControl + ViewToggle) */
18
+ /** Result count after the leading controls, e.g. `{ total, noun: 'home', page }` */
19
+ count?: ToolbarCountProps;
20
+ /** Right-aligned cluster: scope pickers (All/Mine), SegmentedControl, ViewToggle */
18
21
  end?: Snippet;
19
22
  class?: string;
20
23
  [key: string]: unknown;
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import { resultCountText, type ToolbarCountProps } from '../utils/result-count.js';
3
+
4
+ /**
5
+ * ToolbarCount - the result count on a Toolbar / FilterToolbar strip, in the
6
+ * one wording `resultCountText` defines ("Showing 1–50 of 7,289 homes",
7
+ * "12 of 40 homes", "40 homes", "No homes found"). Inherits the strip's
8
+ * light text and announces changes politely. Renders nothing while `total`
9
+ * is undefined (first load). Usually passed as the strip's `count` prop.
10
+ */
11
+ let { note, ...input }: ToolbarCountProps = $props();
12
+
13
+ const text = $derived(resultCountText(input));
14
+ </script>
15
+
16
+ {#if text}
17
+ <span class="whitespace-nowrap text-sm tabular-nums" role="status">
18
+ {text}{note && input.total ? ` · ${note}` : ''}
19
+ </span>
20
+ {/if}
@@ -0,0 +1,4 @@
1
+ import { type ToolbarCountProps } from '../utils/result-count.js';
2
+ declare const ToolbarCount: import("svelte").Component<ToolbarCountProps, {}, "">;
3
+ type ToolbarCount = ReturnType<typeof ToolbarCount>;
4
+ export default ToolbarCount;
@@ -0,0 +1,39 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils.js';
3
+ import Input from './Input.svelte';
4
+
5
+ /**
6
+ * ToolbarSearch - the search field for a Toolbar / FilterToolbar strip:
7
+ * full width on phones, a fixed `sm:w-64` beside the filters, and an
8
+ * accessible name (strips have no visible labels). End `placeholder` with
9
+ * "…", e.g. "Search homes…".
10
+ */
11
+ interface Props {
12
+ /** Accessible name, e.g. "Search homes" */
13
+ label: string;
14
+ value?: string;
15
+ placeholder?: string;
16
+ id?: string;
17
+ class?: string;
18
+ [key: string]: unknown;
19
+ }
20
+
21
+ let {
22
+ label,
23
+ value = $bindable(''),
24
+ placeholder,
25
+ id,
26
+ class: className,
27
+ ...restProps
28
+ }: Props = $props();
29
+ </script>
30
+
31
+ <Input
32
+ {id}
33
+ type="search"
34
+ aria-label={label}
35
+ placeholder={placeholder ?? `${label}…`}
36
+ class={cn('w-full sm:w-64', className)}
37
+ bind:value
38
+ {...restProps}
39
+ />
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ToolbarSearch - the search field for a Toolbar / FilterToolbar strip:
3
+ * full width on phones, a fixed `sm:w-64` beside the filters, and an
4
+ * accessible name (strips have no visible labels). End `placeholder` with
5
+ * "…", e.g. "Search homes…".
6
+ */
7
+ interface Props {
8
+ /** Accessible name, e.g. "Search homes" */
9
+ label: string;
10
+ value?: string;
11
+ placeholder?: string;
12
+ id?: string;
13
+ class?: string;
14
+ [key: string]: unknown;
15
+ }
16
+ declare const ToolbarSearch: import("svelte").Component<Props, {}, "value">;
17
+ type ToolbarSearch = ReturnType<typeof ToolbarSearch>;
18
+ export default ToolbarSearch;
@@ -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';
@@ -65,6 +71,9 @@ export { default as Tabs } from './components/Tabs.svelte';
65
71
  export { default as TabPanel } from './components/TabPanel.svelte';
66
72
  export { default as Toolbar } from './components/Toolbar.svelte';
67
73
  export { default as FilterToolbar } from './components/FilterToolbar.svelte';
74
+ export { default as ToolbarCount } from './components/ToolbarCount.svelte';
75
+ export { default as ToolbarSearch } from './components/ToolbarSearch.svelte';
76
+ export { resultCountText, type ResultCountInput, type ToolbarCountProps, } from './utils/result-count.js';
68
77
  export { default as FilterPanel } from './components/FilterPanel.svelte';
69
78
  export { default as ActiveFilterChips, type ActiveFilterChip, } from './components/ActiveFilterChips.svelte';
70
79
  export { default as SegmentedControl, type SegmentedControlOption, } from './components/SegmentedControl.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';
@@ -78,6 +85,9 @@ export { default as TabPanel } from './components/TabPanel.svelte';
78
85
  // Toolbar controls
79
86
  export { default as Toolbar } from './components/Toolbar.svelte';
80
87
  export { default as FilterToolbar } from './components/FilterToolbar.svelte';
88
+ export { default as ToolbarCount } from './components/ToolbarCount.svelte';
89
+ export { default as ToolbarSearch } from './components/ToolbarSearch.svelte';
90
+ export { resultCountText, } from './utils/result-count.js';
81
91
  export { default as FilterPanel } from './components/FilterPanel.svelte';
82
92
  export { default as ActiveFilterChips, } from './components/ActiveFilterChips.svelte';
83
93
  export { default as SegmentedControl, } from './components/SegmentedControl.svelte';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The one result-count wording for list toolbars (`ToolbarCount`, and the
3
+ * `count` prop on `Toolbar` / `FilterToolbar`):
4
+ *
5
+ * - server-paged, more than one page: "Showing 1–50 of 7,289 homes"
6
+ * - locally filtered (`of` = unfiltered size): "12 of 40 homes"
7
+ * - otherwise: "40 homes" / "1 home"
8
+ * - nothing matches: `empty` (default "No homes found")
9
+ *
10
+ * Returns '' while the first load is pending (`total` undefined), so the strip
11
+ * never flashes "No homes found" before the data lands.
12
+ */
13
+ export interface ResultCountInput {
14
+ /** Rows matching the current search/filters (server total or filtered length); undefined while loading. */
15
+ total: number | undefined | null;
16
+ /** Singular noun, e.g. "home". */
17
+ noun: string;
18
+ /** Plural noun when it isn't `${noun}s`, e.g. "entries". */
19
+ plural?: string;
20
+ /** Server paging: renders the "Showing a–b of N" range when total spans pages. */
21
+ page?: {
22
+ page: number;
23
+ limit: number;
24
+ };
25
+ /** Unfiltered size for locally filtered lists: "12 of 40 homes". */
26
+ of?: number;
27
+ /** Zero-match text (default "No <plural> found"). */
28
+ empty?: string;
29
+ }
30
+ /** `ResultCountInput` plus a trailing remark after " · " (falsy hides it). */
31
+ export interface ToolbarCountProps extends ResultCountInput {
32
+ note?: string | false | null;
33
+ }
34
+ export declare function resultCountText({ total, noun, plural, page, of, empty, }: ResultCountInput): string;
@@ -0,0 +1,15 @@
1
+ const fmt = (n) => n.toLocaleString('en-US');
2
+ export function resultCountText({ total, noun, plural = `${noun}s`, page, of, empty, }) {
3
+ if (total == null)
4
+ return '';
5
+ if (total === 0)
6
+ return empty ?? `No ${plural} found`;
7
+ if (page && total > page.limit) {
8
+ const start = (page.page - 1) * page.limit + 1;
9
+ const end = Math.min(page.page * page.limit, total);
10
+ return `Showing ${fmt(start)}–${fmt(end)} of ${fmt(total)} ${plural}`;
11
+ }
12
+ if (of !== undefined && of !== total)
13
+ return `${fmt(total)} of ${fmt(of)} ${plural}`;
14
+ return `${fmt(total)} ${total === 1 ? noun : plural}`;
15
+ }
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.40.0",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",