@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,168 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils.js';
|
|
4
|
+
import Icon from '../Icon.svelte';
|
|
5
|
+
import DropdownMenu, {
|
|
6
|
+
type DropdownMenuItem,
|
|
7
|
+
type DropdownMenuGroup,
|
|
8
|
+
} from '../DropdownMenu.svelte';
|
|
9
|
+
import SegmentedControl, { type SegmentedControlOption } from '../SegmentedControl.svelte';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* StudioBar - the one bar across the top of a Studio.
|
|
13
|
+
*
|
|
14
|
+
* Left: back link and the document title, edited in place. Middle: the
|
|
15
|
+
* modes that separate building from configuring (e.g. Build · Settings ·
|
|
16
|
+
* Share). Right: save status, issues, the primary action, and one overflow
|
|
17
|
+
* menu for everything else (export, history, delete...). On phones the
|
|
18
|
+
* modes wrap onto their own row.
|
|
19
|
+
*/
|
|
20
|
+
interface Props {
|
|
21
|
+
/** Title of the document being edited (bindable) */
|
|
22
|
+
title?: string;
|
|
23
|
+
/** Called when an edited title is committed (blur or Enter) */
|
|
24
|
+
onTitleChange?: (title: string) => void;
|
|
25
|
+
/** Show the title as text instead of an input */
|
|
26
|
+
titleReadonly?: boolean;
|
|
27
|
+
titlePlaceholder?: string;
|
|
28
|
+
/** Small line under the title, e.g. "Survey · 5 questions" */
|
|
29
|
+
subtitle?: string;
|
|
30
|
+
/** Back link, e.g. to the list of surveys */
|
|
31
|
+
backHref?: string;
|
|
32
|
+
/** Accessible name of the back link (default: "Back") */
|
|
33
|
+
backLabel?: string;
|
|
34
|
+
/** Mode tabs; omit for single-mode editors */
|
|
35
|
+
modes?: SegmentedControlOption[];
|
|
36
|
+
/** Active mode (bindable) */
|
|
37
|
+
mode?: string;
|
|
38
|
+
onModeChange?: (mode: string) => void;
|
|
39
|
+
/** Save state, normally a `SaveStatus` */
|
|
40
|
+
status?: Snippet;
|
|
41
|
+
/** Issues, normally an `IssuesButton` */
|
|
42
|
+
issues?: Snippet;
|
|
43
|
+
/** Primary action(s), e.g. a Publish or Present button */
|
|
44
|
+
actions?: Snippet;
|
|
45
|
+
/** Overflow menu items (export, history, delete...) */
|
|
46
|
+
menuItems?: (DropdownMenuItem | DropdownMenuGroup)[];
|
|
47
|
+
/** Accessible name of the overflow menu (default: "More actions") */
|
|
48
|
+
menuLabel?: string;
|
|
49
|
+
class?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let {
|
|
53
|
+
title = $bindable(''),
|
|
54
|
+
onTitleChange,
|
|
55
|
+
titleReadonly = false,
|
|
56
|
+
titlePlaceholder = 'Untitled',
|
|
57
|
+
subtitle,
|
|
58
|
+
backHref,
|
|
59
|
+
backLabel = 'Back',
|
|
60
|
+
modes,
|
|
61
|
+
mode = $bindable(modes?.[0]?.value ?? ''),
|
|
62
|
+
onModeChange,
|
|
63
|
+
status,
|
|
64
|
+
issues,
|
|
65
|
+
actions,
|
|
66
|
+
menuItems,
|
|
67
|
+
menuLabel = 'More actions',
|
|
68
|
+
class: className,
|
|
69
|
+
}: Props = $props();
|
|
70
|
+
|
|
71
|
+
// Title value when editing began, so Escape can put it back
|
|
72
|
+
let titleAtFocus = '';
|
|
73
|
+
|
|
74
|
+
function commitTitle(input: HTMLInputElement) {
|
|
75
|
+
const next = input.value.trim();
|
|
76
|
+
if (!next) {
|
|
77
|
+
title = titleAtFocus;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (next !== titleAtFocus) onTitleChange?.(next);
|
|
81
|
+
titleAtFocus = next;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function titleKeydown(event: KeyboardEvent) {
|
|
85
|
+
const input = event.currentTarget as HTMLInputElement;
|
|
86
|
+
if (event.key === 'Enter') {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
input.blur();
|
|
89
|
+
} else if (event.key === 'Escape') {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
title = titleAtFocus;
|
|
92
|
+
input.value = titleAtFocus;
|
|
93
|
+
input.blur();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<header
|
|
99
|
+
class={cn(
|
|
100
|
+
'flex shrink-0 flex-wrap items-center gap-x-2 gap-y-2 border-b border-border bg-background px-2 py-2 md:h-14 md:flex-nowrap md:gap-x-3 md:px-3 md:py-0',
|
|
101
|
+
className
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
<div class="flex min-w-0 flex-1 items-center gap-1 md:basis-0">
|
|
105
|
+
{#if backHref}
|
|
106
|
+
<a
|
|
107
|
+
href={backHref}
|
|
108
|
+
class="flex h-9 w-9 shrink-0 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"
|
|
109
|
+
aria-label={backLabel}
|
|
110
|
+
title={backLabel}
|
|
111
|
+
>
|
|
112
|
+
<Icon name="arrow-left" size={18} aria-hidden="true" />
|
|
113
|
+
</a>
|
|
114
|
+
{/if}
|
|
115
|
+
<div class="min-w-0 flex-1">
|
|
116
|
+
<h1 class={titleReadonly ? 'truncate px-1.5 text-base font-semibold' : 'sr-only'}>
|
|
117
|
+
{title || titlePlaceholder}
|
|
118
|
+
</h1>
|
|
119
|
+
{#if !titleReadonly}
|
|
120
|
+
<input
|
|
121
|
+
bind:value={title}
|
|
122
|
+
aria-label="Title"
|
|
123
|
+
placeholder={titlePlaceholder}
|
|
124
|
+
class="block w-full max-w-md truncate rounded-md border border-transparent bg-transparent px-1.5 py-0.5 text-base font-semibold leading-tight outline-none hover:border-border focus:border-input focus:bg-background focus:ring-2 focus:ring-ring"
|
|
125
|
+
onfocus={(e) => (titleAtFocus = (e.currentTarget as HTMLInputElement).value)}
|
|
126
|
+
onblur={(e) => commitTitle(e.currentTarget as HTMLInputElement)}
|
|
127
|
+
onkeydown={titleKeydown}
|
|
128
|
+
/>
|
|
129
|
+
{/if}
|
|
130
|
+
{#if subtitle}
|
|
131
|
+
<p class="truncate px-1.5 text-xs text-muted-foreground">{subtitle}</p>
|
|
132
|
+
{/if}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{#if modes && modes.length > 1}
|
|
137
|
+
<div class="order-last flex w-full justify-center md:order-none md:w-auto">
|
|
138
|
+
<SegmentedControl
|
|
139
|
+
options={modes}
|
|
140
|
+
bind:value={mode}
|
|
141
|
+
onValueChange={onModeChange}
|
|
142
|
+
label="Editor mode"
|
|
143
|
+
size="sm"
|
|
144
|
+
class="w-full md:w-auto [&>*]:flex-1 md:[&>*]:flex-none"
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
{/if}
|
|
148
|
+
|
|
149
|
+
<div class="flex shrink-0 items-center justify-end gap-1 md:flex-1 md:basis-0 md:gap-2">
|
|
150
|
+
{#if status}{@render status()}{/if}
|
|
151
|
+
{#if issues}{@render issues()}{/if}
|
|
152
|
+
{#if actions}{@render actions()}{/if}
|
|
153
|
+
{#if menuItems && menuItems.length > 0}
|
|
154
|
+
<DropdownMenu
|
|
155
|
+
items={menuItems}
|
|
156
|
+
align="end"
|
|
157
|
+
triggerVariant="ghost"
|
|
158
|
+
triggerSize="icon"
|
|
159
|
+
triggerClass="h-9 w-9"
|
|
160
|
+
triggerLabel={menuLabel}
|
|
161
|
+
>
|
|
162
|
+
{#snippet trigger()}
|
|
163
|
+
<Icon name="more-horizontal" size={18} aria-hidden="true" />
|
|
164
|
+
{/snippet}
|
|
165
|
+
</DropdownMenu>
|
|
166
|
+
{/if}
|
|
167
|
+
</div>
|
|
168
|
+
</header>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type DropdownMenuItem, type DropdownMenuGroup } from '../DropdownMenu.svelte';
|
|
3
|
+
import { type SegmentedControlOption } from '../SegmentedControl.svelte';
|
|
4
|
+
/**
|
|
5
|
+
* StudioBar - the one bar across the top of a Studio.
|
|
6
|
+
*
|
|
7
|
+
* Left: back link and the document title, edited in place. Middle: the
|
|
8
|
+
* modes that separate building from configuring (e.g. Build · Settings ·
|
|
9
|
+
* Share). Right: save status, issues, the primary action, and one overflow
|
|
10
|
+
* menu for everything else (export, history, delete...). On phones the
|
|
11
|
+
* modes wrap onto their own row.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
/** Title of the document being edited (bindable) */
|
|
15
|
+
title?: string;
|
|
16
|
+
/** Called when an edited title is committed (blur or Enter) */
|
|
17
|
+
onTitleChange?: (title: string) => void;
|
|
18
|
+
/** Show the title as text instead of an input */
|
|
19
|
+
titleReadonly?: boolean;
|
|
20
|
+
titlePlaceholder?: string;
|
|
21
|
+
/** Small line under the title, e.g. "Survey · 5 questions" */
|
|
22
|
+
subtitle?: string;
|
|
23
|
+
/** Back link, e.g. to the list of surveys */
|
|
24
|
+
backHref?: string;
|
|
25
|
+
/** Accessible name of the back link (default: "Back") */
|
|
26
|
+
backLabel?: string;
|
|
27
|
+
/** Mode tabs; omit for single-mode editors */
|
|
28
|
+
modes?: SegmentedControlOption[];
|
|
29
|
+
/** Active mode (bindable) */
|
|
30
|
+
mode?: string;
|
|
31
|
+
onModeChange?: (mode: string) => void;
|
|
32
|
+
/** Save state, normally a `SaveStatus` */
|
|
33
|
+
status?: Snippet;
|
|
34
|
+
/** Issues, normally an `IssuesButton` */
|
|
35
|
+
issues?: Snippet;
|
|
36
|
+
/** Primary action(s), e.g. a Publish or Present button */
|
|
37
|
+
actions?: Snippet;
|
|
38
|
+
/** Overflow menu items (export, history, delete...) */
|
|
39
|
+
menuItems?: (DropdownMenuItem | DropdownMenuGroup)[];
|
|
40
|
+
/** Accessible name of the overflow menu (default: "More actions") */
|
|
41
|
+
menuLabel?: string;
|
|
42
|
+
class?: string;
|
|
43
|
+
}
|
|
44
|
+
declare const StudioBar: import("svelte").Component<Props, {}, "title" | "mode">;
|
|
45
|
+
type StudioBar = ReturnType<typeof StudioBar>;
|
|
46
|
+
export default StudioBar;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils.js';
|
|
4
|
+
import SegmentedControl from '../SegmentedControl.svelte';
|
|
5
|
+
import { getStudioContext } from './context.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* StudioCanvas - the middle of a Studio: the real thing, as people will see it.
|
|
9
|
+
*
|
|
10
|
+
* Scrolls on its own and centres its content on a quiet backdrop. With
|
|
11
|
+
* `deviceToggle`, a Desktop/Phone switch narrows the frame to phone width.
|
|
12
|
+
* Wrap each selectable part in a `StudioCanvasItem`, or, for content
|
|
13
|
+
* rendered elsewhere (an iframe), handle selection yourself.
|
|
14
|
+
*/
|
|
15
|
+
interface Props {
|
|
16
|
+
children: Snippet;
|
|
17
|
+
/** Show the Desktop/Phone switch */
|
|
18
|
+
deviceToggle?: boolean;
|
|
19
|
+
/** Previewed device (bindable) */
|
|
20
|
+
device?: 'desktop' | 'phone';
|
|
21
|
+
/** Width of the frame on desktop (default: 48rem, a readable column) */
|
|
22
|
+
maxWidth?: string;
|
|
23
|
+
/** Extra controls beside the device switch */
|
|
24
|
+
toolbar?: Snippet;
|
|
25
|
+
/** Accessible name of the canvas region (default: "Canvas") */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Clicked on the backdrop, outside any item: usually clears the selection */
|
|
28
|
+
onBackgroundClick?: () => void;
|
|
29
|
+
/** Classes for the frame that holds the content */
|
|
30
|
+
frameClass?: string;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let {
|
|
35
|
+
children,
|
|
36
|
+
deviceToggle = false,
|
|
37
|
+
device = $bindable('desktop'),
|
|
38
|
+
maxWidth = '48rem',
|
|
39
|
+
toolbar,
|
|
40
|
+
label = 'Canvas',
|
|
41
|
+
onBackgroundClick,
|
|
42
|
+
frameClass,
|
|
43
|
+
class: className,
|
|
44
|
+
}: Props = $props();
|
|
45
|
+
|
|
46
|
+
const studio = getStudioContext();
|
|
47
|
+
// On a phone the canvas already is phone width; the switch would do nothing
|
|
48
|
+
const showDevices = $derived(deviceToggle && studio?.layout !== 'compact');
|
|
49
|
+
const width = $derived(device === 'phone' && showDevices ? '390px' : maxWidth);
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<section aria-label={label} class={cn('flex h-full flex-col', className)}>
|
|
53
|
+
{#if showDevices || toolbar}
|
|
54
|
+
<div class="flex shrink-0 items-center justify-end gap-2 px-3 pt-3">
|
|
55
|
+
{#if toolbar}{@render toolbar()}{/if}
|
|
56
|
+
{#if showDevices}
|
|
57
|
+
<SegmentedControl
|
|
58
|
+
bind:value={device}
|
|
59
|
+
label="Preview size"
|
|
60
|
+
size="sm"
|
|
61
|
+
options={[
|
|
62
|
+
{ value: 'desktop', label: 'Desktop', icon: 'monitor' },
|
|
63
|
+
{ value: 'phone', label: 'Phone', icon: 'smartphone' },
|
|
64
|
+
]}
|
|
65
|
+
/>
|
|
66
|
+
{/if}
|
|
67
|
+
</div>
|
|
68
|
+
{/if}
|
|
69
|
+
|
|
70
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
71
|
+
<div
|
|
72
|
+
class="min-h-0 flex-1 overflow-y-auto px-3 pb-20 pt-3 md:px-6 md:pb-10"
|
|
73
|
+
onclick={(e) => {
|
|
74
|
+
if (e.target === e.currentTarget) onBackgroundClick?.();
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<div
|
|
78
|
+
class={cn(
|
|
79
|
+
'mx-auto w-full rounded-lg border border-border bg-background shadow-sm transition-[max-width] duration-200',
|
|
80
|
+
frameClass
|
|
81
|
+
)}
|
|
82
|
+
style:max-width={width}
|
|
83
|
+
>
|
|
84
|
+
{@render children()}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</section>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* StudioCanvas - the middle of a Studio: the real thing, as people will see it.
|
|
4
|
+
*
|
|
5
|
+
* Scrolls on its own and centres its content on a quiet backdrop. With
|
|
6
|
+
* `deviceToggle`, a Desktop/Phone switch narrows the frame to phone width.
|
|
7
|
+
* Wrap each selectable part in a `StudioCanvasItem`, or, for content
|
|
8
|
+
* rendered elsewhere (an iframe), handle selection yourself.
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
children: Snippet;
|
|
12
|
+
/** Show the Desktop/Phone switch */
|
|
13
|
+
deviceToggle?: boolean;
|
|
14
|
+
/** Previewed device (bindable) */
|
|
15
|
+
device?: 'desktop' | 'phone';
|
|
16
|
+
/** Width of the frame on desktop (default: 48rem, a readable column) */
|
|
17
|
+
maxWidth?: string;
|
|
18
|
+
/** Extra controls beside the device switch */
|
|
19
|
+
toolbar?: Snippet;
|
|
20
|
+
/** Accessible name of the canvas region (default: "Canvas") */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** Clicked on the backdrop, outside any item: usually clears the selection */
|
|
23
|
+
onBackgroundClick?: () => void;
|
|
24
|
+
/** Classes for the frame that holds the content */
|
|
25
|
+
frameClass?: string;
|
|
26
|
+
class?: string;
|
|
27
|
+
}
|
|
28
|
+
declare const StudioCanvas: import("svelte").Component<Props, {}, "device">;
|
|
29
|
+
type StudioCanvas = ReturnType<typeof StudioCanvas>;
|
|
30
|
+
export default StudioCanvas;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick, type Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils.js';
|
|
4
|
+
import Icon from '../Icon.svelte';
|
|
5
|
+
import { getStudioContext } from './context.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* StudioCanvasItem - one selectable part of the canvas (a question, a block).
|
|
9
|
+
*
|
|
10
|
+
* Clicking anywhere in it, or tabbing into it, selects it; the content stays
|
|
11
|
+
* live, so a previewed question can still be answered. The selected item
|
|
12
|
+
* gets a ring and its `toolbar` (duplicate, delete...). With `onInsert`, a
|
|
13
|
+
* "+" between this item and the next adds a new one right there. On phones
|
|
14
|
+
* picking an item opens the inspector sheet.
|
|
15
|
+
*/
|
|
16
|
+
interface Props {
|
|
17
|
+
children: Snippet;
|
|
18
|
+
selected?: boolean;
|
|
19
|
+
onSelect?: () => void;
|
|
20
|
+
/** Names the item for screen readers and its Edit button, e.g. "Question 2" */
|
|
21
|
+
label: string;
|
|
22
|
+
/** Quick actions shown on the selected item */
|
|
23
|
+
toolbar?: Snippet;
|
|
24
|
+
/** Shows a "+" below the item that inserts after it */
|
|
25
|
+
onInsert?: () => void;
|
|
26
|
+
/** Accessible name of the insert button (default: "Add below {label}") */
|
|
27
|
+
insertLabel?: string;
|
|
28
|
+
/** Marks the item as having problems */
|
|
29
|
+
invalid?: boolean;
|
|
30
|
+
class?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let {
|
|
34
|
+
children,
|
|
35
|
+
selected = false,
|
|
36
|
+
onSelect,
|
|
37
|
+
label,
|
|
38
|
+
toolbar,
|
|
39
|
+
onInsert,
|
|
40
|
+
insertLabel,
|
|
41
|
+
invalid = false,
|
|
42
|
+
class: className,
|
|
43
|
+
}: Props = $props();
|
|
44
|
+
|
|
45
|
+
const studio = getStudioContext();
|
|
46
|
+
let element: HTMLDivElement | undefined = $state();
|
|
47
|
+
|
|
48
|
+
function select(reveal: boolean) {
|
|
49
|
+
if (!selected) onSelect?.();
|
|
50
|
+
if (reveal) studio?.revealInspector();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
$effect(() => {
|
|
54
|
+
if (!selected || !element) return;
|
|
55
|
+
const el = element;
|
|
56
|
+
void tick().then(() => {
|
|
57
|
+
// Don't yank the canvas while the user is working inside the item
|
|
58
|
+
if (el.contains(document.activeElement)) return;
|
|
59
|
+
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
60
|
+
el.scrollIntoView?.({ block: 'nearest', behavior: reduce ? 'auto' : 'smooth' });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<div
|
|
66
|
+
bind:this={element}
|
|
67
|
+
role="group"
|
|
68
|
+
aria-label={label}
|
|
69
|
+
aria-current={selected ? 'true' : undefined}
|
|
70
|
+
data-selected={selected || undefined}
|
|
71
|
+
class={cn(
|
|
72
|
+
'group/item relative scroll-my-6 rounded-lg transition-shadow',
|
|
73
|
+
// Problems use a dashed amber outline: in brands whose primary is red, a
|
|
74
|
+
// red ring would read as "selected"
|
|
75
|
+
selected ? 'ring-2 ring-primary' : 'hover:ring-1 hover:ring-border',
|
|
76
|
+
invalid && 'outline-dashed outline-2 -outline-offset-2 outline-warning/70',
|
|
77
|
+
className
|
|
78
|
+
)}
|
|
79
|
+
onclickcapture={(e) => select(!(e.target as Element).closest?.('[data-studio-quick-action]'))}
|
|
80
|
+
onfocusin={() => select(false)}
|
|
81
|
+
>
|
|
82
|
+
{#if !selected}
|
|
83
|
+
<!-- Keyboard way in for items with no focusable content of their own -->
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
class="sr-only left-2 top-2 z-10 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground focus:not-sr-only focus:absolute"
|
|
87
|
+
onclick={() => select(true)}
|
|
88
|
+
>
|
|
89
|
+
Edit {label}
|
|
90
|
+
</button>
|
|
91
|
+
{/if}
|
|
92
|
+
|
|
93
|
+
{#if selected && toolbar}
|
|
94
|
+
<div
|
|
95
|
+
data-studio-quick-action
|
|
96
|
+
class="absolute -top-4 right-3 z-10 flex items-center gap-0.5 rounded-md border border-border bg-background p-0.5 shadow-md"
|
|
97
|
+
>
|
|
98
|
+
{@render toolbar()}
|
|
99
|
+
</div>
|
|
100
|
+
{/if}
|
|
101
|
+
|
|
102
|
+
{@render children()}
|
|
103
|
+
|
|
104
|
+
{#if onInsert}
|
|
105
|
+
<div class="relative h-0" data-studio-quick-action>
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
aria-label={insertLabel ?? `Add below ${label}`}
|
|
109
|
+
title={insertLabel ?? 'Add below'}
|
|
110
|
+
class="absolute left-1/2 top-0 z-10 flex h-7 w-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-border bg-background text-muted-foreground opacity-0 shadow-sm hover:border-primary hover:text-primary focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/item:opacity-100 [@media(hover:none)]:opacity-100"
|
|
111
|
+
class:opacity-100={selected}
|
|
112
|
+
onclick={(e) => {
|
|
113
|
+
e.stopPropagation();
|
|
114
|
+
onInsert?.();
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
<Icon name="plus" size={14} aria-hidden="true" />
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
{/if}
|
|
121
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* StudioCanvasItem - one selectable part of the canvas (a question, a block).
|
|
4
|
+
*
|
|
5
|
+
* Clicking anywhere in it, or tabbing into it, selects it; the content stays
|
|
6
|
+
* live, so a previewed question can still be answered. The selected item
|
|
7
|
+
* gets a ring and its `toolbar` (duplicate, delete...). With `onInsert`, a
|
|
8
|
+
* "+" between this item and the next adds a new one right there. On phones
|
|
9
|
+
* picking an item opens the inspector sheet.
|
|
10
|
+
*/
|
|
11
|
+
interface Props {
|
|
12
|
+
children: Snippet;
|
|
13
|
+
selected?: boolean;
|
|
14
|
+
onSelect?: () => void;
|
|
15
|
+
/** Names the item for screen readers and its Edit button, e.g. "Question 2" */
|
|
16
|
+
label: string;
|
|
17
|
+
/** Quick actions shown on the selected item */
|
|
18
|
+
toolbar?: Snippet;
|
|
19
|
+
/** Shows a "+" below the item that inserts after it */
|
|
20
|
+
onInsert?: () => void;
|
|
21
|
+
/** Accessible name of the insert button (default: "Add below {label}") */
|
|
22
|
+
insertLabel?: string;
|
|
23
|
+
/** Marks the item as having problems */
|
|
24
|
+
invalid?: boolean;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
declare const StudioCanvasItem: import("svelte").Component<Props, {}, "">;
|
|
28
|
+
type StudioCanvasItem = ReturnType<typeof StudioCanvasItem>;
|
|
29
|
+
export default StudioCanvasItem;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface StudioInspectorTab {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Shown after the label, e.g. the number of comments */
|
|
6
|
+
count?: number;
|
|
7
|
+
}
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<script lang="ts">
|
|
11
|
+
import type { Snippet } from 'svelte';
|
|
12
|
+
import { cn } from '../../utils.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* StudioInspector - the Studio's right column: settings for whatever is
|
|
16
|
+
* selected, and nothing else.
|
|
17
|
+
*
|
|
18
|
+
* A header names the selection ("Question 3", "Single choice") with room
|
|
19
|
+
* for an action such as Change type; `tabs` put related views side by side
|
|
20
|
+
* with editing (e.g. Slide · Comments) instead of replacing it. Group the
|
|
21
|
+
* fields with `StudioInspectorSection`, content first and rarely-used
|
|
22
|
+
* settings in a collapsed "Advanced" section.
|
|
23
|
+
*/
|
|
24
|
+
interface Props {
|
|
25
|
+
/** What is selected, e.g. "Question 3" */
|
|
26
|
+
title?: string;
|
|
27
|
+
/** Its kind, e.g. "Single choice" */
|
|
28
|
+
description?: string;
|
|
29
|
+
/** Header action, e.g. a Change type menu */
|
|
30
|
+
headerActions?: Snippet;
|
|
31
|
+
tabs?: StudioInspectorTab[];
|
|
32
|
+
/** Active tab (bindable) */
|
|
33
|
+
tab?: string;
|
|
34
|
+
onTabChange?: (tab: string) => void;
|
|
35
|
+
/** The fields; with tabs, render the active tab's content */
|
|
36
|
+
children: Snippet;
|
|
37
|
+
/** Pinned under the fields */
|
|
38
|
+
footer?: Snippet;
|
|
39
|
+
class?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let {
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
headerActions,
|
|
46
|
+
tabs,
|
|
47
|
+
tab = $bindable(tabs?.[0]?.id ?? ''),
|
|
48
|
+
onTabChange,
|
|
49
|
+
children,
|
|
50
|
+
footer,
|
|
51
|
+
class: className,
|
|
52
|
+
}: Props = $props();
|
|
53
|
+
|
|
54
|
+
const uid = $props.id();
|
|
55
|
+
|
|
56
|
+
function choose(id: string) {
|
|
57
|
+
if (id === tab) return;
|
|
58
|
+
tab = id;
|
|
59
|
+
onTabChange?.(id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tabKeydown(event: KeyboardEvent, index: number) {
|
|
63
|
+
if (!tabs) return;
|
|
64
|
+
let next = -1;
|
|
65
|
+
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
|
|
66
|
+
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
|
|
67
|
+
if (next < 0) return;
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
choose(tabs[next].id);
|
|
70
|
+
document.getElementById(`${uid}-tab-${tabs[next].id}`)?.focus();
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<div class={cn('flex h-full min-h-0 flex-col', className)}>
|
|
75
|
+
{#if title || headerActions}
|
|
76
|
+
<div class="flex shrink-0 items-start gap-2 border-b border-border px-4 py-3">
|
|
77
|
+
<div class="min-w-0 flex-1">
|
|
78
|
+
{#if title}<h2 class="truncate text-sm font-semibold">{title}</h2>{/if}
|
|
79
|
+
{#if description}
|
|
80
|
+
<p class="truncate text-xs text-muted-foreground">{description}</p>
|
|
81
|
+
{/if}
|
|
82
|
+
</div>
|
|
83
|
+
{#if headerActions}
|
|
84
|
+
<div class="flex shrink-0 items-center gap-1">{@render headerActions()}</div>
|
|
85
|
+
{/if}
|
|
86
|
+
</div>
|
|
87
|
+
{/if}
|
|
88
|
+
|
|
89
|
+
{#if tabs && tabs.length > 1}
|
|
90
|
+
<div
|
|
91
|
+
role="tablist"
|
|
92
|
+
aria-label={title ?? 'Inspector'}
|
|
93
|
+
class="flex shrink-0 gap-4 border-b border-border px-4"
|
|
94
|
+
>
|
|
95
|
+
{#each tabs as t, index (t.id)}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
role="tab"
|
|
99
|
+
id="{uid}-tab-{t.id}"
|
|
100
|
+
aria-selected={t.id === tab}
|
|
101
|
+
aria-controls="{uid}-panel"
|
|
102
|
+
tabindex={t.id === tab ? 0 : -1}
|
|
103
|
+
class={cn(
|
|
104
|
+
'-mb-px border-b-2 py-2 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
105
|
+
t.id === tab
|
|
106
|
+
? 'border-primary text-foreground'
|
|
107
|
+
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
108
|
+
)}
|
|
109
|
+
onclick={() => choose(t.id)}
|
|
110
|
+
onkeydown={(e) => tabKeydown(e, index)}
|
|
111
|
+
>
|
|
112
|
+
{t.label}
|
|
113
|
+
{#if t.count}
|
|
114
|
+
<span class="ml-1 rounded-full bg-muted px-1.5 text-xs tabular-nums">{t.count}</span>
|
|
115
|
+
{/if}
|
|
116
|
+
</button>
|
|
117
|
+
{/each}
|
|
118
|
+
</div>
|
|
119
|
+
{/if}
|
|
120
|
+
|
|
121
|
+
<div
|
|
122
|
+
class="min-h-0 flex-1 overflow-y-auto"
|
|
123
|
+
id={tabs && tabs.length > 1 ? `${uid}-panel` : undefined}
|
|
124
|
+
role={tabs && tabs.length > 1 ? 'tabpanel' : undefined}
|
|
125
|
+
aria-labelledby={tabs && tabs.length > 1 ? `${uid}-tab-${tab}` : undefined}
|
|
126
|
+
>
|
|
127
|
+
{@render children()}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{#if footer}
|
|
131
|
+
<div class="shrink-0 border-t border-border px-4 py-3">{@render footer()}</div>
|
|
132
|
+
{/if}
|
|
133
|
+
</div>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface StudioInspectorTab {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
/** Shown after the label, e.g. the number of comments */
|
|
5
|
+
count?: number;
|
|
6
|
+
}
|
|
7
|
+
import type { Snippet } from 'svelte';
|
|
8
|
+
/**
|
|
9
|
+
* StudioInspector - the Studio's right column: settings for whatever is
|
|
10
|
+
* selected, and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* A header names the selection ("Question 3", "Single choice") with room
|
|
13
|
+
* for an action such as Change type; `tabs` put related views side by side
|
|
14
|
+
* with editing (e.g. Slide · Comments) instead of replacing it. Group the
|
|
15
|
+
* fields with `StudioInspectorSection`, content first and rarely-used
|
|
16
|
+
* settings in a collapsed "Advanced" section.
|
|
17
|
+
*/
|
|
18
|
+
interface Props {
|
|
19
|
+
/** What is selected, e.g. "Question 3" */
|
|
20
|
+
title?: string;
|
|
21
|
+
/** Its kind, e.g. "Single choice" */
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Header action, e.g. a Change type menu */
|
|
24
|
+
headerActions?: Snippet;
|
|
25
|
+
tabs?: StudioInspectorTab[];
|
|
26
|
+
/** Active tab (bindable) */
|
|
27
|
+
tab?: string;
|
|
28
|
+
onTabChange?: (tab: string) => void;
|
|
29
|
+
/** The fields; with tabs, render the active tab's content */
|
|
30
|
+
children: Snippet;
|
|
31
|
+
/** Pinned under the fields */
|
|
32
|
+
footer?: Snippet;
|
|
33
|
+
class?: string;
|
|
34
|
+
}
|
|
35
|
+
declare const StudioInspector: import("svelte").Component<Props, {}, "tab">;
|
|
36
|
+
type StudioInspector = ReturnType<typeof StudioInspector>;
|
|
37
|
+
export default StudioInspector;
|