@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
@@ -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>
@@ -0,0 +1,64 @@
1
+ /** Attributes to spread on the element that acts as an item's drag handle */
2
+ export interface SortableHandleProps {
3
+ type: 'button';
4
+ 'aria-label': string;
5
+ 'aria-roledescription': string;
6
+ 'aria-describedby': string;
7
+ 'aria-pressed': boolean;
8
+ 'data-sortable-handle': string;
9
+ onpointerdown: (event: PointerEvent) => void;
10
+ onpointermove: (event: PointerEvent) => void;
11
+ onpointerup: (event: PointerEvent) => void;
12
+ onpointercancel: (event: PointerEvent) => void;
13
+ onkeydown: (event: KeyboardEvent) => void;
14
+ onblur: () => void;
15
+ }
16
+ export interface SortableItemContext<T> {
17
+ item: T;
18
+ index: number;
19
+ /** Spread on a `<button>` inside the item: `<button {...handle}>` */
20
+ handle: SortableHandleProps;
21
+ /** The item is being dragged or is picked up by the keyboard */
22
+ dragging: boolean;
23
+ }
24
+ import { type Snippet } from 'svelte';
25
+ declare function $$render<T>(): {
26
+ props: {
27
+ items: T[];
28
+ /** Stable key for each item */
29
+ getKey: (item: T, index: number) => string | number;
30
+ /** Move the item at `from` so it ends up at index `to` */
31
+ onReorder: (from: number, to: number) => void;
32
+ /** Renders one item; spread `handle` on its drag-handle button */
33
+ item: Snippet<[SortableItemContext<T>]>;
34
+ /** Accessible name of the list, e.g. "Questions" */
35
+ label: string;
36
+ /** Name used in announcements and handle labels (default: the 1-based position) */
37
+ getItemLabel?: (item: T, index: number) => string;
38
+ /** Disables reordering; handles stay rendered but do nothing */
39
+ disabled?: boolean;
40
+ class?: string;
41
+ itemClass?: string;
42
+ };
43
+ exports: {};
44
+ bindings: "";
45
+ slots: {};
46
+ events: {};
47
+ };
48
+ declare class __sveltets_Render<T> {
49
+ props(): ReturnType<typeof $$render<T>>['props'];
50
+ events(): ReturnType<typeof $$render<T>>['events'];
51
+ slots(): ReturnType<typeof $$render<T>>['slots'];
52
+ bindings(): "";
53
+ exports(): {};
54
+ }
55
+ interface $$IsomorphicComponent {
56
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
57
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
58
+ } & ReturnType<__sveltets_Render<T>['exports']>;
59
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
60
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
61
+ }
62
+ declare const SortableList: $$IsomorphicComponent;
63
+ type SortableList<T> = InstanceType<typeof SortableList<T>>;
64
+ export default SortableList;
@@ -165,11 +165,10 @@
165
165
  : navigation
166
166
  );
167
167
 
168
- // Reactive media query for mobile detection. Initialized synchronously so
169
- // the first client paint doesn't flash the desktop layout (256px content
170
- // margin + full-width sidebar) before an effect corrects it. Deliberately
171
- // reads the initial `mobileBreakpoint` — later changes are handled by the
172
- // matchMedia effect below.
168
+ // Reactive media query for mobile detection. Initialized synchronously so a
169
+ // client-only render paints the right layout at once. Deliberately reads the
170
+ // initial `mobileBreakpoint` — later changes are handled by the matchMedia
171
+ // effect below.
173
172
  // svelte-ignore state_referenced_locally
174
173
  const initialIsMobile =
175
174
  typeof window !== 'undefined' &&
@@ -179,12 +178,23 @@
179
178
  sidebarStore.setMobile(initialIsMobile);
180
179
  }
181
180
 
182
- // Suppress the margin transition until after the first paint so any
183
- // hydration correction snaps instead of visibly sliding.
181
+ // Until mounted the layout is responsive in CSS alone: the server cannot know
182
+ // the viewport, so a JS-sized layout would render as desktop and visibly snap
183
+ // to mobile on hydration. Server HTML and the hydrating render therefore share
184
+ // breakpoint classes; once mounted, JS state (collapse, drawer) takes over.
184
185
  let mounted = $state(false);
186
+ // One frame later still: lets the hand-over to JS sizing (e.g. a saved collapsed
187
+ // sidebar) snap instead of sliding.
188
+ let animate = $state(false);
185
189
 
186
190
  $effect(() => {
187
191
  mounted = true;
192
+ if (typeof requestAnimationFrame !== 'function') {
193
+ animate = true;
194
+ return;
195
+ }
196
+ const frame = requestAnimationFrame(() => (animate = true));
197
+ return () => cancelAnimationFrame(frame);
188
198
  });
