@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.
- package/dist/__tests__/dropdown-trigger.test.js +9 -0
- package/dist/__tests__/sidebar-mobile.test.js +13 -0
- package/dist/__tests__/studio.test.js +223 -0
- package/dist/lib/components/DropdownMenu.svelte +17 -0
- package/dist/lib/components/DropdownMenu.svelte.d.ts +5 -0
- package/dist/lib/components/Sheet.svelte +155 -0
- package/dist/lib/components/Sheet.svelte.d.ts +28 -0
- package/dist/lib/components/SortableList.svelte +251 -0
- package/dist/lib/components/SortableList.svelte.d.ts +64 -0
- package/dist/lib/components/layout/DashboardLayout.svelte +34 -12
- package/dist/lib/components/layout/sidebar/SidebarNavItem.svelte +6 -2
- package/dist/lib/components/layout/sidebar/SidebarSection.svelte +5 -3
- package/dist/lib/components/studio/InsertPalette.svelte +192 -0
- package/dist/lib/components/studio/InsertPalette.svelte.d.ts +39 -0
- package/dist/lib/components/studio/IssuesButton.svelte +141 -0
- package/dist/lib/components/studio/IssuesButton.svelte.d.ts +33 -0
- package/dist/lib/components/studio/SaveStatus.svelte +110 -0
- package/dist/lib/components/studio/SaveStatus.svelte.d.ts +22 -0
- package/dist/lib/components/studio/Studio.svelte +189 -0
- package/dist/lib/components/studio/Studio.svelte.d.ts +49 -0
- package/dist/lib/components/studio/StudioBar.svelte +168 -0
- package/dist/lib/components/studio/StudioBar.svelte.d.ts +46 -0
- package/dist/lib/components/studio/StudioCanvas.svelte +87 -0
- package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +30 -0
- package/dist/lib/components/studio/StudioCanvasItem.svelte +121 -0
- package/dist/lib/components/studio/StudioCanvasItem.svelte.d.ts +29 -0
- package/dist/lib/components/studio/StudioInspector.svelte +133 -0
- package/dist/lib/components/studio/StudioInspector.svelte.d.ts +37 -0
- package/dist/lib/components/studio/StudioInspectorSection.svelte +79 -0
- package/dist/lib/components/studio/StudioInspectorSection.svelte.d.ts +24 -0
- package/dist/lib/components/studio/StudioOutline.svelte +303 -0
- package/dist/lib/components/studio/StudioOutline.svelte.d.ts +55 -0
- package/dist/lib/components/studio/StudioPage.svelte +36 -0
- package/dist/lib/components/studio/StudioPage.svelte.d.ts +18 -0
- package/dist/lib/components/studio/context.d.ts +25 -0
- package/dist/lib/components/studio/context.js +9 -0
- package/dist/lib/components/studio/index.d.ts +12 -0
- package/dist/lib/components/studio/index.js +12 -0
- package/dist/lib/index.d.ts +3 -0
- package/dist/lib/index.js +6 -0
- package/dist/lib/schemas/common.d.ts +2 -2
- package/dist/lib/stores/sidebar.svelte.d.ts +6 -0
- package/dist/lib/stores/sidebar.svelte.js +13 -0
- 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
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
-
//
|
|
183
|
-
//
|
|
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=
|
|
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
|
-
|
|
301
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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;
|