@classic-homes/theme-svelte 1.17.0 → 1.19.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 (44) hide show
  1. package/dist/__tests__/dropdown-trigger.test.js +9 -0
  2. package/dist/__tests__/sidebar-mobile.test.js +13 -0
  3. package/dist/__tests__/studio.test.js +223 -0
  4. package/dist/lib/components/DropdownMenu.svelte +17 -0
  5. package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
  6. package/dist/lib/components/Sheet.svelte +155 -0
  7. package/dist/lib/components/Sheet.svelte.d.ts +28 -0
  8. package/dist/lib/components/SortableList.svelte +251 -0
  9. package/dist/lib/components/SortableList.svelte.d.ts +64 -0
  10. package/dist/lib/components/layout/DashboardLayout.svelte +34 -12
  11. package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +6 -2
  12. package/dist/lib/components/layout/sidebar/SidebarSection.svelte +5 -3
  13. package/dist/lib/components/studio/InsertPalette.svelte +192 -0
  14. package/dist/lib/components/studio/InsertPalette.svelte.d.ts +39 -0
  15. package/dist/lib/components/studio/IssuesButton.svelte +141 -0
  16. package/dist/lib/components/studio/IssuesButton.svelte.d.ts +33 -0
  17. package/dist/lib/components/studio/SaveStatus.svelte +110 -0
  18. package/dist/lib/components/studio/SaveStatus.svelte.d.ts +22 -0
  19. package/dist/lib/components/studio/Studio.svelte +189 -0
  20. package/dist/lib/components/studio/Studio.svelte.d.ts +49 -0
  21. package/dist/lib/components/studio/StudioBar.svelte +168 -0
  22. package/dist/lib/components/studio/StudioBar.svelte.d.ts +46 -0
  23. package/dist/lib/components/studio/StudioCanvas.svelte +87 -0
  24. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +30 -0
  25. package/dist/lib/components/studio/StudioCanvasItem.svelte +121 -0
  26. package/dist/lib/components/studio/StudioCanvasItem.svelte.d.ts +29 -0
  27. package/dist/lib/components/studio/StudioInspector.svelte +133 -0
  28. package/dist/lib/components/studio/StudioInspector.svelte.d.ts +37 -0
  29. package/dist/lib/components/studio/StudioInspectorSection.svelte +79 -0
  30. package/dist/lib/components/studio/StudioInspectorSection.svelte.d.ts +24 -0
  31. package/dist/lib/components/studio/StudioOutline.svelte +303 -0
  32. package/dist/lib/components/studio/StudioOutline.svelte.d.ts +55 -0
  33. package/dist/lib/components/studio/StudioPage.svelte +36 -0
  34. package/dist/lib/components/studio/StudioPage.svelte.d.ts +18 -0
  35. package/dist/lib/components/studio/context.d.ts +25 -0
  36. package/dist/lib/components/studio/context.js +9 -0
  37. package/dist/lib/components/studio/index.d.ts +12 -0
  38. package/dist/lib/components/studio/index.js +12 -0
  39. package/dist/lib/index.d.ts +3 -0
  40. package/dist/lib/index.js +6 -0
  41. package/dist/lib/schemas/common.d.ts +2 -2
  42. package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
  43. package/dist/lib/stores/sidebar.svelte.js +13 -0
  44. package/package.json +1 -1
@@ -27,4 +27,13 @@ describe('DropdownMenu trigger', () => {
27
27
  }
28
28
  expect(button.classList.contains('custom')).toBe(true);
29
29
  });
30
+ it('adds a disclosure chevron only with showChevron', () => {
31
+ const { unmount } = render(DropdownMenu, { props: { items, trigger } });
32
+ expect(screen.getByRole('button', { name: 'Menu' }).querySelector('svg')).toBeNull();
33
+ unmount();
34
+ render(DropdownMenu, { props: { items, trigger, triggerVariant: 'ghost', showChevron: true } });
35
+ const chevron = screen.getByRole('button', { name: 'Menu' }).querySelector('svg');
36
+ expect(chevron).not.toBeNull();
37
+ expect(chevron?.getAttribute('aria-hidden')).toBe('true');
38
+ });
30
39
  });
