@classic-homes/theme-svelte 1.18.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/__tests__/studio.test.js +240 -0
  2. package/dist/lib/components/Sheet.svelte +155 -0
  3. package/dist/lib/components/Sheet.svelte.d.ts +28 -0
  4. package/dist/lib/components/SortableList.svelte +251 -0
  5. package/dist/lib/components/SortableList.svelte.d.ts +64 -0
  6. package/dist/lib/components/studio/InsertPalette.svelte +192 -0
  7. package/dist/lib/components/studio/InsertPalette.svelte.d.ts +39 -0
  8. package/dist/lib/components/studio/IssuesButton.svelte +141 -0
  9. package/dist/lib/components/studio/IssuesButton.svelte.d.ts +33 -0
  10. package/dist/lib/components/studio/SaveStatus.svelte +110 -0
  11. package/dist/lib/components/studio/SaveStatus.svelte.d.ts +22 -0
  12. package/dist/lib/components/studio/Studio.svelte +189 -0
  13. package/dist/lib/components/studio/Studio.svelte.d.ts +49 -0
  14. package/dist/lib/components/studio/StudioBar.svelte +168 -0
  15. package/dist/lib/components/studio/StudioBar.svelte.d.ts +46 -0
  16. package/dist/lib/components/studio/StudioCanvas.svelte +87 -0
  17. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +30 -0
  18. package/dist/lib/components/studio/StudioCanvasItem.svelte +129 -0
  19. package/dist/lib/components/studio/StudioCanvasItem.svelte.d.ts +29 -0
  20. package/dist/lib/components/studio/StudioInspector.svelte +133 -0
  21. package/dist/lib/components/studio/StudioInspector.svelte.d.ts +37 -0
  22. package/dist/lib/components/studio/StudioInspectorSection.svelte +79 -0
  23. package/dist/lib/components/studio/StudioInspectorSection.svelte.d.ts +24 -0
  24. package/dist/lib/components/studio/StudioOutline.svelte +303 -0
  25. package/dist/lib/components/studio/StudioOutline.svelte.d.ts +55 -0
  26. package/dist/lib/components/studio/StudioPage.svelte +36 -0
  27. package/dist/lib/components/studio/StudioPage.svelte.d.ts +18 -0
  28. package/dist/lib/components/studio/context.d.ts +25 -0
  29. package/dist/lib/components/studio/context.js +9 -0
  30. package/dist/lib/components/studio/index.d.ts +12 -0
  31. package/dist/lib/components/studio/index.js +12 -0
  32. package/dist/lib/index.d.ts +3 -0
  33. package/dist/lib/index.js +6 -0
  34. package/dist/lib/schemas/common.d.ts +2 -2
  35. package/package.json +1 -1
