@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.
Files changed (35) hide show
  1. package/dist/__tests__/studio.test.js +223 -0
  2. package/dist/lib/components/Sheet.svelte +155 -0
  3. package/dist/lib/components/Sheet.svelte.d.ts +28 -0
  4. package/dist/lib/components/SortableList.svelte +251 -0
  5. package/dist/lib/components/SortableList.svelte.d.ts +64 -0
  6. package/dist/lib/components/studio/InsertPalette.svelte +192 -0
  7. package/dist/lib/components/studio/InsertPalette.svelte.d.ts +39 -0
  8. package/dist/lib/components/studio/IssuesButton.svelte +141 -0
  9. package/dist/lib/components/studio/IssuesButton.svelte.d.ts +33 -0
  10. package/dist/lib/components/studio/SaveStatus.svelte +110 -0
  11. package/dist/lib/components/studio/SaveStatus.svelte.d.ts +22 -0
  12. package/dist/lib/components/studio/Studio.svelte +189 -0
  13. package/dist/lib/components/studio/Studio.svelte.d.ts +49 -0
  14. package/dist/lib/components/studio/StudioBar.svelte +168 -0
  15. package/dist/lib/components/studio/StudioBar.svelte.d.ts +46 -0
  16. package/dist/lib/components/studio/StudioCanvas.svelte +87 -0
  17. package/dist/lib/components/studio/StudioCanvas.svelte.d.ts +30 -0
  18. package/dist/lib/components/studio/StudioCanvasItem.svelte +121 -0
  19. package/dist/lib/components/studio/StudioCanvasItem.svelte.d.ts +29 -0
  20. package/dist/lib/components/studio/StudioInspector.svelte +133 -0
  21. package/dist/lib/components/studio/StudioInspector.svelte.d.ts +37 -0
  22. package/dist/lib/components/studio/StudioInspectorSection.svelte +79 -0
  23. package/dist/lib/components/studio/StudioInspectorSection.svelte.d.ts +24 -0
  24. package/dist/lib/components/studio/StudioOutline.svelte +303 -0
  25. package/dist/lib/components/studio/StudioOutline.svelte.d.ts +55 -0
  26. package/dist/lib/components/studio/StudioPage.svelte +36 -0
  27. package/dist/lib/components/studio/StudioPage.svelte.d.ts +18 -0
  28. package/dist/lib/components/studio/context.d.ts +25 -0
  29. package/dist/lib/components/studio/context.js +9 -0
  30. package/dist/lib/components/studio/index.d.ts +12 -0
  31. package/dist/lib/components/studio/index.js +12 -0
  32. package/dist/lib/index.d.ts +3 -0
  33. package/dist/lib/index.js +6 -0
  34. package/dist/lib/schemas/common.d.ts +2 -2
  35. package/package.json +1 -1