@@ -70,4 +70,17 @@ describe('sidebar mobile semantics', () => {
70
70
  sidebarStore.setMobile(false);
71
71
  expect(sidebarStore.isOpen).toBe(false);
72
72
  });
73
+ it('does not animate expansion until the frames after initialize', async () => {
74
+ const appId = freshAppId();
75
+ localStorage.setItem(`classic-theme-sidebar-${appId}-sections`, JSON.stringify(['a']));
76
+ // A fresh store instance would start false; the singleton may already be settled
77
+ // from earlier tests, so check the reset-on-initialize path via a new appId.
78
+ sidebarStore.animate = false;
79
+ sidebarStore.initialize(appId);
80
+ expect(sidebarStore.isSectionExpanded('a')).toBe(true);
81
+ expect(sidebarStore.animate).toBe(false);
82
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
83
+ await new Promise((resolve) => setTimeout(resolve, 0));
84
+ expect(sidebarStore.animate).toBe(true);
85
+ });
73
86
  });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Studio editor frame: outline selection and reordering (pointer-free, by
3
+ * keyboard), the save and issues signals, and the responsive move of the
4
+ * outline and inspector into sheets.
5
+ */
6
+ import { describe, it, expect, vi, afterEach } from 'vitest';
7
+ import { mount, unmount, flushSync, tick, createRawSnippet } from 'svelte';
8
+ import StudioOutline from '../lib/components/studio/StudioOutline.svelte';
9
+ import SaveStatus from '../lib/components/studio/SaveStatus.svelte';
10
+ import IssuesButton from '../lib/components/studio/IssuesButton.svelte';
11
+ import Studio from '../lib/components/studio/Studio.svelte';
12
+ import StudioCanvasItem from '../lib/components/studio/StudioCanvasItem.svelte';
13
+ let instance = null;
14
+ let host;
15
+ function render(component, props) {
16
+ host = document.createElement('div');
17
+ document.body.appendChild(host);
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ instance = mount(component, { target: host, props });
20
+ flushSync();
21
+ return host;
22
+ }
23
+ function viewport(width) {
24
+ vi.spyOn(window, 'matchMedia').mockImplementation((query) => {
25
+ const min = Number(/min-width:\s*(\d+)/.exec(query)?.[1] ?? 0);
26
+ return {
27
+ matches: width >= min,
28
+ media: query,
29
+ onchange: null,
30
+ addEventListener: vi.fn(),
31
+ removeEventListener: vi.fn(),
32
+ addListener: vi.fn(),
33
+ removeListener: vi.fn(),
34
+ dispatchEvent: vi.fn(),
35
+ };
36
+ });
37
+ }
38
+ const html = (markup) => createRawSnippet(() => ({ render: () => markup }));
39
+ function key(target, k, init = {}) {
40
+ target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, ...init }));
41
+ flushSync();
42
+ }
43
+ afterEach(() => {
44
+ if (instance)
45
+ unmount(instance);
46
+ instance = null;
47
+ host?.remove();
48
+ document.body.innerHTML = '';
49
+ vi.restoreAllMocks();
50
+ });
51
+ const entries = [
52
+ { id: 'a', title: 'Name' },
53
+ { id: 'b', title: 'Email', issues: 2 },
54
+ { id: 'c', title: 'Rating' },
55
+ ];
56
+ describe('StudioOutline', () => {
57
+ it('selects a row and marks it current', () => {
58
+ const onSelect = vi.fn();
59
+ const el = render(StudioOutline, { items: entries, selected: 'a', onSelect });
60
+ const rows = el.querySelectorAll('[data-outline-row]');
61
+ rows[2].click();
62
+ flushSync();
63
+ expect(onSelect).toHaveBeenCalledWith('c');
64
+ expect(rows[2].getAttribute('aria-current')).toBe('true');
65
+ expect(rows[0].hasAttribute('aria-current')).toBe(false);
66
+ });
67
+ it('keeps one row in the tab order and moves focus with the arrow keys', () => {
68
+ const el = render(StudioOutline, { items: entries, selected: 'b' });
69
+ const rows = el.querySelectorAll('[data-outline-row]');
70
+ expect([...rows].map((r) => r.tabIndex)).toEqual([-1, 0, -1]);
71
+ rows[1].focus();
72
+ key(rows[1], 'ArrowDown');
73
+ expect(document.activeElement).toBe(rows[2]);
74
+ });
75
+ it('shows issue counts as text, not only colour', () => {
76
+ const el = render(StudioOutline, { items: entries });
77
+ expect(el.textContent).toContain('2');
78
+ expect(el.textContent).toContain('issues');
79
+ });
80
+ it('reorders with the keyboard: pick up, move, drop', () => {
81
+ const onReorder = vi.fn();
82
+ const el = render(StudioOutline, { items: entries, onReorder });
83
+ const handle = el.querySelector('[data-sortable-handle="a"]');
84
+ expect(handle.getAttribute('aria-label')).toBe('Reorder Name');
85
+ key(handle, ' ');
86
+ expect(handle.getAttribute('aria-pressed')).toBe('true');
87
+ key(handle, 'ArrowDown');
88
+ expect(onReorder).toHaveBeenCalledWith(0, 1);
89
+ });
90
+ it('moves in one step with Alt+Arrow', () => {
91
+ const onReorder = vi.fn();
92
+ const el = render(StudioOutline, { items: entries, onReorder });
93
+ const handle = el.querySelector('[data-sortable-handle="c"]');
94
+ key(handle, 'ArrowUp', { altKey: true });
95
+ expect(onReorder).toHaveBeenCalledWith(2, 1);
96
+ });
97
+ it('ignores arrows on a handle that is not picked up', () => {
98
+ const onReorder = vi.fn();
99
+ const el = render(StudioOutline, { items: entries, onReorder });
100
+ key(el.querySelector('[data-sortable-handle="a"]'), 'ArrowDown');
101
+ expect(onReorder).not.toHaveBeenCalled();
102
+ });
103
+ it('has no drag handles when reordering is off', () => {
104
+ const el = render(StudioOutline, { items: entries });
105
+ expect(el.querySelector('[data-sortable-handle]')).toBeNull();
106
+ });
107
+ it('renders the add button only when onAdd is given', () => {
108
+ const onAdd = vi.fn();
109
+ const el = render(StudioOutline, { items: entries, onAdd, addLabel: 'Add question' });
110
+ const add = [...el.querySelectorAll('button')].find((b) => b.textContent?.includes('Add question'));
111
+ add.click();
112
+ expect(onAdd).toHaveBeenCalled();
113
+ });
114
+ });
115
+ describe('SaveStatus', () => {
116
+ it('shows one word for routine states and announces nothing', () => {
117
+ const el = render(SaveStatus, { state: 'saved' });
118
+ expect(el.textContent).toContain('Saved');
119
+ expect(el.querySelector('[role="status"]').textContent).toBe('');
120
+ });
121
+ it('announces failures and offers Retry in its details', async () => {
122
+ const onRetry = vi.fn();
123
+ const el = render(SaveStatus, {
124
+ state: 'error',
125
+ detail: 'The server did not answer.',
126
+ onRetry,
127
+ });
128
+ expect(el.querySelector('[role="status"]').textContent).toBe('Not saved');
129
+ const trigger = el.querySelector('button');
130
+ expect(trigger.getAttribute('aria-label')).toContain('Not saved');
131
+ trigger.click();
132
+ flushSync();
133
+ await tick();
134
+ const retry = [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Retry'));
135
+ expect(document.body.textContent).toContain('The server did not answer.');
136
+ retry.click();
137
+ expect(onRetry).toHaveBeenCalled();
138
+ });
139
+ });
140
+ describe('IssuesButton', () => {
141
+ const issues = [
142
+ { id: '1', message: 'Add a question', location: 'Questions' },
143
+ { id: '2', message: 'Add at least two options', location: 'Question 2' },
144
+ { id: '3', message: 'Consider a close date', severity: 'warning' },
145
+ ];
146
+ it('is hidden when there is nothing to fix', () => {
147
+ const el = render(IssuesButton, { issues: [] });
148
+ expect(el.querySelector('button')).toBeNull();
149
+ });
150
+ it('names errors and suggestions in its label', () => {
151
+ const el = render(IssuesButton, { issues });
152
+ expect(el.querySelector('button').getAttribute('aria-label')).toBe('2 issues to fix, 1 suggestion');
153
+ });
154
+ it('jumps to an issue and closes', async () => {
155
+ const onSelect = vi.fn();
156
+ const el = render(IssuesButton, { issues, onSelect });
157
+ el.querySelector('button').click();
158
+ flushSync();
159
+ await tick();
160
+ const entry = [...document.querySelectorAll('button')].find((b) => b.textContent?.includes('Add at least two options'));
161
+ entry.click();
162
+ flushSync();
163
+ expect(onSelect).toHaveBeenCalledWith(issues[1]);
164
+ });
165
+ });
166
+ describe('Studio layout', () => {
167
+ const parts = {
168
+ bar: html('<header>Bar</header>'),
169
+ rail: html('<nav>Rail</nav>'),
170
+ canvas: html('<p>Canvas</p>'),
171
+ inspector: html('<div>Inspector</div>'),
172
+ };
173
+ it('shows rail, canvas and inspector side by side when wide', () => {
174
+ viewport(1440);
175
+ const el = render(Studio, parts);
176
+ expect(el.querySelector('aside[aria-label="Outline"]')).not.toBeNull();
177
+ expect(el.querySelector('aside[aria-label="Properties"]')).not.toBeNull();
178
+ expect(el.textContent).not.toContain('Outline');
179
+ });
180
+ it('moves the rail into a sheet below 1280px', () => {
181
+ viewport(1024);
182
+ const el = render(Studio, parts);
183
+ expect(el.querySelector('aside[aria-label="Outline"]')).toBeNull();
184
+ expect(el.querySelector('aside[aria-label="Properties"]')).not.toBeNull();
185
+ const open = [...el.querySelectorAll('button')].find((b) => b.textContent?.includes('Outline'));
186
+ open.click();
187
+ flushSync();
188
+ expect(document.body.textContent).toContain('Rail');
189
+ });
190
+ it('leaves only the canvas on phones', () => {
191
+ viewport(390);
192
+ const el = render(Studio, parts);
193
+ expect(el.querySelector('aside')).toBeNull();
194
+ expect(el.textContent).toContain('Canvas');
195
+ const labels = [...el.querySelectorAll('button')].map((b) => b.textContent?.trim());
196
+ expect(labels).toEqual(['Outline', 'Properties']);
197
+ });
198
+ });
199
+ describe('StudioCanvasItem', () => {
200
+ it('selects on click and offers a keyboard way in', () => {
201
+ const onSelect = vi.fn();
202
+ const el = render(StudioCanvasItem, {
203
+ label: 'Question 1',
204
+ onSelect,
205
+ children: html('<p>Body</p>'),
206
+ });
207
+ const edit = el.querySelector('button');
208
+ expect(edit.textContent?.trim()).toBe('Edit Question 1');
209
+ el.querySelector('p').click();
210
+ expect(onSelect).toHaveBeenCalled();
211
+ });
212
+ it('inserts below without needing a selection change', () => {
213
+ const onInsert = vi.fn();
214
+ const el = render(StudioCanvasItem, {
215
+ label: 'Question 1',
216
+ selected: true,
217
+ onInsert,
218
+ children: html('<p>Body</p>'),
219
+ });
220
+ el.querySelector('button[aria-label="Add below Question 1"]').click();
221
+ expect(onInsert).toHaveBeenCalled();
222
+ });
223
+ });
@@ -50,6 +50,11 @@
50
50
  triggerSize?: ButtonVariants['size'];
51
51
  /** Accessible name for the trigger `<button>` (for icon-only triggers) */
52
52
  triggerLabel?: string;
53
+ /**
54
+ * Show a disclosure chevron after the trigger content, turned while the menu is open,
55
+ * so a text trigger (e.g. header navigation) reads as a menu rather than a link.
56
+ */
57
+ showChevron?: boolean;
53
58
  }