@@ -0,0 +1,240 @@
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
+ });
224
+ describe('StudioCanvasItem quick actions', () => {
225
+ it('does not select the item when focus lands on its "+" (a closing dialog returns it there)', () => {
226
+ const onSelect = vi.fn();
227
+ const el = render(StudioCanvasItem, {
228
+ label: 'Question 1',
229
+ onSelect,
230
+ onInsert: () => { },
231
+ children: html('<p>Body</p>'),
232
+ });
233
+ el.querySelector('button[aria-label="Add below Question 1"]').focus();
234
+ flushSync();
235
+ expect(onSelect).not.toHaveBeenCalled();
236
+ el.querySelector('button').focus(); // the "Edit Question 1" button
237
+ flushSync();
238
+ expect(onSelect).toHaveBeenCalledTimes(1);
239
+ });
240
+ });
@@ -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;
@@ -0,0 +1,251 @@
1
+ <script lang="ts" module>
2
+ /** Attributes to spread on the element that acts as an item's drag handle */
3
+ export interface SortableHandleProps {
4
+ type: 'button';
5
+ 'aria-label': string;
6
+ 'aria-roledescription': string;
7
+ 'aria-describedby': string;
8
+ 'aria-pressed': boolean;
9
+ 'data-sortable-handle': string;
10
+ onpointerdown: (event: PointerEvent) => void;
11
+ onpointermove: (event: PointerEvent) => void;
12
+ onpointerup: (event: PointerEvent) => void;
13
+ onpointercancel: (event: PointerEvent) => void;
14
+ onkeydown: (event: KeyboardEvent) => void;
15
+ onblur: () => void;
16
+ }
17
+
18
+ export interface SortableItemContext<T> {
19
+ item: T;
20
+ index: number;
21
+ /** Spread on a `<button>` inside the item: `<button {...handle}>` */
22
+ handle: SortableHandleProps;
23
+ /** The item is being dragged or is picked up by the keyboard */
24
+ dragging: boolean;
25
+ }
26
+ </script>
27
+
28
+ <script lang="ts" generics="T">
29
+ import { tick, type Snippet } from 'svelte';
30
+ import { cn } from '../utils.js';
31
+
32
+ /**
33
+ * SortableList - a vertical list the user can reorder by drag or keyboard.
34
+ *
35
+ * Each item renders its own drag handle by spreading `handle` on a button.
36
+ * Pointer: press and drag the handle. Keyboard: focus the handle, press
37
+ * Space or Enter to pick the item up, move it with the arrow keys, press
38
+ * Space/Enter again to drop or Escape to put it back. Alt+Arrow moves an
39
+ * item in one step without picking it up. Every move is announced.
40
+ *
41
+ * The list never reorders `items` itself: it calls `onReorder(from, to)`
42
+ * and expects the parent to update the array.
43
+ */
44
+ interface Props {
45
+ items: T[];
46
+ /** Stable key for each item */
47
+ getKey: (item: T, index: number) => string | number;
48
+ /** Move the item at `from` so it ends up at index `to` */
49
+ onReorder: (from: number, to: number) => void;
50
+ /** Renders one item; spread `handle` on its drag-handle button */
51
+ item: Snippet<[SortableItemContext<T>]>;
52
+ /** Accessible name of the list, e.g. "Questions" */
53
+ label: string;
54
+ /** Name used in announcements and handle labels (default: the 1-based position) */
55
+ getItemLabel?: (item: T, index: number) => string;
56
+ /** Disables reordering; handles stay rendered but do nothing */
57
+ disabled?: boolean;
58
+ class?: string;
59
+ itemClass?: string;
60
+ }
61
+
62
+ let {
63
+ items,
64
+ getKey,
65
+ onReorder,
66
+ item: itemSnippet,
67
+ label,
68
+ getItemLabel = (_item: T, index: number) => `Item ${index + 1}`,
69
+ disabled = false,
70
+ class: className,
71
+ itemClass,
72
+ }: Props = $props();
73
+
74
+ const uid = $props.id();
75
+ const instructionsId = `sortable-${uid}-help`;
76
+
77
+ let listEl: HTMLUListElement | undefined = $state();
78
+ let announcement = $state('');
79
+
80
+ // Pointer drag state
81
+ let dragFrom = $state<number | null>(null);
82
+ let dragOver = $state<number | null>(null);
83
+ let dragOffset = $state(0);
84
+ let startY = 0;
85
+ let rects: DOMRect[] = [];
86
+
87
+ // Keyboard pick-up state. Tracked by position (keys may be indexes), with
88
+ // the starting position kept for Escape.
89
+ let grabbed = $state<number | null>(null);
90
+ let grabbedFrom = 0;
91
+ // Set while a keyboard move re-renders the list, so the blur it causes
92
+ // doesn't drop the item
93
+ let moving = false;
94
+
95
+ function announce(message: string) {
96
+ // Re-set so repeated identical messages are announced again
97
+ announcement = '';
98
+ queueMicrotask(() => (announcement = message));
99
+ }
100
+
101
+ function positionText(index: number) {
102
+ return `position ${index + 1} of ${items.length}`;
103
+ }
104
+
105
+ // By position rather than key: the moved item is at its new index whatever
106
+ // the parent keys it by.
107
+ async function focusHandle(index: number) {
108
+ await tick();
109
+ listEl?.querySelectorAll<HTMLElement>(':scope > li [data-sortable-handle]')[index]?.focus();
110
+ }
111
+
112
+ async function move(from: number, to: number, keepFocus = true) {
113
+ if (to < 0 || to >= items.length || from === to) return;
114
+ const name = getItemLabel(items[from], from);
115
+ moving = true;
116
+ onReorder(from, to);
117
+ if (grabbed === from) grabbed = to;
118
+ announce(`${name} moved to ${positionText(to)}.`);
119
+ if (keepFocus) await focusHandle(to);
120
+ moving = false;
121
+ }
122
+
123
+ function itemShift(index: number): number {
124
+ if (dragFrom === null || dragOver === null) return 0;
125
+ if (index === dragFrom) return dragOffset;
126
+ const height = rects[dragFrom]?.height ?? 0;
127
+ const gap = rects.length > 1 ? Math.max(0, rects[1].top - rects[0].bottom) : 0;
128
+ if (dragFrom < dragOver && index > dragFrom && index <= dragOver) return -(height + gap);
129
+ if (dragFrom > dragOver && index >= dragOver && index < dragFrom) return height + gap;
130
+ return 0;
131
+ }
132
+
133
+ function endDrag(commit: boolean) {
134
+ const from = dragFrom;
135
+ const to = dragOver;
136
+ dragFrom = null;
137
+ dragOver = null;
138
+ dragOffset = 0;
139
+ rects = [];
140
+ if (commit && from !== null && to !== null && from !== to) void move(from, to, false);
141
+ }
142
+
143
+ function handleProps(index: number, key: string | number, name: string): SortableHandleProps {
144
+ return {
145
+ type: 'button',
146
+ 'aria-label': `Reorder ${name}`,
147
+ 'aria-roledescription': 'sortable handle',
148
+ 'aria-describedby': instructionsId,
149
+ 'aria-pressed': grabbed === index,
150
+ 'data-sortable-handle': String(key),
151
+ onpointerdown(event) {
152
+ if (disabled || event.button !== 0 || !listEl) return;
153
+ event.preventDefault();
154
+ // Capture on the handle only: capturing on the list would swallow clicks
155
+ // on the rest of the item.
156
+ (event.currentTarget as HTMLElement).setPointerCapture?.(event.pointerId);
157
+ rects = Array.from(listEl.children, (child) => child.getBoundingClientRect());
158
+ startY = event.clientY;
159
+ dragFrom = index;
160
+ dragOver = index;
161
+ dragOffset = 0;
162
+ },
163
+ onpointermove(event) {
164
+ if (dragFrom === null) return;
165
+ dragOffset = event.clientY - startY;
166
+ const origin = rects[dragFrom];
167
+ const center = origin.top + origin.height / 2 + dragOffset;
168
+ // The new index is the number of other items whose midpoint is above
169
+ // the dragged item's midpoint.
170
+ dragOver = rects.filter(
171
+ (rect, i) => i !== dragFrom && rect.top + rect.height / 2 < center
172
+ ).length;
173
+ },
174
+ onpointerup() {
175
+ endDrag(true);
176
+ },
177
+ onpointercancel() {
178
+ endDrag(false);
179
+ },
180
+ onkeydown(event) {
181
+ if (disabled) return;
182
+ const picked = grabbed === index;
183
+ if (event.key === ' ' || event.key === 'Enter') {
184
+ event.preventDefault();
185
+ if (picked) {
186
+ grabbed = null;
187
+ announce(`${name} dropped at ${positionText(index)}.`);
188
+ } else {
189
+ grabbed = index;
190
+ grabbedFrom = index;
191
+ announce(`${name} picked up at ${positionText(index)}. Use the arrow keys to move it.`);
192
+ }
193
+ } else if (event.key === 'Escape' && picked) {
194
+ event.preventDefault();
195
+ event.stopPropagation();
196
+ grabbed = null;
197
+ if (index !== grabbedFrom) {
198
+ moving = true;
199
+ onReorder(index, grabbedFrom);
200
+ void focusHandle(grabbedFrom).then(() => (moving = false));
201
+ }
202
+ announce(`Reorder cancelled. ${name} is back at ${positionText(grabbedFrom)}.`);
203
+ } else if (
204
+ (picked || event.altKey) &&
205
+ (event.key === 'ArrowUp' || event.key === 'ArrowDown')
206
+ ) {
207
+ event.preventDefault();
208
+ void move(index, event.key === 'ArrowUp' ? index - 1 : index + 1);
209
+ } else if (picked && (event.key === 'Home' || event.key === 'End')) {
210
+ event.preventDefault();
211
+ void move(index, event.key === 'Home' ? 0 : items.length - 1);
212
+ }
213
+ },
214
+ onblur() {
215
+ // Losing focus while picked up drops the item where it is
216
+ if (!moving && grabbed === index) grabbed = null;
217
+ },
218
+ };
219
+ }
220
+ </script>
221
+
222
+ <ul bind:this={listEl} aria-label={label} class={cn('relative', className)}>
223
+ {#each items as entry, index (getKey(entry, index))}
224
+ {@const key = getKey(entry, index)}
225
+ {@const dragging = dragFrom === index || grabbed === index}
226
+ {@const shift = itemShift(index)}
227
+ <li
228
+ class={cn(
229
+ 'relative',
230
+ dragFrom !== null && dragFrom !== index && 'transition-transform duration-150',
231
+ dragFrom === index && 'z-10 opacity-90 shadow-lg',
232
+ itemClass
233
+ )}
234
+ style:transform={shift ? `translateY(${shift}px)` : undefined}
235
+ data-dragging={dragging || undefined}
236
+ >
237
+ {@render itemSnippet({
238
+ item: entry,
239
+ index,
240
+ handle: handleProps(index, key, getItemLabel(entry, index)),
241
+ dragging,
242
+ })}
243
+ </li>
244
+ {/each}
245
+ </ul>
246
+
247
+ <p id={instructionsId} class="sr-only">
248
+ Press Space or Enter to pick up, use the arrow keys to move, Space or Enter to drop, Escape to
249
+ cancel.
250
+ </p>
251
+ <div class="sr-only" role="status" aria-live="assertive" aria-atomic="true">{announcement}</div>