@@ -0,0 +1,64 @@
1
+ /** Attributes to spread on the element that acts as an item's drag handle */
2
+ export interface SortableHandleProps {
3
+ type: 'button';
4
+ 'aria-label': string;
5
+ 'aria-roledescription': string;
6
+ 'aria-describedby': string;
7
+ 'aria-pressed': boolean;
8
+ 'data-sortable-handle': string;
9
+ onpointerdown: (event: PointerEvent) => void;
10
+ onpointermove: (event: PointerEvent) => void;
11
+ onpointerup: (event: PointerEvent) => void;
12
+ onpointercancel: (event: PointerEvent) => void;
13
+ onkeydown: (event: KeyboardEvent) => void;
14
+ onblur: () => void;
15
+ }
16
+ export interface SortableItemContext<T> {
17
+ item: T;
18
+ index: number;
19
+ /** Spread on a `<button>` inside the item: `<button {...handle}>` */
20
+ handle: SortableHandleProps;
21
+ /** The item is being dragged or is picked up by the keyboard */
22
+ dragging: boolean;
23
+ }
24
+ import { type Snippet } from 'svelte';
25
+ declare function $$render<T>(): {
26
+ props: {
27
+ items: T[];
28
+ /** Stable key for each item */
29
+ getKey: (item: T, index: number) => string | number;
30
+ /** Move the item at `from` so it ends up at index `to` */
31
+ onReorder: (from: number, to: number) => void;
32
+ /** Renders one item; spread `handle` on its drag-handle button */
33
+ item: Snippet<[SortableItemContext<T>]>;
34
+ /** Accessible name of the list, e.g. "Questions" */
35
+ label: string;
36
+ /** Name used in announcements and handle labels (default: the 1-based position) */
37
+ getItemLabel?: (item: T, index: number) => string;
38
+ /** Disables reordering; handles stay rendered but do nothing */
39
+ disabled?: boolean;
40
+ class?: string;
41
+ itemClass?: string;
42
+ };
43
+ exports: {};
44
+ bindings: "";
45
+ slots: {};
46
+ events: {};
47
+ };
48
+ declare class __sveltets_Render<T> {
49
+ props(): ReturnType<typeof $$render<T>>['props'];
50
+ events(): ReturnType<typeof $$render<T>>['events'];
51
+ slots(): ReturnType<typeof $$render<T>>['slots'];
52
+ bindings(): "";
53
+ exports(): {};
54
+ }
55
+ interface $$IsomorphicComponent {
56
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
57
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
58
+ } & ReturnType<__sveltets_Render<T>['exports']>;
59
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
60
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
61
+ }
62
+ declare const SortableList: $$IsomorphicComponent;
63
+ type SortableList<T> = InstanceType<typeof SortableList<T>>;
64
+ export default SortableList;
@@ -0,0 +1,192 @@
1
+ <script lang="ts" module>
2
+ import type { IconName } from '../Icon.svelte';
3
+
4
+ export interface InsertPaletteItem {
5
+ id: string;
6
+ label: string;
7
+ /** One line on when to use it: "Pick one answer from a short list" */
8
+ description?: string;
9
+ icon?: IconName;
10
+ disabled?: boolean;
11
+ /** Extra search terms, e.g. ["radio", "single"] */
12
+ keywords?: string[];
13
+ }
14
+
15
+ export interface InsertPaletteGroup {
16
+ label: string;
17
+ items: InsertPaletteItem[];
18
+ }
19
+ </script>
20
+
21
+ <script lang="ts">
22
+ import { tick, type Snippet } from 'svelte';
23
+ import { cn } from '../../utils.js';
24
+ import Dialog from '../Dialog.svelte';
25
+ import Icon from '../Icon.svelte';
26
+
27
+ /**
28
+ * InsertPalette - the one way to add something to a Studio: a searchable,
29
+ * grouped picker of question types, slide layouts, blocks...
30
+ *
31
+ * Every add button (rail, canvas "+", empty state) opens the same palette,
32
+ * so there is one place to learn. Each entry says when to use it; pass
33
+ * `thumbnail` to show a picture of it (slide layouts). Type to filter,
34
+ * Enter adds the first match, arrow keys move between entries.
35
+ */
36
+ interface Props {
37
+ open?: boolean;
38
+ groups: InsertPaletteGroup[];
39
+ onSelect: (id: string) => void;
40
+ /** Dialog title (default: "Add") */
41
+ title?: string;
42
+ description?: string;
43
+ searchPlaceholder?: string;
44
+ /** Picture per entry; switches to a grid of cards */
45
+ thumbnail?: Snippet<[InsertPaletteItem]>;
46
+ }
47
+
48
+ let {
49
+ open = $bindable(false),
50
+ groups,
51
+ onSelect,
52
+ title = 'Add',
53
+ description,
54
+ searchPlaceholder = 'Search',
55
+ thumbnail,
56
+ }: Props = $props();
57
+
58
+ let query = $state('');
59
+ let listEl: HTMLDivElement | undefined = $state();
60
+ let searchEl: HTMLInputElement | undefined = $state();
61
+
62
+ const filtered = $derived.by(() => {
63
+ const q = query.trim().toLowerCase();
64
+ if (!q) return groups;
65
+ return groups
66
+ .map((group) => ({
67
+ ...group,
68
+ items: group.items.filter((item) =>
69
+ [item.label, item.description ?? '', ...(item.keywords ?? [])].some((text) =>
70
+ text.toLowerCase().includes(q)
71
+ )
72
+ ),
73
+ }))
74
+ .filter((group) => group.items.length > 0);
75
+ });
76
+
77
+ const firstMatch = $derived(filtered.flatMap((g) => g.items).find((item) => !item.disabled));
78
+
79
+ $effect(() => {
80
+ if (!open) return;
81
+ query = '';
82
+ void tick().then(() => searchEl?.focus());
83
+ });
84
+
85
+ function choose(item: InsertPaletteItem) {
86
+ if (item.disabled) return;
87
+ open = false;
88
+ onSelect(item.id);
89
+ }
90
+
91
+ function entries() {
92
+ return Array.from(listEl?.querySelectorAll<HTMLButtonElement>('[data-palette-item]') ?? []);
93
+ }
94
+
95
+ function searchKeydown(event: KeyboardEvent) {
96
+ if (event.key === 'Enter' && firstMatch) {
97
+ event.preventDefault();
98
+ choose(firstMatch);
99
+ } else if (event.key === 'ArrowDown') {
100
+ event.preventDefault();
101
+ entries()[0]?.focus();
102
+ }
103
+ }
104
+
105
+ function entryKeydown(event: KeyboardEvent) {
106
+ const all = entries();
107
+ const index = all.indexOf(event.currentTarget as HTMLButtonElement);
108
+ let next = -1;
109
+ if (event.key === 'ArrowDown' || event.key === 'ArrowRight') next = index + 1;
110
+ else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') next = index - 1;
111
+ else return;
112
+ event.preventDefault();
113
+ if (next < 0) searchEl?.focus();
114
+ else all[Math.min(next, all.length - 1)]?.focus();
115
+ }
116
+ </script>
117
+
118
+ <Dialog bind:open {title} {description} size={thumbnail ? '2xl' : 'xl'}>
119
+ <div class="space-y-4 pb-4">
120
+ <div class="relative">
121
+ <Icon
122
+ name="search"
123
+ size={16}
124
+ class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
125
+ aria-hidden="true"
126
+ />
127
+ <input
128
+ bind:this={searchEl}
129
+ bind:value={query}
130
+ type="search"
131
+ aria-label={searchPlaceholder}
132
+ placeholder={searchPlaceholder}
133
+ class="h-10 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
134
+ onkeydown={searchKeydown}
135
+ />
136
+ </div>
137
+
138
+ <div bind:this={listEl} class="space-y-5">
139
+ {#each filtered as group (group.label)}
140
+ <section aria-label={group.label}>
141
+ <h3 class="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
142
+ {group.label}
143
+ </h3>
144
+ <ul
145
+ class={cn(
146
+ 'grid gap-2',
147
+ thumbnail ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-1 sm:grid-cols-2'
148
+ )}
149
+ >
150
+ {#each group.items as item (item.id)}
151
+ <li>
152
+ <button
153
+ type="button"
154
+ data-palette-item
155
+ disabled={item.disabled}
156
+ class={cn(
157
+ 'flex h-full w-full gap-3 rounded-md border border-border p-3 text-left hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
158
+ thumbnail ? 'flex-col' : 'items-start'
159
+ )}
160
+ onclick={() => choose(item)}
161
+ onkeydown={entryKeydown}
162
+ >
163
+ {#if thumbnail}
164
+ <span class="block overflow-hidden rounded border border-border bg-muted">
165
+ {@render thumbnail(item)}
166
+ </span>
167
+ {:else if item.icon}
168
+ <span
169
+ class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-foreground"
170
+ >
171
+ <Icon name={item.icon} size={16} aria-hidden="true" />
172
+ </span>
173
+ {/if}
174
+ <span class="min-w-0">
175
+ <span class="block text-sm font-medium">{item.label}</span>
176
+ {#if item.description}
177
+ <span class="mt-0.5 block text-xs text-muted-foreground">
178
+ {item.description}
179
+ </span>
180
+ {/if}
181
+ </span>
182
+ </button>
183
+ </li>
184
+ {/each}
185
+ </ul>
186
+ </section>
187
+ {:else}
188
+ <p class="py-8 text-center text-sm text-muted-foreground">Nothing matches “{query}”.</p>
189
+ {/each}
190
+ </div>
191
+ </div>
192
+ </Dialog>
@@ -0,0 +1,39 @@
1
+ import type { IconName } from '../Icon.svelte';
2
+ export interface InsertPaletteItem {
3
+ id: string;
4
+ label: string;
5
+ /** One line on when to use it: "Pick one answer from a short list" */
6
+ description?: string;
7
+ icon?: IconName;
8
+ disabled?: boolean;
9
+ /** Extra search terms, e.g. ["radio", "single"] */
10
+ keywords?: string[];
11
+ }
12
+ export interface InsertPaletteGroup {
13
+ label: string;
14
+ items: InsertPaletteItem[];
15
+ }
16
+ import { type Snippet } from 'svelte';
17
+ /**
18
+ * InsertPalette - the one way to add something to a Studio: a searchable,
19
+ * grouped picker of question types, slide layouts, blocks...
20
+ *
21
+ * Every add button (rail, canvas "+", empty state) opens the same palette,
22
+ * so there is one place to learn. Each entry says when to use it; pass
23
+ * `thumbnail` to show a picture of it (slide layouts). Type to filter,
24
+ * Enter adds the first match, arrow keys move between entries.
25
+ */
26
+ interface Props {
27
+ open?: boolean;
28
+ groups: InsertPaletteGroup[];
29
+ onSelect: (id: string) => void;
30
+ /** Dialog title (default: "Add") */
31
+ title?: string;
32
+ description?: string;
33
+ searchPlaceholder?: string;
34
+ /** Picture per entry; switches to a grid of cards */
35
+ thumbnail?: Snippet<[InsertPaletteItem]>;
36
+ }
37
+ declare const InsertPalette: import("svelte").Component<Props, {}, "open">;
38
+ type InsertPalette = ReturnType<typeof InsertPalette>;
39
+ export default InsertPalette;
@@ -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;