54
59
 
55
60
  let {
@@ -64,6 +69,7 @@
64
69
  triggerVariant,
65
70
  triggerSize = 'sm',
66
71
  triggerLabel,
72
+ showChevron = false,
67
73
  }: Props = $props();
68
74
 
69
75
  function handleOpenChange(newOpen: boolean) {
@@ -130,6 +136,17 @@
130
136
  aria-label={triggerLabel}
131
137
  >
132
138
  {@render trigger()}
139
+ {#if showChevron}
140
+ <Icon
141
+ name="chevron-down"
142
+ size={16}
143
+ class={cn(
144
+ 'ml-1 shrink-0 opacity-60 transition-transform duration-200 motion-reduce:transition-none',
145
+ open && 'rotate-180'
146
+ )}
147
+ aria-hidden="true"
148
+ />
149
+ {/if}
133
150
  </button>
134
151
  {/snippet}
135
152
  </DropdownMenuPrimitive.Trigger>
@@ -44,6 +44,11 @@ interface Props {
44
44
  triggerSize?: ButtonVariants['size'];
45
45
  /** Accessible name for the trigger `<button>` (for icon-only triggers) */
46
46
  triggerLabel?: string;
47
+ /**
48
+ * Show a disclosure chevron after the trigger content, turned while the menu is open,
49
+ * so a text trigger (e.g. header navigation) reads as a menu rather than a link.
50
+ */
51
+ showChevron?: boolean;
47
52
  }
48
53
  declare const DropdownMenu: import("svelte").Component<Props, {}, "open">;
49
54
  type DropdownMenu = ReturnType<typeof DropdownMenu>;
@@ -0,0 +1,155 @@
1
+ <script lang="ts">
2
+ import { Dialog as DialogPrimitive } from 'bits-ui';
3
+ import { tv } from 'tailwind-variants';
4
+ import type { Snippet } from 'svelte';
5
+ import { cn } from '../utils.js';
6
+ import Icon from './Icon.svelte';
7
+
8
+ /**
9
+ * Sheet - a modal panel that slides in from an edge of the screen.
10
+ *
11
+ * Use it for secondary surfaces that belong beside the page rather than on
12
+ * top of it: version history, a phone-width outline or inspector, filters.
13
+ * Built on the same Bits UI dialog as `Dialog` (focus trap, Escape, scrim
14
+ * click and focus return), so only the placement differs.
15
+ */
16
+ const sheet = tv({
17
+ slots: {
18
+ content:
19
+ 'fixed z-50 flex flex-col border-border bg-background shadow-lg outline-none duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out',
20
+ },
21
+ variants: {
22
+ side: {
23
+ right: {
24
+ content:
25
+ 'inset-y-0 right-0 h-dvh w-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
26
+ },
27
+ left: {
28
+ content:
29
+ 'inset-y-0 left-0 h-dvh w-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
30
+ },
31
+ bottom: {
32
+ content:
33
+ 'inset-x-0 bottom-0 max-h-[85dvh] w-full rounded-t-xl border-t pb-[env(safe-area-inset-bottom)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
34
+ },
35
+ },
36
+ size: { sm: {}, md: {}, lg: {} },
37
+ },
38
+ compoundVariants: [
39
+ { side: ['left', 'right'], size: 'sm', class: { content: 'sm:max-w-xs' } },
40
+ { side: ['left', 'right'], size: 'md', class: { content: 'sm:max-w-md' } },
41
+ { side: ['left', 'right'], size: 'lg', class: { content: 'sm:max-w-xl' } },
42
+ ],
43
+ defaultVariants: { side: 'right', size: 'md' },
44
+ });
45
+
46
+ interface Props {
47
+ /** Whether the sheet is open (bindable) */
48
+ open?: boolean;
49
+ onOpenChange?: (open: boolean) => void;
50
+ /** Edge the sheet slides in from (default: right) */
51
+ side?: 'left' | 'right' | 'bottom';
52
+ /** Width of a left/right sheet from `sm` up; phones always get the full width */
53
+ size?: 'sm' | 'md' | 'lg';
54
+ /** Heading, also the sheet's accessible name */
55
+ title: string;
56
+ /**
57
+ * Hide the heading row (the title still names the sheet for screen
58
+ * readers) when the content brings its own header. The close button moves
59
+ * into the grab bar (bottom) or the top-right corner (left/right).
60
+ */
61
+ hideTitle?: boolean;
62
+ description?: string;
63
+ /** Buttons beside the close button */
64
+ headerActions?: Snippet;
65
+ /** Pinned footer, e.g. the sheet's primary action */
66
+ footer?: Snippet;
67
+ children: Snippet;
68
+ class?: string;
69
+ }
70
+
71
+ let {
72
+ open = $bindable(false),
73
+ onOpenChange,
74
+ side = 'right',
75
+ size = 'md',
76
+ title,
77
+ hideTitle = false,
78
+ description,
79
+ headerActions,
80
+ footer,
81
+ children,
82
+ class: className,
83
+ }: Props = $props();
84
+
85
+ const styles = $derived(sheet({ side, size }));
86
+
87
+ function handleOpenChange(next: boolean) {
88
+ open = next;
89
+ onOpenChange?.(next);
90
+ }
91
+ </script>
92
+
93
+ <!-- Rendered inside Content, so Close finds its dialog -->
94
+ {#snippet closeButton()}
95
+ <DialogPrimitive.Close
96
+ class="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
97
+ aria-label="Close"
98
+ >
99
+ <Icon name="close" size={18} aria-hidden="true" />
100
+ </DialogPrimitive.Close>
101
+ {/snippet}
102
+
103
+ <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
104
+ <DialogPrimitive.Portal>
105
+ <DialogPrimitive.Overlay
106
+ class="fixed inset-0 z-50 bg-foreground/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
107
+ />
108
+ <DialogPrimitive.Content class={cn(styles.content(), className)}>
109
+ {#if side === 'bottom'}
110
+ <!-- Grab bar row; carries the close button when the title is hidden -->
111
+ <div class="relative flex h-6 shrink-0 items-center justify-center">
112
+ <div class="h-1.5 w-10 rounded-full bg-muted" aria-hidden="true"></div>
113
+ {#if hideTitle}
114
+ <div class="absolute right-2 top-1">{@render closeButton()}</div>
115
+ {/if}
116
+ </div>
117
+ {/if}
118
+
119
+ {#if hideTitle}
120
+ <DialogPrimitive.Title class="sr-only">{title}</DialogPrimitive.Title>
121
+ {#if description}
122
+ <DialogPrimitive.Description class="sr-only">{description}</DialogPrimitive.Description>
123
+ {/if}
124
+ {#if side !== 'bottom'}
125
+ <div class="absolute right-2 top-2 z-10">{@render closeButton()}</div>
126
+ {/if}
127
+ {:else}
128
+ <div class="flex shrink-0 items-start gap-3 border-b border-border px-4 py-3">
129
+ <div class="min-w-0 flex-1">
130
+ <DialogPrimitive.Title class="text-base font-semibold leading-tight">
131
+ {title}
132
+ </DialogPrimitive.Title>
133
+ {#if description}
134
+ <DialogPrimitive.Description class="mt-1 text-sm text-muted-foreground">
135
+ {description}
136
+ </DialogPrimitive.Description>
137
+ {/if}
138
+ </div>
139
+ <div class="-mr-1 ml-auto flex shrink-0 items-center gap-1">
140
+ {#if headerActions}{@render headerActions()}{/if}
141
+ {@render closeButton()}
142
+ </div>
143
+ </div>
144
+ {/if}
145
+ <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
146
+ {@render children()}
147
+ </div>
148
+ {#if footer}
149
+ <div class="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
150
+ {@render footer()}
151
+ </div>
152
+ {/if}
153
+ </DialogPrimitive.Content>
154
+ </DialogPrimitive.Portal>
155
+ </DialogPrimitive.Root>
@@ -0,0 +1,28 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** Whether the sheet is open (bindable) */
4
+ open?: boolean;
5
+ onOpenChange?: (open: boolean) => void;
6
+ /** Edge the sheet slides in from (default: right) */
7
+ side?: 'left' | 'right' | 'bottom';
8
+ /** Width of a left/right sheet from `sm` up; phones always get the full width */
9
+ size?: 'sm' | 'md' | 'lg';
10
+ /** Heading, also the sheet's accessible name */
11
+ title: string;
12
+ /**
13
+ * Hide the heading row (the title still names the sheet for screen
14
+ * readers) when the content brings its own header. The close button moves
15
+ * into the grab bar (bottom) or the top-right corner (left/right).
16
+ */
17
+ hideTitle?: boolean;
18
+ description?: string;
19
+ /** Buttons beside the close button */
20
+ headerActions?: Snippet;
21
+ /** Pinned footer, e.g. the sheet's primary action */
22
+ footer?: Snippet;
23
+ children: Snippet;
24
+ class?: string;
25
+ }
26
+ declare const Sheet: import("svelte").Component<Props, {}, "open">;
27
+ type Sheet = ReturnType<typeof Sheet>;
28
+ export default Sheet;