@classic-homes/theme-svelte 1.18.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__/studio.test.js +223 -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/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/package.json +1 -1
|
@@ -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;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils.js';
|
|
4
|
+
import Icon from '../Icon.svelte';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* StudioInspectorSection - a titled group of fields in the inspector.
|
|
8
|
+
*
|
|
9
|
+
* Set `collapsible` for settings most people never touch ("Advanced"); they
|
|
10
|
+
* start closed unless `open` is set, and the heading says how many are set
|
|
11
|
+
* via `summary` so nothing hides silently.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
title?: string;
|
|
15
|
+
/** Short hint under the title */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Collapse behind the title */
|
|
18
|
+
collapsible?: boolean;
|
|
19
|
+
/** Expanded (bindable); only used when collapsible */
|
|
20
|
+
open?: boolean;
|
|
21
|
+
/** Shown beside a collapsed title, e.g. "2 set" */
|
|
22
|
+
summary?: string;
|
|
23
|
+
children: Snippet;
|
|
24
|
+
class?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let {
|
|
28
|
+
title,
|
|
29
|
+
description,
|
|
30
|
+
collapsible = false,
|
|
31
|
+
open = $bindable(false),
|
|
32
|
+
summary,
|
|
33
|
+
children,
|
|
34
|
+
class: className,
|
|
35
|
+
}: Props = $props();
|
|
36
|
+
|
|
37
|
+
const uid = $props.id();
|
|
38
|
+
const expanded = $derived(!collapsible || open);
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<section
|
|
42
|
+
class={cn('border-b border-border px-4 py-4 last:border-b-0', className)}
|
|
43
|
+
aria-labelledby={title ? `${uid}-title` : undefined}
|
|
44
|
+
>
|
|
45
|
+
{#if title}
|
|
46
|
+
{#if collapsible}
|
|
47
|
+
<h3 id="{uid}-title" class="-my-1">
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
aria-expanded={open}
|
|
51
|
+
aria-controls="{uid}-body"
|
|
52
|
+
class="flex w-full items-center gap-2 rounded-md py-1 text-left text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
53
|
+
onclick={() => (open = !open)}
|
|
54
|
+
>
|
|
55
|
+
<Icon
|
|
56
|
+
name="chevron-right"
|
|
57
|
+
size={16}
|
|
58
|
+
class={cn('shrink-0 text-muted-foreground transition-transform', open && 'rotate-90')}
|
|
59
|
+
aria-hidden="true"
|
|
60
|
+
/>
|
|
61
|
+
<span class="flex-1">{title}</span>
|
|
62
|
+
{#if summary && !open}
|
|
63
|
+
<span class="text-xs font-normal text-muted-foreground">{summary}</span>
|
|
64
|
+
{/if}
|
|
65
|
+
</button>
|
|
66
|
+
</h3>
|
|
67
|
+
{:else}
|
|
68
|
+
<h3 id="{uid}-title" class="text-sm font-semibold">{title}</h3>
|
|
69
|
+
{/if}
|
|
70
|
+
{#if description && expanded}
|
|
71
|
+
<p class="mt-1 text-xs text-muted-foreground">{description}</p>
|
|
72
|
+
{/if}
|
|
73
|
+
{/if}
|
|
74
|
+
{#if expanded}
|
|
75
|
+
<div id="{uid}-body" class={cn('space-y-4', title && 'mt-3')}>
|
|
76
|
+
{@render children()}
|
|
77
|
+
</div>
|
|
78
|
+
{/if}
|
|
79
|
+
</section>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* StudioInspectorSection - a titled group of fields in the inspector.
|
|
4
|
+
*
|
|
5
|
+
* Set `collapsible` for settings most people never touch ("Advanced"); they
|
|
6
|
+
* start closed unless `open` is set, and the heading says how many are set
|
|
7
|
+
* via `summary` so nothing hides silently.
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
title?: string;
|
|
11
|
+
/** Short hint under the title */
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Collapse behind the title */
|
|
14
|
+
collapsible?: boolean;
|
|
15
|
+
/** Expanded (bindable); only used when collapsible */
|
|
16
|
+
open?: boolean;
|
|
17
|
+
/** Shown beside a collapsed title, e.g. "2 set" */
|
|
18
|
+
summary?: string;
|
|
19
|
+
children: Snippet;
|
|
20
|
+
class?: string;
|
|
21
|
+
}
|
|
22
|
+
declare const StudioInspectorSection: import("svelte").Component<Props, {}, "open">;
|
|
23
|
+
type StudioInspectorSection = ReturnType<typeof StudioInspectorSection>;
|
|
24
|
+
export default StudioInspectorSection;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { IconName } from '../Icon.svelte';
|
|
3
|
+
|
|
4
|
+
export interface StudioOutlineEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
/** Second line, e.g. the question type or slide layout */
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
icon?: IconName;
|
|
10
|
+
/** Number of problems on this item; shown as a count, not just a colour */
|
|
11
|
+
issues?: number;
|
|
12
|
+
/** Colour of the issue count (default: error) */
|
|
13
|
+
issueSeverity?: 'error' | 'warning';
|
|
14
|
+
/** Dim the row, e.g. a hidden slide */
|
|
15
|
+
muted?: boolean;
|
|
16
|
+
/** Short trailing tag, e.g. "Hidden" or "2 comments" */
|
|
17
|
+
badge?: string;
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<script lang="ts">
|
|
22
|
+
import { tick, type Snippet } from 'svelte';
|
|
23
|
+
import { cn } from '../../utils.js';
|
|
24
|
+
import Icon from '../Icon.svelte';
|
|
25
|
+
import DropdownMenu, { type DropdownMenuItem } from '../DropdownMenu.svelte';
|
|
26
|
+
import SortableList from '../SortableList.svelte';
|
|
27
|
+
import { getStudioContext } from './context.js';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* StudioOutline - the Studio's rail: every item in order, the one place to
|
|
31
|
+
* select, reorder and add.
|
|
32
|
+
*
|
|
33
|
+
* Rows reorder by their drag handle (or keyboard, see `SortableList`); the
|
|
34
|
+
* row menu holds duplicate/delete and Move up/down. Arrow keys move between
|
|
35
|
+
* rows. Picking a row inside the phone sheet closes the sheet and opens the
|
|
36
|
+
* inspector. Pass `thumbnail` to show a preview per row (slides).
|
|
37
|
+
*/
|
|
38
|
+
interface Props {
|
|
39
|
+
items: StudioOutlineEntry[];
|
|
40
|
+
/** Selected item id (bindable) */
|
|
41
|
+
selected?: string | null;
|
|
42
|
+
onSelect?: (id: string) => void;
|
|
43
|
+
/** Move item `from` to index `to`; omit to turn reordering off */
|
|
44
|
+
onReorder?: (from: number, to: number) => void;
|
|
45
|
+
/** Row menu items; Move up/down are added for you when `onReorder` is set */
|
|
46
|
+
menuItems?: (entry: StudioOutlineEntry, index: number) => DropdownMenuItem[];
|
|
47
|
+
/** Shows the add button at the bottom of the rail */
|
|
48
|
+
onAdd?: () => void;
|
|
49
|
+
/** Add button text (default: "Add") */
|
|
50
|
+
addLabel?: string;
|
|
51
|
+
/** Accessible name of the list, e.g. "Questions" (default: "Items") */
|
|
52
|
+
label?: string;
|
|
53
|
+
/** Number rows 1, 2, 3... (default: true) */
|
|
54
|
+
numbered?: boolean;
|
|
55
|
+
/** Preview per row, e.g. a slide thumbnail */
|
|
56
|
+
thumbnail?: Snippet<[StudioOutlineEntry, number]>;
|
|
57
|
+
/** Above the list, e.g. a heading or filter */
|
|
58
|
+
header?: Snippet;
|
|
59
|
+
/** Shown when there are no items */
|
|
60
|
+
empty?: Snippet;
|
|
61
|
+
class?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let {
|
|
65
|
+
items,
|
|
66
|
+
selected = $bindable(null),
|
|
67
|
+
onSelect,
|
|
68
|
+
onReorder,
|
|
69
|
+
menuItems,
|
|
70
|
+
onAdd,
|
|
71
|
+
addLabel = 'Add',
|
|
72
|
+
label = 'Items',
|
|
73
|
+
numbered = true,
|
|
74
|
+
thumbnail,
|
|
75
|
+
header,
|
|
76
|
+
empty,
|
|
77
|
+
class: className,
|
|
78
|
+
}: Props = $props();
|
|
79
|
+
|
|
80
|
+
const studio = getStudioContext();
|
|
81
|
+
let listHost: HTMLDivElement | undefined = $state();
|
|
82
|
+
|
|
83
|
+
const focusIndex = $derived(
|
|
84
|
+
Math.max(
|
|
85
|
+
0,
|
|
86
|
+
items.findIndex((i) => i.id === selected)
|
|
87
|
+
)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
function select(id: string) {
|
|
91
|
+
selected = id;
|
|
92
|
+
onSelect?.(id);
|
|
93
|
+
studio?.revealInspector();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rowButtons() {
|
|
97
|
+
return Array.from(listHost?.querySelectorAll<HTMLElement>('[data-outline-row]') ?? []);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function rowKeydown(event: KeyboardEvent, index: number) {
|
|
101
|
+
const buttons = rowButtons();
|
|
102
|
+
let next = -1;
|
|
103
|
+
if (event.key === 'ArrowDown') next = Math.min(index + 1, buttons.length - 1);
|
|
104
|
+
else if (event.key === 'ArrowUp') next = Math.max(index - 1, 0);
|
|
105
|
+
else if (event.key === 'Home') next = 0;
|
|
106
|
+
else if (event.key === 'End') next = buttons.length - 1;
|
|
107
|
+
if (next < 0) return;
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
buttons[next]?.focus();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rowMenu(entry: StudioOutlineEntry, index: number): DropdownMenuItem[] {
|
|
113
|
+
const own = menuItems?.(entry, index) ?? [];
|
|
114
|
+
if (!onReorder) return own;
|
|
115
|
+
const reorder = onReorder;
|
|
116
|
+
const moves: DropdownMenuItem[] = [
|
|
117
|
+
{
|
|
118
|
+
label: 'Move up',
|
|
119
|
+
icon: 'arrow-up',
|
|
120
|
+
disabled: index === 0,
|
|
121
|
+
onSelect: () => reorder(index, index - 1),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
label: 'Move down',
|
|
125
|
+
icon: 'arrow-down',
|
|
126
|
+
disabled: index === items.length - 1,
|
|
127
|
+
onSelect: () => reorder(index, index + 1),
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
return own.length ? [...moves, { type: 'separator' }, ...own] : moves;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Keep the selected row visible when selection comes from the canvas
|
|
134
|
+
$effect(() => {
|
|
135
|
+
const id = selected;
|
|
136
|
+
if (!id || !listHost) return;
|
|
137
|
+
void tick().then(() => {
|
|
138
|
+
listHost
|
|
139
|
+
?.querySelector(`[data-outline-id="${CSS.escape(id)}"]`)
|
|
140
|
+
?.scrollIntoView?.({ block: 'nearest' });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
{#snippet row(entry: StudioOutlineEntry, index: number, handle?: Record<string, unknown>)}
|
|
146
|
+
{@const active = entry.id === selected}
|
|
147
|
+
{@const menu = rowMenu(entry, index)}
|
|
148
|
+
<div
|
|
149
|
+
data-outline-id={entry.id}
|
|
150
|
+
class={cn(
|
|
151
|
+
'group relative flex items-stretch gap-0.5 rounded-md',
|
|
152
|
+
active ? 'bg-primary/10 ring-1 ring-inset ring-primary/40' : 'hover:bg-muted-hover'
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
{#if handle}
|
|
156
|
+
<button
|
|
157
|
+
{...handle}
|
|
158
|
+
class="flex w-6 shrink-0 cursor-grab touch-none items-center justify-center rounded-l-md text-muted-foreground opacity-0 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing group-hover:opacity-100 aria-pressed:opacity-100 [@media(hover:none)]:opacity-100"
|
|
159
|
+
class:opacity-100={active}
|
|
160
|
+
>
|
|
161
|
+
<Icon name="grip" size={14} aria-hidden="true" />
|
|
162
|
+
</button>
|
|
163
|
+
{/if}
|
|
164
|
+
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
data-outline-row
|
|
168
|
+
tabindex={index === focusIndex ? 0 : -1}
|
|
169
|
+
aria-current={active ? 'true' : undefined}
|
|
170
|
+
class={cn(
|
|
171
|
+
'flex min-w-0 flex-1 flex-col gap-1.5 rounded-md py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
172
|
+
!handle && 'pl-2',
|
|
173
|
+
entry.muted && 'opacity-60'
|
|
174
|
+
)}
|
|
175
|
+
onclick={() => select(entry.id)}
|
|
176
|
+
onkeydown={(e) => rowKeydown(e, index)}
|
|
177
|
+
>
|
|
178
|
+
{#if thumbnail}
|
|
179
|
+
<div class="w-full overflow-hidden rounded border border-border bg-background">
|
|
180
|
+
{@render thumbnail(entry, index)}
|
|
181
|
+
</div>
|
|
182
|
+
{/if}
|
|
183
|
+
<span class="flex min-w-0 items-start gap-2">
|
|
184
|
+
{#if numbered}
|
|
185
|
+
<span class="w-5 shrink-0 pt-px text-right text-xs tabular-nums text-muted-foreground">
|
|
186
|
+
{index + 1}
|
|
187
|
+
</span>
|
|
188
|
+
{/if}
|
|
189
|
+
{#if entry.icon && !thumbnail}
|
|
190
|
+
<Icon
|
|
191
|
+
name={entry.icon}
|
|
192
|
+
size={16}
|
|
193
|
+
class="mt-px shrink-0 text-muted-foreground"
|
|
194
|
+
aria-hidden="true"
|
|
195
|
+
/>
|
|
196
|
+
{/if}
|
|
197
|
+
<span class="min-w-0 flex-1">
|
|
198
|
+
<span
|
|
199
|
+
class={cn(
|
|
200
|
+
'block truncate text-sm',
|
|
201
|
+
active ? 'font-medium text-foreground' : 'text-foreground'
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
{entry.title || 'Untitled'}
|
|
205
|
+
</span>
|
|
206
|
+
{#if entry.subtitle}
|
|
207
|
+
<span class="block truncate text-xs text-muted-foreground">{entry.subtitle}</span>
|
|
208
|
+
{/if}
|
|
209
|
+
</span>
|
|
210
|
+
{#if entry.badge}
|
|
211
|
+
<span
|
|
212
|
+
class="shrink-0 rounded bg-muted px-1.5 py-px text-[0.6875rem] text-muted-foreground"
|
|
213
|
+
>
|
|
214
|
+
{entry.badge}
|
|
215
|
+
</span>
|
|
216
|
+
{/if}
|
|
217
|
+
{#if entry.issues}
|
|
218
|
+
<span
|
|
219
|
+
class="inline-flex shrink-0 items-center gap-0.5 text-xs font-medium tabular-nums"
|
|
220
|
+
title="{entry.issues} {entry.issues === 1 ? 'issue' : 'issues'}"
|
|
221
|
+
>
|
|
222
|
+
<Icon
|
|
223
|
+
name="alert-triangle"
|
|
224
|
+
size={12}
|
|
225
|
+
class={entry.issueSeverity === 'warning' ? 'text-warning' : 'text-destructive'}
|
|
226
|
+
aria-hidden="true"
|
|
227
|
+
/>
|
|
228
|
+
{entry.issues}
|
|
229
|
+
<span class="sr-only">{entry.issues === 1 ? 'issue' : 'issues'}</span>
|
|
230
|
+
</span>
|
|
231
|
+
{/if}
|
|
232
|
+
</span>
|
|
233
|
+
</button>
|
|
234
|
+
|
|
235
|
+
{#if menu.length}
|
|
236
|
+
<div
|
|
237
|
+
class={cn(
|
|
238
|
+
'flex shrink-0 items-start pt-1 opacity-0 focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100',
|
|
239
|
+
active && 'opacity-100'
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
<DropdownMenu
|
|
243
|
+
items={menu}
|
|
244
|
+
align="end"
|
|
245
|
+
triggerClass="flex h-7 w-7 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"
|
|
246
|
+
triggerLabel="Actions for {entry.title || `item ${index + 1}`}"
|
|
247
|
+
>
|
|
248
|
+
{#snippet trigger()}
|
|
249
|
+
<Icon name="more-horizontal" size={16} aria-hidden="true" />
|
|
250
|
+
{/snippet}
|
|
251
|
+
</DropdownMenu>
|
|
252
|
+
</div>
|
|
253
|
+
{/if}
|
|
254
|
+
</div>
|
|
255
|
+
{/snippet}
|
|
256
|
+
|
|
257
|
+
<div class={cn('flex h-full min-h-0 flex-col', className)}>
|
|
258
|
+
{#if header}
|
|
259
|
+
<div class="shrink-0 border-b border-border px-3 py-2">{@render header()}</div>
|
|
260
|
+
{/if}
|
|
261
|
+
|
|
262
|
+
<div bind:this={listHost} class="min-h-0 flex-1 overflow-y-auto p-2">
|
|
263
|
+
{#if items.length === 0}
|
|
264
|
+
{#if empty}
|
|
265
|
+
{@render empty()}
|
|
266
|
+
{:else}
|
|
267
|
+
<p class="px-2 py-6 text-center text-sm text-muted-foreground">Nothing here yet.</p>
|
|
268
|
+
{/if}
|
|
269
|
+
{:else if onReorder}
|
|
270
|
+
<SortableList
|
|
271
|
+
{items}
|
|
272
|
+
getKey={(entry) => entry.id}
|
|
273
|
+
{onReorder}
|
|
274
|
+
{label}
|
|
275
|
+
getItemLabel={(entry, index) => entry.title || `item ${index + 1}`}
|
|
276
|
+
class="space-y-0.5"
|
|
277
|
+
>
|
|
278
|
+
{#snippet item({ item: entry, index, handle })}
|
|
279
|
+
{@render row(entry, index, handle as unknown as Record<string, unknown>)}
|
|
280
|
+
{/snippet}
|
|
281
|
+
</SortableList>
|
|
282
|
+
{:else}
|
|
283
|
+
<ul aria-label={label} class="space-y-0.5">
|
|
284
|
+
{#each items as entry, index (entry.id)}
|
|
285
|
+
<li>{@render row(entry, index)}</li>
|
|
286
|
+
{/each}
|
|
287
|
+
</ul>
|
|
288
|
+
{/if}
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
{#if onAdd}
|
|
292
|
+
<div class="shrink-0 border-t border-border p-2">
|
|
293
|
+
<button
|
|
294
|
+
type="button"
|
|
295
|
+
class="flex h-9 w-full items-center justify-center gap-2 rounded-md border border-dashed border-border text-sm font-medium text-muted-foreground hover:border-primary hover:bg-primary/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
296
|
+
onclick={onAdd}
|
|
297
|
+
>
|
|
298
|
+
<Icon name="plus" size={16} aria-hidden="true" />
|
|
299
|
+
{addLabel}
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
{/if}
|
|
303
|
+
</div>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { IconName } from '../Icon.svelte';
|
|
2
|
+
export interface StudioOutlineEntry {
|
|
3
|
+
id: string;
|
|
4
|
+
title: string;
|
|
5
|
+
/** Second line, e.g. the question type or slide layout */
|
|
6
|
+
subtitle?: string;
|
|
7
|
+
icon?: IconName;
|
|
8
|
+
/** Number of problems on this item; shown as a count, not just a colour */
|
|
9
|
+
issues?: number;
|
|
10
|
+
/** Colour of the issue count (default: error) */
|
|
11
|
+
issueSeverity?: 'error' | 'warning';
|
|
12
|
+
/** Dim the row, e.g. a hidden slide */
|
|
13
|
+
muted?: boolean;
|
|
14
|
+
/** Short trailing tag, e.g. "Hidden" or "2 comments" */
|
|
15
|
+
badge?: string;
|
|
16
|
+
}
|
|
17
|
+
import { type Snippet } from 'svelte';
|
|
18
|
+
import { type DropdownMenuItem } from '../DropdownMenu.svelte';
|
|
19
|
+
/**
|
|
20
|
+
* StudioOutline - the Studio's rail: every item in order, the one place to
|
|
21
|
+
* select, reorder and add.
|
|
22
|
+
*
|
|
23
|
+
* Rows reorder by their drag handle (or keyboard, see `SortableList`); the
|
|
24
|
+
* row menu holds duplicate/delete and Move up/down. Arrow keys move between
|
|
25
|
+
* rows. Picking a row inside the phone sheet closes the sheet and opens the
|
|
26
|
+
* inspector. Pass `thumbnail` to show a preview per row (slides).
|
|
27
|
+
*/
|
|
28
|
+
interface Props {
|
|
29
|
+
items: StudioOutlineEntry[];
|
|
30
|
+
/** Selected item id (bindable) */
|
|
31
|
+
selected?: string | null;
|
|
32
|
+
onSelect?: (id: string) => void;
|
|
33
|
+
/** Move item `from` to index `to`; omit to turn reordering off */
|
|
34
|
+
onReorder?: (from: number, to: number) => void;
|
|
35
|
+
/** Row menu items; Move up/down are added for you when `onReorder` is set */
|
|
36
|
+
menuItems?: (entry: StudioOutlineEntry, index: number) => DropdownMenuItem[];
|
|
37
|
+
/** Shows the add button at the bottom of the rail */
|
|
38
|
+
onAdd?: () => void;
|
|
39
|
+
/** Add button text (default: "Add") */
|
|
40
|
+
addLabel?: string;
|
|
41
|
+
/** Accessible name of the list, e.g. "Questions" (default: "Items") */
|
|
42
|
+
label?: string;
|
|
43
|
+
/** Number rows 1, 2, 3... (default: true) */
|
|
44
|
+
numbered?: boolean;
|
|
45
|
+
/** Preview per row, e.g. a slide thumbnail */
|
|
46
|
+
thumbnail?: Snippet<[StudioOutlineEntry, number]>;
|
|
47
|
+
/** Above the list, e.g. a heading or filter */
|
|
48
|
+
header?: Snippet;
|
|
49
|
+
/** Shown when there are no items */
|
|
50
|
+
empty?: Snippet;
|
|
51
|
+
class?: string;
|
|
52
|
+
}
|
|
53
|
+
declare const StudioOutline: import("svelte").Component<Props, {}, "selected">;
|
|
54
|
+
type StudioOutline = ReturnType<typeof StudioOutline>;
|
|
55
|
+
export default StudioOutline;
|