189
199
 
190
200
  $effect(() => {
@@ -252,6 +262,14 @@
252
262
  isMobile ? 0 : sidebarStore.isOpen ? expandedWidth : collapsedWidth
253
263
  );
254
264
 
265
+ // Pre-mount responsive classes, per breakpoint (literal strings, for Tailwind)
266
+ const ssrClasses: Record<Breakpoint, { content: string; sidebar: string }> = {
267
+ sm: { content: 'sm:ml-[var(--dl-sidebar-width)]', sidebar: 'max-sm:-translate-x-full' },
268
+ md: { content: 'md:ml-[var(--dl-sidebar-width)]', sidebar: 'max-md:-translate-x-full' },
269
+ lg: { content: 'lg:ml-[var(--dl-sidebar-width)]', sidebar: 'max-lg:-translate-x-full' },
270
+ };
271
+ const ssr = $derived(ssrClasses[mobileBreakpoint]);
272
+
255
273
  const contentPaddingClass = $derived(
256
274
  contentPadding === 'none' ? '' : contentPadding === 'compact' ? 'p-2 lg:p-4' : 'p-4 lg:p-6'
257
275
  );
@@ -286,21 +304,25 @@
286
304
  {onPinnedChange}
287
305
  {pinIcon}
288
306
  {appId}
307
+ class={mounted ? undefined : ssr.sidebar}
289
308
  />
290
309
 
291
310
  <!-- Main Content Area -->
292
311
  <div
293
- class="flex flex-1 flex-col {mounted
312
+ class="flex flex-1 flex-col {mounted ? '' : ssr.content} {animate
294
313
  ? 'transition-[margin] duration-300 motion-reduce:transition-none'
295
314
  : ''}"
296
- style="margin-left: {sidebarWidth}px;"
315
+ style={mounted
316
+ ? `margin-left: ${sidebarWidth}px;`
317
+ : `--dl-sidebar-width: ${sidebarCollapsed ? collapsedWidth : expandedWidth}px;`}
297
318
  >
