@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,141 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface StudioIssue {
|
|
3
|
+
id: string;
|
|
4
|
+
/** What is wrong, phrased as what to do: "Add at least two options" */
|
|
5
|
+
message: string;
|
|
6
|
+
/** `error` blocks publishing; `warning` is advice (default: error) */
|
|
7
|
+
severity?: 'error' | 'warning';
|
|
8
|
+
/** Where it is, e.g. "Question 3" or "Settings" */
|
|
9
|
+
location?: string;
|
|
10
|
+
}
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<script lang="ts">
|
|
14
|
+
import { Popover } from 'bits-ui';
|
|
15
|
+
import { cn } from '../../utils.js';
|
|
16
|
+
import Icon from '../Icon.svelte';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* IssuesButton - the single place an editor lists what needs fixing.
|
|
20
|
+
*
|
|
21
|
+
* A count in the bar that opens a checklist; picking an entry calls
|
|
22
|
+
* `onSelect` so the editor can select the item and focus the field. Bind
|
|
23
|
+
* `open` to show the same list when someone tries to publish with errors,
|
|
24
|
+
* instead of a separate publish-problems dialog. Hidden when there is
|
|
25
|
+
* nothing to fix, unless `showWhenEmpty`.
|
|
26
|
+
*/
|
|
27
|
+
interface Props {
|
|
28
|
+
issues: StudioIssue[];
|
|
29
|
+
/** Jump to an issue: select its item and focus the field */
|
|
30
|
+
onSelect?: (issue: StudioIssue) => void;
|
|
31
|
+
/** Popover open (bindable) */
|
|
32
|
+
open?: boolean;
|
|
33
|
+
/** Heading of the list (default: "Before you publish") */
|
|
34
|
+
title?: string;
|
|
35
|
+
/** Render a quiet "No issues" chip when the list is empty */
|
|
36
|
+
showWhenEmpty?: boolean;
|
|
37
|
+
class?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let {
|
|
41
|
+
issues,
|
|
42
|
+
onSelect,
|
|
43
|
+
open = $bindable(false),
|
|
44
|
+
title = 'Before you publish',
|
|
45
|
+
showWhenEmpty = false,
|
|
46
|
+
class: className,
|
|
47
|
+
}: Props = $props();
|
|
48
|
+
|
|
49
|
+
const errors = $derived(issues.filter((i) => (i.severity ?? 'error') === 'error'));
|
|
50
|
+
const warnings = $derived(issues.filter((i) => i.severity === 'warning'));
|
|
51
|
+
const countLabel = $derived(
|
|
52
|
+
[
|
|
53
|
+
errors.length && `${errors.length} ${errors.length === 1 ? 'issue' : 'issues'} to fix`,
|
|
54
|
+
warnings.length &&
|
|
55
|
+
`${warnings.length} ${warnings.length === 1 ? 'suggestion' : 'suggestions'}`,
|
|
56
|
+
]
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join(', ')
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
function pick(issue: StudioIssue) {
|
|
62
|
+
open = false;
|
|
63
|
+
onSelect?.(issue);
|
|
64
|
+
}
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
{#snippet group(list: StudioIssue[], heading: string, error: boolean)}
|
|
68
|
+
{#if list.length}
|
|
69
|
+
<p class="px-3 pb-1 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
70
|
+
{heading}
|
|
71
|
+
</p>
|
|
72
|
+
<ul class="pb-1">
|
|
73
|
+
{#each list as issue (issue.id)}
|
|
74
|
+
<li>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
class="flex w-full items-start gap-2 px-3 py-2 text-left text-sm hover:bg-muted-hover focus-visible:bg-muted-hover focus-visible:outline-none"
|
|
78
|
+
onclick={() => pick(issue)}
|
|
79
|
+
>
|
|
80
|
+
<Icon
|
|
81
|
+
name={error ? 'alert-circle' : 'info'}
|
|
82
|
+
size={16}
|
|
83
|
+
class={cn('mt-0.5 shrink-0', error ? 'text-destructive' : 'text-warning')}
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
/>
|
|
86
|
+
<span class="min-w-0 flex-1">
|
|
87
|
+
{#if issue.location}
|
|
88
|
+
<span class="block text-xs text-muted-foreground">{issue.location}</span>
|
|
89
|
+
{/if}
|
|
90
|
+
{issue.message}
|
|
91
|
+
</span>
|
|
92
|
+
</button>
|
|
93
|
+
</li>
|
|
94
|
+
{/each}
|
|
95
|
+
</ul>
|
|
96
|
+
{/if}
|
|
97
|
+
{/snippet}
|
|
98
|
+
|
|
99
|
+
{#if issues.length > 0}
|
|
100
|
+
<Popover.Root bind:open>
|
|
101
|
+
<Popover.Trigger
|
|
102
|
+
class={cn(
|
|
103
|
+
'inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm font-medium hover:bg-muted-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
104
|
+
className
|
|
105
|
+
)}
|
|
106
|
+
aria-label={countLabel}
|
|
107
|
+
>
|
|
108
|
+
<Icon
|
|
109
|
+
name="alert-triangle"
|
|
110
|
+
size={16}
|
|
111
|
+
class={errors.length ? 'text-destructive' : 'text-warning'}
|
|
112
|
+
aria-hidden="true"
|
|
113
|
+
/>
|
|
114
|
+
<span aria-hidden="true">{issues.length}</span>
|
|
115
|
+
</Popover.Trigger>
|
|
116
|
+
<Popover.Portal>
|
|
117
|
+
<Popover.Content
|
|
118
|
+
align="end"
|
|
119
|
+
sideOffset={6}
|
|
120
|
+
collisionPadding={8}
|
|
121
|
+
class="z-50 flex max-h-[min(28rem,70dvh)] w-80 flex-col overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md"
|
|
122
|
+
>
|
|
123
|
+
<div class="border-b border-border px-3 py-2">
|
|
124
|
+
<p class="text-sm font-semibold">{title}</p>
|
|
125
|
+
<p class="text-xs text-muted-foreground">{countLabel}</p>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="overflow-y-auto">
|
|
128
|
+
{@render group(errors, 'Must fix', true)}
|
|
129
|
+
{@render group(warnings, 'Suggestions', false)}
|
|
130
|
+
</div>
|
|
131
|
+
</Popover.Content>
|
|
132
|
+
</Popover.Portal>
|
|
133
|
+
</Popover.Root>
|
|
134
|
+
{:else if showWhenEmpty}
|
|
135
|
+
<span
|
|
136
|
+
class={cn('inline-flex h-8 items-center gap-1.5 px-2 text-sm text-muted-foreground', className)}
|
|
137
|
+
>
|
|
138
|
+
<Icon name="check-circle" size={16} class="text-success" aria-hidden="true" />
|
|
139
|
+
<span class="sr-only sm:not-sr-only">No issues</span>
|
|
140
|
+
</span>
|
|
141
|
+
{/if}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface StudioIssue {
|
|
2
|
+
id: string;
|
|
3
|
+
/** What is wrong, phrased as what to do: "Add at least two options" */
|
|
4
|
+
message: string;
|
|
5
|
+
/** `error` blocks publishing; `warning` is advice (default: error) */
|
|
6
|
+
severity?: 'error' | 'warning';
|
|
7
|
+
/** Where it is, e.g. "Question 3" or "Settings" */
|
|
8
|
+
location?: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* IssuesButton - the single place an editor lists what needs fixing.
|
|
12
|
+
*
|
|
13
|
+
* A count in the bar that opens a checklist; picking an entry calls
|
|
14
|
+
* `onSelect` so the editor can select the item and focus the field. Bind
|
|
15
|
+
* `open` to show the same list when someone tries to publish with errors,
|
|
16
|
+
* instead of a separate publish-problems dialog. Hidden when there is
|
|
17
|
+
* nothing to fix, unless `showWhenEmpty`.
|
|
18
|
+
*/
|
|
19
|
+
interface Props {
|
|
20
|
+
issues: StudioIssue[];
|
|
21
|
+
/** Jump to an issue: select its item and focus the field */
|
|
22
|
+
onSelect?: (issue: StudioIssue) => void;
|
|
23
|
+
/** Popover open (bindable) */
|
|
24
|
+
open?: boolean;
|
|
25
|
+
/** Heading of the list (default: "Before you publish") */
|
|
26
|
+
title?: string;
|
|
27
|
+
/** Render a quiet "No issues" chip when the list is empty */
|
|
28
|
+
showWhenEmpty?: boolean;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
declare const IssuesButton: import("svelte").Component<Props, {}, "open">;
|
|
32
|
+
type IssuesButton = ReturnType<typeof IssuesButton>;
|
|
33
|
+
export default IssuesButton;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type SaveState =
|
|
3
|
+
| 'saved'
|
|
4
|
+
| 'saving'
|
|
5
|
+
| 'unsaved'
|
|
6
|
+
| 'error'
|
|
7
|
+
| 'offline'
|
|
8
|
+
| 'conflict'
|
|
9
|
+
| 'invalid'
|
|
10
|
+
| 'readonly';
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<script lang="ts">
|
|
14
|
+
import { Popover } from 'bits-ui';
|
|
15
|
+
import { cn } from '../../utils.js';
|
|
16
|
+
import Icon, { type IconName } from '../Icon.svelte';
|
|
17
|
+
import Button from '../Button.svelte';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* SaveStatus - one compact signal for an autosaving editor.
|
|
21
|
+
*
|
|
22
|
+
* Shows an icon and one word (Saved, Saving…, Not saved…). When there is
|
|
23
|
+
* more to say - why a save failed, what needs fixing - the chip opens a
|
|
24
|
+
* popover with that detail and, for failures, a Retry button. Replaces
|
|
25
|
+
* stacks of status badges and save-error alerts.
|
|
26
|
+
*/
|
|
27
|
+
interface Props {
|
|
28
|
+
state: SaveState;
|
|
29
|
+
/** Longer explanation shown in the popover, e.g. "Fix 2 questions to save them." */
|
|
30
|
+
detail?: string;
|
|
31
|
+
/** Offered in the popover for `error` and `offline` */
|
|
32
|
+
onRetry?: () => void;
|
|
33
|
+
/** Override the one-word label */
|
|
34
|
+
label?: string;
|
|
35
|
+
class?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let { state, detail, onRetry, label, class: className }: Props = $props();
|
|
39
|
+
|
|
40
|
+
const meta: Record<SaveState, { label: string; icon: IconName; tone: string }> = {
|
|
41
|
+
saved: { label: 'Saved', icon: 'check-circle', tone: 'text-muted-foreground' },
|
|
42
|
+
saving: { label: 'Saving…', icon: 'loader', tone: 'text-muted-foreground' },
|
|
43
|
+
unsaved: { label: 'Unsaved', icon: 'edit', tone: 'text-muted-foreground' },
|
|
44
|
+
error: { label: 'Not saved', icon: 'alert-circle', tone: 'text-destructive' },
|
|
45
|
+
offline: { label: 'Offline', icon: 'wifi-off', tone: 'text-warning' },
|
|
46
|
+
conflict: { label: 'Conflict', icon: 'alert-triangle', tone: 'text-destructive' },
|
|
47
|
+
invalid: { label: 'Needs fixes', icon: 'alert-triangle', tone: 'text-warning' },
|
|
48
|
+
readonly: { label: 'View only', icon: 'eye', tone: 'text-muted-foreground' },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const current = $derived(meta[state]);
|
|
52
|
+
const text = $derived(label ?? current.label);
|
|
53
|
+
const canRetry = $derived(!!onRetry && (state === 'error' || state === 'offline'));
|
|
54
|
+
const hasDetail = $derived(!!detail || canRetry);
|
|
55
|
+
const announced = $derived(
|
|
56
|
+
state === 'error' || state === 'offline' || state === 'conflict' || state === 'invalid'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const chip =
|
|
60
|
+
'inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm font-medium text-foreground';
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
{#snippet content()}
|
|
64
|
+
<Icon
|
|
65
|
+
name={current.icon}
|
|
66
|
+
size={16}
|
|
67
|
+
class={cn('shrink-0', current.tone, state === 'saving' && 'animate-spin')}
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
/>
|
|
70
|
+
<!-- Icon-only on phones; the word stays available to screen readers -->
|
|
71
|
+
<span class="sr-only sm:not-sr-only">{text}</span>
|
|
72
|
+
{/snippet}
|
|
73
|
+
|
|
74
|
+
<div class={cn('inline-flex', className)}>
|
|
75
|
+
{#if hasDetail}
|
|
76
|
+
<Popover.Root>
|
|
77
|
+
<Popover.Trigger
|
|
78
|
+
class={cn(
|
|
79
|
+
chip,
|
|
80
|
+
'hover:bg-muted-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
81
|
+
)}
|
|
82
|
+
aria-label="Save status: {text}. Show details"
|
|
83
|
+
>
|
|
84
|
+
{@render content()}
|
|
85
|
+
<Icon name="chevron-down" size={14} class="text-muted-foreground" aria-hidden="true" />
|
|
86
|
+
</Popover.Trigger>
|
|
87
|
+
<Popover.Portal>
|
|
88
|
+
<Popover.Content
|
|
89
|
+
align="end"
|
|
90
|
+
sideOffset={6}
|
|
91
|
+
collisionPadding={8}
|
|
92
|
+
class="z-50 w-72 rounded-md border border-border bg-popover p-3 text-sm text-popover-foreground shadow-md"
|
|
93
|
+
>
|
|
94
|
+
<p class="font-medium">{text}</p>
|
|
95
|
+
{#if detail}<p class="mt-1 text-muted-foreground">{detail}</p>{/if}
|
|
96
|
+
{#if canRetry}
|
|
97
|
+
<Button size="sm" variant="secondary" class="mt-3" onclick={() => onRetry?.()}>
|
|
98
|
+
<Icon name="refresh" size={14} aria-hidden="true" />
|
|
99
|
+
Retry
|
|
100
|
+
</Button>
|
|
101
|
+
{/if}
|
|
102
|
+
</Popover.Content>
|
|
103
|
+
</Popover.Portal>
|
|
104
|
+
</Popover.Root>
|
|
105
|
+
{:else}
|
|
106
|
+
<span class={chip}>{@render content()}</span>
|
|
107
|
+
{/if}
|
|
108
|
+
<!-- Only problems are announced; "Saving… Saved" after every keystroke would be noise -->
|
|
109
|
+
<span class="sr-only" role="status" aria-live="polite">{announced ? text : ''}</span>
|
|
110
|
+
</div>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type SaveState = 'saved' | 'saving' | 'unsaved' | 'error' | 'offline' | 'conflict' | 'invalid' | 'readonly';
|
|
2
|
+
/**
|
|
3
|
+
* SaveStatus - one compact signal for an autosaving editor.
|
|
4
|
+
*
|
|
5
|
+
* Shows an icon and one word (Saved, Saving…, Not saved…). When there is
|
|
6
|
+
* more to say - why a save failed, what needs fixing - the chip opens a
|
|
7
|
+
* popover with that detail and, for failures, a Retry button. Replaces
|
|
8
|
+
* stacks of status badges and save-error alerts.
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
state: SaveState;
|
|
12
|
+
/** Longer explanation shown in the popover, e.g. "Fix 2 questions to save them." */
|
|
13
|
+
detail?: string;
|
|
14
|
+
/** Offered in the popover for `error` and `offline` */
|
|
15
|
+
onRetry?: () => void;
|
|
16
|
+
/** Override the one-word label */
|
|
17
|
+
label?: string;
|
|
18
|
+
class?: string;
|
|
19
|
+
}
|
|
20
|
+
declare const SaveStatus: import("svelte").Component<Props, {}, "">;
|
|
21
|
+
type SaveStatus = ReturnType<typeof SaveStatus>;
|
|
22
|
+
export default SaveStatus;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, type Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils.js';
|
|
4
|
+
import Icon from '../Icon.svelte';
|
|
5
|
+
import Sheet from '../Sheet.svelte';
|
|
6
|
+
import { setStudioContext, type StudioLayout } from './context.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Studio - full-screen frame for editors (survey builders, slide decks,
|
|
10
|
+
* page and form builders).
|
|
11
|
+
*
|
|
12
|
+
* One fixed layout that never page-scrolls:
|
|
13
|
+
*
|
|
14
|
+
* ```
|
|
15
|
+
* ┌ bar ─────────────────────────────────────────┐
|
|
16
|
+
* │ banner (optional) │
|
|
17
|
+
* ├ rail ──┬ canvas ─────────────┬ inspector ────┤
|
|
18
|
+
* │ outline│ the real thing, │ settings for │
|
|
19
|
+
* │ │ click to select │ the selection │
|
|
20
|
+
* └────────┴─────────────────────┴───────────────┘
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Rail, canvas and inspector scroll on their own. Below 1280px the rail
|
|
24
|
+
* moves into a left sheet; below 768px the inspector moves into a bottom
|
|
25
|
+
* sheet as well, leaving the canvas full screen. Parts read the layout
|
|
26
|
+
* through `getStudioContext()`, so `StudioOutline` closes its sheet after a
|
|
27
|
+
* pick and `StudioCanvasItem` opens the inspector sheet on phones.
|
|
28
|
+
*
|
|
29
|
+
* Leave out `rail` and `inspector` for full-width modes such as a settings
|
|
30
|
+
* page (render a `StudioPage` as the canvas).
|
|
31
|
+
*/
|
|
32
|
+
interface Props {
|
|
33
|
+
/** Top bar, normally a `StudioBar` */
|
|
34
|
+
bar: Snippet;
|
|
35
|
+
/** One message slot under the bar, for conflicts and decisions that block editing */
|
|
36
|
+
banner?: Snippet;
|
|
37
|
+
/** Left column, normally a `StudioOutline` */
|
|
38
|
+
rail?: Snippet;
|
|
39
|
+
/** Main area, normally a `StudioCanvas` or `StudioPage` */
|
|
40
|
+
canvas: Snippet;
|
|
41
|
+
/** Right column, normally a `StudioInspector` */
|
|
42
|
+
inspector?: Snippet;
|
|
43
|
+
/** Accessible name of the rail and the button that opens it (default: "Outline") */
|
|
44
|
+
railLabel?: string;
|
|
45
|
+
/** Accessible name of the inspector and the button that opens it (default: "Properties") */
|
|
46
|
+
inspectorLabel?: string;
|
|
47
|
+
/** Rail sheet open below 1280px (bindable) */
|
|
48
|
+
railOpen?: boolean;
|
|
49
|
+
/** Inspector sheet open below 768px (bindable) */
|
|
50
|
+
inspectorOpen?: boolean;
|
|
51
|
+
class?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let {
|
|
55
|
+
bar,
|
|
56
|
+
banner,
|
|
57
|
+
rail,
|
|
58
|
+
canvas,
|
|
59
|
+
inspector,
|
|
60
|
+
railLabel = 'Outline',
|
|
61
|
+
inspectorLabel = 'Properties',
|
|
62
|
+
railOpen = $bindable(false),
|
|
63
|
+
inspectorOpen = $bindable(false),
|
|
64
|
+
class: className,
|
|
65
|
+
}: Props = $props();
|
|
66
|
+
|
|
67
|
+
// Starts wide so SSR and desktop's first paint match. The columns also carry
|
|
68
|
+
// the breakpoints as CSS, so server-rendered HTML is already right on phones.
|
|
69
|
+
let layout = $state<StudioLayout>('wide');
|
|
70
|
+
|
|
71
|
+
onMount(() => {
|
|
72
|
+
const wide = window.matchMedia('(min-width: 1280px)');
|
|
73
|
+
const medium = window.matchMedia('(min-width: 768px)');
|
|
74
|
+
const update = () => {
|
|
75
|
+
layout = wide.matches ? 'wide' : medium.matches ? 'medium' : 'compact';
|
|
76
|
+
if (layout === 'wide') railOpen = false;
|
|
77
|
+
if (layout !== 'compact') inspectorOpen = false;
|
|
78
|
+
};
|
|
79
|
+
update();
|
|
80
|
+
wide.addEventListener('change', update);
|
|
81
|
+
medium.addEventListener('change', update);
|
|
82
|
+
return () => {
|
|
83
|
+
wide.removeEventListener('change', update);
|
|
84
|
+
medium.removeEventListener('change', update);
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const railInSheet = $derived(!!rail && layout !== 'wide');
|
|
89
|
+
const inspectorInSheet = $derived(!!inspector && layout === 'compact');
|
|
90
|
+
|
|
91
|
+
setStudioContext({
|
|
92
|
+
get layout() {
|
|
93
|
+
return layout;
|
|
94
|
+
},
|
|
95
|
+
get railInSheet() {
|
|
96
|
+
return railInSheet;
|
|
97
|
+
},
|
|
98
|
+
get inspectorInSheet() {
|
|
99
|
+
return inspectorInSheet;
|
|
100
|
+
},
|
|
101
|
+
openRail: () => {
|
|
102
|
+
if (railInSheet) railOpen = true;
|
|
103
|
+
},
|
|
104
|
+
closeRail: () => (railOpen = false),
|
|
105
|
+
revealInspector: () => {
|
|
106
|
+
railOpen = false;
|
|
107
|
+
if (inspectorInSheet) inspectorOpen = true;
|
|
108
|
+
},
|
|
109
|
+
closeInspector: () => (inspectorOpen = false),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const floatingButton =
|
|
113
|
+
'pointer-events-auto inline-flex h-9 items-center gap-2 rounded-full border border-border bg-background px-3 text-sm font-medium shadow-md hover:bg-muted-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<div class={cn('flex h-dvh flex-col overflow-hidden bg-background text-foreground', className)}>
|
|
117
|
+
{@render bar()}
|
|
118
|
+
|
|
119
|
+
{#if banner}
|
|
120
|
+
<div class="shrink-0">{@render banner()}</div>
|
|
121
|
+
{/if}
|
|
122
|
+
|
|
123
|
+
<div class="relative flex min-h-0 flex-1">
|
|
124
|
+
{#if rail && !railInSheet}
|
|
125
|
+
<aside
|
|
126
|
+
aria-label={railLabel}
|
|
127
|
+
class="hidden w-64 shrink-0 flex-col overflow-hidden border-r border-border bg-background xl:flex"
|
|
128
|
+
>
|
|
129
|
+
{@render rail()}
|
|
130
|
+
</aside>
|
|
131
|
+
{/if}
|
|
132
|
+
|
|
133
|
+
<div class="relative min-w-0 flex-1 overflow-hidden bg-muted/40">
|
|
134
|
+
{@render canvas()}
|
|
135
|
+
|
|
136
|
+
{#if railInSheet || inspectorInSheet}
|
|
137
|
+
<div
|
|
138
|
+
class="pointer-events-none absolute inset-x-3 bottom-3 z-10 flex items-end justify-between gap-2"
|
|
139
|
+
>
|
|
140
|
+
{#if railInSheet}
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
class={floatingButton}
|
|
144
|
+
aria-expanded={railOpen}
|
|
145
|
+
onclick={() => (railOpen = true)}
|
|
146
|
+
>
|
|
147
|
+
<Icon name="list" size={16} aria-hidden="true" />
|
|
148
|
+
{railLabel}
|
|
149
|
+
</button>
|
|
150
|
+
{:else}
|
|
151
|
+
<span></span>
|
|
152
|
+
{/if}
|
|
153
|
+
{#if inspectorInSheet}
|
|
154
|
+
<button
|
|
155
|
+
type="button"
|
|
156
|
+
class={floatingButton}
|
|
157
|
+
aria-expanded={inspectorOpen}
|
|
158
|
+
onclick={() => (inspectorOpen = true)}
|
|
159
|
+
>
|
|
160
|
+
<Icon name="settings" size={16} aria-hidden="true" />
|
|
161
|
+
{inspectorLabel}
|
|
162
|
+
</button>
|
|
163
|
+
{/if}
|
|
164
|
+
</div>
|
|
165
|
+
{/if}
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{#if inspector && !inspectorInSheet}
|
|
169
|
+
<aside
|
|
170
|
+
aria-label={inspectorLabel}
|
|
171
|
+
class="hidden w-80 shrink-0 flex-col overflow-hidden border-l border-border bg-background md:flex 2xl:w-96"
|
|
172
|
+
>
|
|
173
|
+
{@render inspector()}
|
|
174
|
+
</aside>
|
|
175
|
+
{/if}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{#if rail && railInSheet}
|
|
180
|
+
<Sheet bind:open={railOpen} side="left" size="sm" title={railLabel}>
|
|
181
|
+
<div class="flex h-full flex-col">{@render rail()}</div>
|
|
182
|
+
</Sheet>
|
|
183
|
+
{/if}
|
|
184
|
+
|
|
185
|
+
{#if inspector && inspectorInSheet}
|
|
186
|
+
<Sheet bind:open={inspectorOpen} side="bottom" title={inspectorLabel} hideTitle>
|
|
187
|
+
{@render inspector()}
|
|
188
|
+
</Sheet>
|
|
189
|
+
{/if}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Studio - full-screen frame for editors (survey builders, slide decks,
|
|
4
|
+
* page and form builders).
|
|
5
|
+
*
|
|
6
|
+
* One fixed layout that never page-scrolls:
|
|
7
|
+
*
|
|
8
|
+
* ```
|
|
9
|
+
* ┌ bar ─────────────────────────────────────────┐
|
|
10
|
+
* │ banner (optional) │
|
|
11
|
+
* ├ rail ──┬ canvas ─────────────┬ inspector ────┤
|
|
12
|
+
* │ outline│ the real thing, │ settings for │
|
|
13
|
+
* │ │ click to select │ the selection │
|
|
14
|
+
* └────────┴─────────────────────┴───────────────┘
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* Rail, canvas and inspector scroll on their own. Below 1280px the rail
|
|
18
|
+
* moves into a left sheet; below 768px the inspector moves into a bottom
|
|
19
|
+
* sheet as well, leaving the canvas full screen. Parts read the layout
|
|
20
|
+
* through `getStudioContext()`, so `StudioOutline` closes its sheet after a
|
|
21
|
+
* pick and `StudioCanvasItem` opens the inspector sheet on phones.
|
|
22
|
+
*
|
|
23
|
+
* Leave out `rail` and `inspector` for full-width modes such as a settings
|
|
24
|
+
* page (render a `StudioPage` as the canvas).
|
|
25
|
+
*/
|
|
26
|
+
interface Props {
|
|
27
|
+
/** Top bar, normally a `StudioBar` */
|
|
28
|
+
bar: Snippet;
|
|
29
|
+
/** One message slot under the bar, for conflicts and decisions that block editing */
|
|
30
|
+
banner?: Snippet;
|
|
31
|
+
/** Left column, normally a `StudioOutline` */
|
|
32
|
+
rail?: Snippet;
|
|
33
|
+
/** Main area, normally a `StudioCanvas` or `StudioPage` */
|
|
34
|
+
canvas: Snippet;
|
|
35
|
+
/** Right column, normally a `StudioInspector` */
|
|
36
|
+
inspector?: Snippet;
|
|
37
|
+
/** Accessible name of the rail and the button that opens it (default: "Outline") */
|
|
38
|
+
railLabel?: string;
|
|
39
|
+
/** Accessible name of the inspector and the button that opens it (default: "Properties") */
|
|
40
|
+
inspectorLabel?: string;
|
|
41
|
+
/** Rail sheet open below 1280px (bindable) */
|
|
42
|
+
railOpen?: boolean;
|
|
43
|
+
/** Inspector sheet open below 768px (bindable) */
|
|
44
|
+
inspectorOpen?: boolean;
|
|
45
|
+
class?: string;
|
|
46
|
+
}
|
|
47
|
+
declare const Studio: import("svelte").Component<Props, {}, "railOpen" | "inspectorOpen">;
|
|
48
|
+
type Studio = ReturnType<typeof Studio>;
|
|
49
|
+
export default Studio;
|