@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,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;