298
319
  {#if showHeader}
299
320
  <Header
300
- showMenuButton={isMobile}
301
- menuOpen={sidebarStore.isOpen}
321
+ {mobileBreakpoint}
322
+ showMenuButton={isMobile || !mounted}
323
+ menuOpen={mounted && isMobile && sidebarStore.isOpen}
302
324
  onMenuClick={() => sidebarStore.toggle()}
303
- showCollapseButton={!isMobile}
325
+ showCollapseButton={!isMobile || !mounted}
304
326
  sidebarCollapsed={!sidebarStore.isOpen}
305
327
  onCollapseClick={() => sidebarStore.toggle()}
306
328
  title={pageTitle}
@@ -346,7 +346,8 @@
346
346
  >
347
347
  <svg
348
348
  class={cn(
349
- 'h-4 w-4 transition-transform duration-200',
349
+ 'h-4 w-4',
350
+ sidebarStore.animate && 'transition-transform duration-200',
350
351
  isExpanded ? 'rotate-0' : '-rotate-90'
351
352
  )}
352
353
  fill="none"
@@ -371,7 +372,10 @@
371
372
  </div>
372
373
 
373
374
  {#if isExpanded}
374
- <ul class="mt-1 space-y-1 pl-4 overflow-hidden" transition:slide={{ duration: 200 }}>
375
+ <ul
376
+ class="mt-1 space-y-1 pl-4 overflow-hidden"
377
+ transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
378
+ >
375
379
  {#each item.children ?? [] as child (child.id)}
376
380
  <li>
377
381
  {#if child.children?.length}
@@ -78,7 +78,8 @@
78
78
  <button
79
79
  onclick={toggleSection}
80
80
  class={cn(
81
- 'flex w-full items-center justify-between mb-2 px-4 text-xs font-semibold uppercase tracking-wider rounded-md overflow-hidden',
81
+ // min-h-8: a 32px target (WCAG 2.5.8 asks 24px); the text-xs line alone is 16px
82
+ 'flex min-h-8 w-full items-center justify-between mb-1 px-4 text-xs font-semibold uppercase tracking-wider rounded-md overflow-hidden',
82
83
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
83
84
  isLight
84
85
  ? 'text-muted-foreground hover:text-foreground'
@@ -90,7 +91,8 @@
90
91
  <span class="truncate">{section.title}</span>
91
92
  <svg
92
93
  class={cn(
93
- 'h-4 w-4 shrink-0 transition-transform duration-200',
94
+ 'h-4 w-4 shrink-0',
95
+ sidebarStore.animate && 'transition-transform duration-200',
94
96
  isExpanded ? 'rotate-0' : '-rotate-90'
95
97
  )}
96
98
  fill="none"
@@ -147,7 +149,7 @@
147
149
  <ul
148
150
  class="space-y-1 px-2 overflow-hidden"
149
151
  id={`sidebar-section-${section.id}`}
150
- transition:slide={{ duration: 200 }}
152
+ transition:slide={{ duration: sidebarStore.animate ? 200 : 0 }}
151
153
  >
152
154
  {#each section.items as item}
153
155
  <li>
@@ -0,0 +1,192 @@
1
+ <script lang="ts" module>
2
+ import type { IconName } from '../Icon.svelte';
3
+
4
+ export interface InsertPaletteItem {
5
+ id: string;
6
+ label: string;
7
+ /** One line on when to use it: "Pick one answer from a short list" */
8
+ description?: string;
9
+ icon?: IconName;
10
+ disabled?: boolean;
11
+ /** Extra search terms, e.g. ["radio", "single"] */
12
+ keywords?: string[];
13
+ }
14
+
15
+ export interface InsertPaletteGroup {
16
+ label: string;
17
+ items: InsertPaletteItem[];
18
+ }
19
+ </script>
20
+
21
+ <script lang="ts">
22
+ import { tick, type Snippet } from 'svelte';
23
+ import { cn } from '../../utils.js';
24
+ import Dialog from '../Dialog.svelte';
25
+ import Icon from '../Icon.svelte';
26
+
27
+ /**
28
+ * InsertPalette - the one way to add something to a Studio: a searchable,
29
+ * grouped picker of question types, slide layouts, blocks...
30
+ *
31
+ * Every add button (rail, canvas "+", empty state) opens the same palette,
32
+ * so there is one place to learn. Each entry says when to use it; pass
33
+ * `thumbnail` to show a picture of it (slide layouts). Type to filter,
34
+ * Enter adds the first match, arrow keys move between entries.
35
+ */
36
+ interface Props {
37
+ open?: boolean;
38
+ groups: InsertPaletteGroup[];
39
+ onSelect: (id: string) => void;
40
+ /** Dialog title (default: "Add") */
41
+ title?: string;
42
+ description?: string;
43
+ searchPlaceholder?: string;
44
+ /** Picture per entry; switches to a grid of cards */
45
+ thumbnail?: Snippet<[InsertPaletteItem]>;
46
+ }
47
+
48
+ let {
49
+ open = $bindable(false),
50
+ groups,
51
+ onSelect,
52
+ title = 'Add',
53
+ description,
54
+ searchPlaceholder = 'Search',
55
+ thumbnail,
56
+ }: Props = $props();
57
+
58
+ let query = $state('');
59
+ let listEl: HTMLDivElement | undefined = $state();
60
+ let searchEl: HTMLInputElement | undefined = $state();
61
+
62
+ const filtered = $derived.by(() => {
63
+ const q = query.trim().toLowerCase();
64
+ if (!q) return groups;
65
+ return groups
66
+ .map((group) => ({
67
+ ...group,
68
+ items: group.items.filter((item) =>
69
+ [item.label, item.description ?? '', ...(item.keywords ?? [])].some((text) =>
70
+ text.toLowerCase().includes(q)
71
+ )
72
+ ),
73
+ }))
74
+ .filter((group) => group.items.length > 0);
75
+ });
76
+
77
+ const firstMatch = $derived(filtered.flatMap((g) => g.items).find((item) => !item.disabled));
78
+
79
+ $effect(() => {
80
+ if (!open) return;
81
+ query = '';
82
+ void tick().then(() => searchEl?.focus());
83
+ });
84
+
85
+ function choose(item: InsertPaletteItem) {
86
+ if (item.disabled) return;
87
+ open = false;
88
+ onSelect(item.id);
89
+ }
90
+
91
+ function entries() {
92
+ return Array.from(listEl?.querySelectorAll<HTMLButtonElement>('[data-palette-item]') ?? []);
93
+ }
94
+
95
+ function searchKeydown(event: KeyboardEvent) {
96
+ if (event.key === 'Enter' && firstMatch) {
97
+ event.preventDefault();
98
+ choose(firstMatch);
99
+ } else if (event.key === 'ArrowDown') {
100
+ event.preventDefault();
101
+ entries()[0]?.focus();
102
+ }
103
+ }
104
+
105
+ function entryKeydown(event: KeyboardEvent) {
106
+ const all = entries();
107
+ const index = all.indexOf(event.currentTarget as HTMLButtonElement);
108
+ let next = -1;
109
+ if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = index + 1;
110
+ else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = index - 1;
111
+ else return;
112
+ event.preventDefault();
113
+ if (next < 0) searchEl?.focus();
114
+ else all[Math.min(next, all.length - 1)]?.focus();
115
+ }
116
+ </script>
117
+
118
+ <Dialog bind:open {title} {description} size={thumbnail ? '2xl' : 'xl'}>
119
+ <div class="space-y-4 pb-4">
120
+ <div class="relative">
121
+ <Icon
122
+ name="search"
123
+ size={16}
124
+ class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
125
+ aria-hidden="true"
126
+ />
127
+ <input
128
+ bind:this={searchEl}
129
+ bind:value={query}
130
+ type="search"
131
+ aria-label={searchPlaceholder}
132
+ placeholder={searchPlaceholder}
133
+ class="h-10 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
134
+ onkeydown={searchKeydown}
135
+ />
136
+ </div>
137
+
138
+ <div bind:this={listEl} class="space-y-5">
139
+ {#each filtered as group (group.label)}
140
+ <section aria-label={group.label}>
141
+ <h3 class="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
142
+ {group.label}
143
+ </h3>
144
+ <ul
145
+ class={cn(
146
+ 'grid gap-2',
147
+ thumbnail ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-1 sm:grid-cols-2'
148
+ )}
149
+ >
150
+ {#each group.items as item (item.id)}
151
+ <li>
152
+ <button
153
+ type="button"
154
+ data-palette-item
155
+ disabled={item.disabled}
156
+ class={cn(
157
+ 'flex h-full w-full gap-3 rounded-md border border-border p-3 text-left hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
158
+ thumbnail ? 'flex-col' : 'items-start'
159
+ )}
160
+ onclick={() => choose(item)}
161
+ onkeydown={entryKeydown}
162
+ >
163
+ {#if thumbnail}
164
+ <span class="block overflow-hidden rounded border border-border bg-muted">
165
+ {@render thumbnail(item)}
166
+ </span>
167
+ {:else if item.icon}
168
+ <span
169
+ class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-foreground"
170
+ >
171
+ <Icon name={item.icon} size={16} aria-hidden="true" />
172
+ </span>
173
+ {/if}
174
+ <span class="min-w-0">
175
+ <span class="block text-sm font-medium">{item.label}</span>
176
+ {#if item.description}
177
+ <span class="mt-0.5 block text-xs text-muted-foreground">
178
+ {item.description}
179
+ </span>
180
+ {/if}
181
+ </span>
182
+ </button>
183
+ </li>
184
+ {/each}
185
+ </ul>
186
+ </section>
187
+ {:else}
188
+ <p class="py-8 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
189
+ {/each}
190
+ </div>
191
+ </div>
192
+ </Dialog>
@@ -0,0 +1,39 @@
1
+ import type { IconName } from '../Icon.svelte';
2
+ export interface InsertPaletteItem {
3
+ id: string;
4
+ label: string;
5
+ /** One line on when to use it: "Pick one answer from a short list" */
6
+ description?: string;
7
+ icon?: IconName;
8
+ disabled?: boolean;
9
+ /** Extra search terms, e.g. ["radio", "single"] */
10
+ keywords?: string[];
11
+ }
12
+ export interface InsertPaletteGroup {
13
+ label: string;
14
+ items: InsertPaletteItem[];
15
+ }
16
+ import { type Snippet } from 'svelte';
17
+ /**
18
+ * InsertPalette - the one way to add something to a Studio: a searchable,
19
+ * grouped picker of question types, slide layouts, blocks...
20
+ *
21
+ * Every add button (rail, canvas "+", empty state) opens the same palette,
22
+ * so there is one place to learn. Each entry says when to use it; pass
23
+ * `thumbnail` to show a picture of it (slide layouts). Type to filter,
24
+ * Enter adds the first match, arrow keys move between entries.
25
+ */
26
+ interface Props {
27
+ open?: boolean;
28
+ groups: InsertPaletteGroup[];
29
+ onSelect: (id: string) => void;
30
+ /** Dialog title (default: "Add") */
31
+ title?: string;
32
+ description?: string;
33
+ searchPlaceholder?: string;
34
+ /** Picture per entry; switches to a grid of cards */
35
+ thumbnail?: Snippet<[InsertPaletteItem]>;
36
+ }
37
+ declare const InsertPalette: import("svelte").Component<Props, {}, "open">;
38
+ type InsertPalette = ReturnType<typeof InsertPalette>;
39
+ export default InsertPalette;