canopycms 0.2.0 → 0.3.1

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 (74) hide show
  1. package/CHANGELOG.md +213 -41
  2. package/dist/api/branch.d.ts +2 -2
  3. package/dist/api/branch.js +4 -31
  4. package/dist/authorization/branch.js +3 -4
  5. package/dist/cli/cli.js +0 -18
  6. package/dist/cli/generate-ai-content.js +0 -6
  7. package/dist/cli/init.js +0 -6
  8. package/dist/editor/BranchManager.d.ts +2 -0
  9. package/dist/editor/BranchManager.js +3 -3
  10. package/dist/editor/Editor.js +253 -128
  11. package/dist/editor/EntryNavigator.d.ts +44 -9
  12. package/dist/editor/EntryNavigator.js +275 -103
  13. package/dist/editor/branch-lock.d.ts +42 -0
  14. package/dist/editor/branch-lock.js +79 -0
  15. package/dist/editor/branch-status.d.ts +0 -6
  16. package/dist/editor/branch-status.js +0 -10
  17. package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
  18. package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
  19. package/dist/editor/components/BranchPill.d.ts +3 -18
  20. package/dist/editor/components/BranchPill.js +7 -10
  21. package/dist/editor/components/EditorHeader.d.ts +24 -52
  22. package/dist/editor/components/EditorHeader.js +151 -71
  23. package/dist/editor/components/EditorRail.d.ts +8 -8
  24. package/dist/editor/components/EditorRail.js +7 -5
  25. package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
  26. package/dist/editor/components/EntryBreadcrumb.js +3 -3
  27. package/dist/editor/components/EntryHeader.d.ts +60 -0
  28. package/dist/editor/components/EntryHeader.js +45 -0
  29. package/dist/editor/components/EntrySaveButton.d.ts +24 -0
  30. package/dist/editor/components/EntrySaveButton.js +101 -0
  31. package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
  32. package/dist/editor/components/NoEditPermissionNotice.js +1 -1
  33. package/dist/editor/components/RetryToastMessage.d.ts +3 -1
  34. package/dist/editor/components/RetryToastMessage.js +8 -4
  35. package/dist/editor/components/SaveAllButton.d.ts +25 -0
  36. package/dist/editor/components/SaveAllButton.js +81 -0
  37. package/dist/editor/components/UnsavedMark.d.ts +1 -1
  38. package/dist/editor/components/UnsavedMark.js +1 -1
  39. package/dist/editor/components/index.d.ts +1 -1
  40. package/dist/editor/components/index.js +1 -1
  41. package/dist/editor/copy.d.ts +0 -1
  42. package/dist/editor/copy.js +0 -1
  43. package/dist/editor/editor-utils.d.ts +2 -2
  44. package/dist/editor/editor-utils.js +2 -2
  45. package/dist/editor/entry-search.d.ts +10 -16
  46. package/dist/editor/entry-search.js +4 -24
  47. package/dist/editor/hooks/useBranchManager.d.ts +7 -3
  48. package/dist/editor/hooks/useBranchManager.js +13 -9
  49. package/dist/editor/hooks/useEntryManager.d.ts +3 -0
  50. package/dist/editor/hooks/useEntryManager.js +10 -3
  51. package/dist/editor/shell/ActivityRail.d.ts +4 -0
  52. package/dist/editor/shell/ActivityRail.js +13 -4
  53. package/dist/editor/shell/EditorShell.d.ts +6 -7
  54. package/dist/editor/shell/EditorShell.js +9 -13
  55. package/dist/editor/shell/PaneLayout.d.ts +1 -0
  56. package/dist/editor/shell/PaneLayout.js +23 -5
  57. package/dist/editor/shell/SidePanelHost.d.ts +11 -4
  58. package/dist/editor/shell/SidePanelHost.js +95 -7
  59. package/dist/editor/shell/TopBar.d.ts +4 -4
  60. package/dist/editor/shell/TopBar.js +6 -3
  61. package/dist/editor/utils/focus-return.d.ts +10 -2
  62. package/dist/editor/utils/focus-return.js +40 -6
  63. package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
  64. package/dist/operating-mode/client-safe-strategy.js +1 -10
  65. package/dist/operating-mode/types.d.ts +0 -3
  66. package/dist/version.d.ts +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +1 -1
  69. package/dist/editor/components/BranchLockBanner.d.ts +0 -23
  70. package/dist/editor/components/BranchLockBanner.js +0 -66
  71. package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
  72. package/dist/editor/components/QuickOpenPalette.js +0 -71
  73. package/dist/editor/components/SaveSplitButton.d.ts +0 -31
  74. package/dist/editor/components/SaveSplitButton.js +0 -121
@@ -1,66 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Alert, Box, Button, Group, Text } from '@mantine/core';
3
- import { IconLock } from '@tabler/icons-react';
4
- import { EDITOR_ACTIONS } from '../copy.js';
5
- /**
6
- * Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
7
- * nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
8
- * base-branch read-only takes precedence over a workflow status lock.
9
- */
10
- function branchLockState({ branchStatus, branchReadOnly = false, branchWriteBlocked = false, }) {
11
- const statusLocked = branchWriteBlocked && !branchReadOnly;
12
- // `branchStatus` can't describe the lock while branch data loads (undefined) or under version
13
- // skew (an old server omits `branchWriteBlocked` but reports 'editing'), so both read as
14
- // "details unavailable" rather than naming a status the lock contradicts.
15
- const branchDataUnavailable = statusLocked && (branchStatus === undefined || branchStatus === 'editing');
16
- return { statusLocked, branchDataUnavailable };
17
- }
18
- /**
19
- * Whether the banner renders; the top bar reads the same answer, so the two cannot drift.
20
- * @internal Exported for tests.
21
- */
22
- export function branchLockBannerShown(lock) {
23
- return !!lock.branchReadOnly || branchLockState(lock).statusLocked;
24
- }
25
- /**
26
- * Why the branch takes no writes, with the way forward; undefined when it does take them. The
27
- * top bar's Save and the navigator's row locks say this; the banner describes the same lock.
28
- */
29
- export function describeBranchLock(lock) {
30
- if (!branchLockBannerShown(lock))
31
- return undefined;
32
- const { statusLocked, branchDataUnavailable } = branchLockState(lock);
33
- if (lock.branchReadOnly)
34
- return 'The base branch is read-only. Create a branch to make changes.';
35
- if (branchDataUnavailable)
36
- return 'Branch details could not be loaded. Use "Switch branch…" to retry.';
37
- if (!statusLocked)
38
- return undefined;
39
- if (lock.branchStatus === 'submitted')
40
- return 'This branch is in review. Withdraw it to make changes.';
41
- if (lock.branchStatus === 'approved')
42
- return 'This branch is approved. Withdraw it to make changes.';
43
- return `This branch is ${lock.branchStatus}. Its content is read-only.`;
44
- }
45
- /** The banner region's explanation of a read-only branch; not dismissible while it applies. */
46
- export function BranchLockBanner({ branchName, onBranchManagerOpen, ...lock }) {
47
- const { branchDataUnavailable } = branchLockState(lock);
48
- if (!branchLockBannerShown(lock))
49
- return null;
50
- const [testId, message, action] = lock.branchReadOnly
51
- ? [
52
- 'protected-branch-banner',
53
- `You are viewing the protected base branch "${branchName}". Content is read-only — create a branch to make changes.`,
54
- `${EDITOR_ACTIONS.newBranch}…`,
55
- ]
56
- : [
57
- 'status-locked-banner',
58
- branchDataUnavailable
59
- ? `Branch details for "${branchName}" could not be loaded, so content is locked until they arrive. Use Switch branch… to retry.`
60
- : lock.branchStatus === 'submitted'
61
- ? `Branch "${branchName}" is submitted for review and locked for edits. Withdraw it from review to make changes.`
62
- : `Branch "${branchName}" is ${lock.branchStatus} — content is read-only.`,
63
- `${EDITOR_ACTIONS.switchBranch}…`,
64
- ];
65
- return (_jsx(Box, { px: "md", py: "sm", children: _jsx(Alert, { icon: _jsx(IconLock, { size: 16 }), color: "yellow", variant: "light", "data-testid": testId, children: _jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "wrap", children: [_jsx(Text, { size: "sm", children: message }), _jsx(Button, { variant: "default", onClick: onBranchManagerOpen, children: action })] }) }) }));
66
- }
@@ -1,28 +0,0 @@
1
- /** @internal Exported for tests. Marks an overlay Mod+P may open over: the palette's own closing dialog. */
2
- export declare const QUICK_OPEN_REPLACES_ATTR = "data-quick-open-replaces";
3
- /** One option: an entry, with the type and route its second line shows. */
4
- export interface QuickOpenEntry {
5
- path: string;
6
- label: string;
7
- /** The entry's title, matched by the search where the label is a singleton's type label. */
8
- title?: string;
9
- slug?: string;
10
- collectionPath?: string;
11
- collectionName?: string;
12
- /** The entry's type (`entryTypeLine`); absent for a singleton, whose label already names it. */
13
- typeLabel?: string;
14
- /** The entry's route on the site (`buildPreviewRoute`); absent when it has no page. */
15
- route?: string;
16
- }
17
- export interface QuickOpenPaletteProps {
18
- opened: boolean;
19
- onOpen: () => void;
20
- onClose: () => void;
21
- entries: ReadonlyArray<QuickOpenEntry>;
22
- /** Display name for a collection heading; the collection's own name when absent, none when ''. */
23
- collectionLabel?: (collectionPath: string) => string | undefined;
24
- onSelect: (path: string) => void;
25
- /** Names the element that takes focus after the palette closes, for a caller that unmounted the opener. */
26
- returnFocusTo?: () => HTMLElement | null;
27
- }
28
- export declare function QuickOpenPalette({ opened, onOpen, onClose, entries, collectionLabel, onSelect, returnFocusTo, }: QuickOpenPaletteProps): import("react/jsx-runtime").JSX.Element;
@@ -1,71 +0,0 @@
1
- 'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- /** Mod+P palette: type part of an entry's title or slug, pick it with the keyboard or mouse. */
4
- import { useEffect, useMemo, useState } from 'react';
5
- import { Combobox, Highlight, Modal, ScrollArea, Text, TextInput, useCombobox } from '@mantine/core';
6
- import { useHotkeys } from '@mantine/hooks';
7
- import { IconSearch } from '@tabler/icons-react';
8
- import { filterEntries, groupEntriesByCollection } from '../entry-search.js';
9
- import { modKeyLabel } from '../platform.js';
10
- import { EDITOR_ACTIONS } from '../copy.js';
11
- import { FOCUS_RETURN_ATTR, focusIfIdle } from '../utils/focus-return.js';
12
- /** @internal Exported for tests. Marks an overlay Mod+P may open over: the palette's own closing dialog. */
13
- export const QUICK_OPEN_REPLACES_ATTR = 'data-quick-open-replaces';
14
- /**
15
- * True while a modal or drawer (not a popover) is open that `QUICK_OPEN_REPLACES_ATTR` doesn't mark.
16
- * Overlays stack in DOM order and the palette's portal precedes the editor's other dialogs, so it
17
- * would open hidden under them and take the keyboard from the one in use.
18
- */
19
- const otherOverlayOpen = () => Array.from(document.querySelectorAll('[role="dialog"][aria-modal="true"]')).some((dialog) => !dialog.closest(`[${QUICK_OPEN_REPLACES_ATTR}]`));
20
- /** Options rendered at once; the rest are reached by typing more. */
21
- const MAX_OPTIONS = 50;
22
- /** An option's second line: its type and its route, whichever it has. */
23
- const detailLine = (entry) => [entry.typeLabel, entry.route].filter(Boolean).join(' · ');
24
- export function QuickOpenPalette({ opened, onOpen, onClose, entries, collectionLabel, onSelect, returnFocusTo, }) {
25
- // `[]` ignores no tags and `true` fires from contenteditable, so the shortcut works from inside
26
- // a text field or the MDX editor.
27
- useHotkeys([
28
- [
29
- 'mod+P',
30
- (event) => {
31
- // Mantine's `mod` is Ctrl or ⌘ everywhere; on macOS Ctrl+P moves the caret up a line.
32
- if (modKeyLabel() === '⌘' && !event.metaKey)
33
- return;
34
- // Keeps the browser's print dialog shut, even when the palette can't open.
35
- event.preventDefault();
36
- if (!otherOverlayOpen())
37
- onOpen();
38
- },
39
- { preventDefault: false },
40
- ],
41
- ], [], true);
42
- const [query, setQuery] = useState('');
43
- const [wasOpened, setWasOpened] = useState(opened);
44
- if (opened !== wasOpened) {
45
- setWasOpened(opened);
46
- if (!opened)
47
- setQuery('');
48
- }
49
- // Always open: the options are the palette's body, not a dropdown below a field.
50
- const combobox = useCombobox({ opened: true });
51
- const { selectFirstOption } = combobox;
52
- const matches = useMemo(() => filterEntries(entries, query), [entries, query]);
53
- const shown = useMemo(() => matches.slice(0, MAX_OPTIONS), [matches]);
54
- const groups = useMemo(() => groupEntriesByCollection(shown, collectionLabel), [shown, collectionLabel]);
55
- // Enter right after typing opens the best match, so the first option is always armed. Deferred
56
- // a tick: on opening, the modal's portal and the options list mount after this effect.
57
- useEffect(() => {
58
- if (!opened)
59
- return;
60
- const timer = setTimeout(selectFirstOption, 0);
61
- return () => clearTimeout(timer);
62
- }, [opened, query, selectFirstOption]);
63
- const handleSubmit = (path) => {
64
- onSelect(path);
65
- onClose();
66
- };
67
- return (_jsx(Modal, { opened: opened, onClose: onClose, title: EDITOR_ACTIONS.openEntry, size: "lg", yOffset: "10vh", returnFocus: !returnFocusTo, onExitTransitionEnd: () => returnFocusTo && focusIfIdle(returnFocusTo()), [QUICK_OPEN_REPLACES_ATTR]: true, [FOCUS_RETURN_ATTR]: true, children: _jsxs(Combobox, { store: combobox, onOptionSubmit: handleSubmit, children: [_jsx(Combobox.EventsTarget, { withExpandedAttribute: true, "data-mantine-stop-propagation": false, children: _jsx(TextInput, { role: "combobox", "aria-label": "Search entries", "aria-autocomplete": "list", placeholder: "Type an entry title or slug", leftSection: _jsx(IconSearch, { size: 14, "aria-hidden": true }), value: query, onChange: (event) => setQuery(event.currentTarget.value), "data-autofocus": true, "data-testid": "quick-open-input" }) }), _jsx(Combobox.Options, { mt: "sm", "aria-label": "Entries", children: _jsx(ScrollArea.Autosize, { mah: 360, type: "scroll", scrollbars: "y", children: matches.length === 0 ? (_jsxs(Text, { size: "sm", c: "dimmed", p: "xs", "data-testid": "quick-open-empty", children: ["No entries match \u201C", query, "\u201D."] })) : (groups.map((group, index) => (_jsx(Combobox.Group, { label: group.heading || undefined, children: group.entries.map((entry) => {
68
- const detail = detailLine(entry);
69
- return (_jsxs(Combobox.Option, { value: entry.path, "data-testid": "quick-open-option", children: [_jsx(Highlight, { highlight: query.trim(), size: "sm", children: entry.label }), detail && (_jsx(Text, { size: "xs", "data-testid": "quick-open-option-detail", children: detail }))] }, entry.path));
70
- }) }, index)))) }) }), matches.length > MAX_OPTIONS && (_jsxs(Text, { size: "xs", c: "dimmed", mt: "xs", children: ["Showing ", MAX_OPTIONS, " of ", matches.length, ". Keep typing to narrow the list."] }))] }) }));
71
- }
@@ -1,31 +0,0 @@
1
- import type { SaveAllProgress } from '../hooks/save-all.js';
2
- export interface SaveSplitButtonProps {
3
- onSave: () => void;
4
- /** Why Save can't act now; empty when it can. */
5
- saveBlockedReason: string;
6
- /** The editor is loading or saving: Save waits without a reason. */
7
- busy: boolean;
8
- onSaveAll: () => void;
9
- /** Entries on the branch with unsaved changes. */
10
- unsavedCount: number;
11
- /** Why Save all can't act now, other than nothing being unsaved. */
12
- saveAllBlockedReason?: string;
13
- /** Set while Save all runs. */
14
- saveAllProgress: SaveAllProgress | null;
15
- /** A Save of the open entry is in flight. */
16
- saving?: boolean;
17
- /** Rises each time a single Save lands, whichever entry it saved. */
18
- savedSignal?: number;
19
- /** The last Save that landed saved the open entry. */
20
- savedHere?: boolean;
21
- /** The open entry has unsaved changes. */
22
- entryDirty: boolean;
23
- /** Writing drafts to localStorage failed, so they live only in this tab. */
24
- draftStorageFailed: boolean;
25
- }
26
- /**
27
- * Save with a caret offering "Save all". While the open entry has unsaved changes, Save's Tooltip
28
- * and description say they are kept on this device. Only when keeping them failed does the bar
29
- * carry a warning before Save, icon-only below `lg`, its text then its Tooltip and accessible name.
30
- */
31
- export declare function SaveSplitButton({ onSave, saveBlockedReason, busy, onSaveAll, unsavedCount, saveAllBlockedReason, saveAllProgress, saving, savedSignal, savedHere, entryDirty, draftStorageFailed, }: SaveSplitButtonProps): import("react/jsx-runtime").JSX.Element;
@@ -1,121 +0,0 @@
1
- 'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useRef, useState } from 'react';
4
- import { Box, Button, Loader, Menu, Text, Tooltip, VisuallyHidden } from '@mantine/core';
5
- import { useReducedMotion } from '@mantine/hooks';
6
- import { IconAlertTriangle, IconCheck, IconChevronDown, IconLoader2 } from '@tabler/icons-react';
7
- import { EDITOR_ACTIONS } from '../copy.js';
8
- import { ReasonedMenuItem } from './ReasonedMenuItem.js';
9
- import { useMenuItemFocusReturn } from '../utils/focus-return.js';
10
- const STORAGE_FAILED_TEXT = 'Unsaved · not kept on this device';
11
- const STORAGE_FAILED_HINT = "Save before you leave or reload, or they'll be lost.";
12
- const KEPT_HINT = 'Unsaved changes are kept on this device until you save.';
13
- // Visually hidden, as Mantine's VisuallyHidden, for a region that must stay mounted.
14
- const HIDDEN_STYLE = {
15
- position: 'absolute',
16
- width: 1,
17
- height: 1,
18
- overflow: 'hidden',
19
- clip: 'rect(0 0 0 0)',
20
- whiteSpace: 'nowrap',
21
- };
22
- const SAVED_SHOWN_MS = 2000;
23
- // Below `sm` the status words are visually hidden, so Save keeps its own width beside an icon.
24
- const LABEL_TEXT_CLASS = 'canopy-save-label-text';
25
- const LABEL_STYLES = `
26
- @media (max-width: 47.99em) {
27
- .${LABEL_TEXT_CLASS} {
28
- position: absolute; width: 1px; height: 1px; overflow: hidden;
29
- clip: rect(0 0 0 0); white-space: nowrap;
30
- }
31
- }
32
- `;
33
- /**
34
- * Every label in one grid cell, only `shown` visible, so the button keeps the widest one's width
35
- * and the top bar never shifts.
36
- */
37
- function SaveLabels({ shown, spinner }) {
38
- const labels = {
39
- save: EDITOR_ACTIONS.save,
40
- saving: (_jsxs(_Fragment, { children: [_jsx(Box, { component: "span", display: "inline-flex", w: 14, children: shown === 'saving' &&
41
- (spinner ? (_jsx(Loader, { size: 14, color: "currentColor" })) : (_jsx(IconLoader2, { size: 14, "aria-hidden": "true" }))) }), _jsx("span", { className: LABEL_TEXT_CLASS, children: "Saving\u2026" })] })),
42
- saved: (_jsxs(_Fragment, { children: [_jsx(IconCheck, { size: 14, "aria-hidden": "true" }), _jsx("span", { className: LABEL_TEXT_CLASS, children: "Saved" })] })),
43
- };
44
- return (_jsx(Box, { component: "span", pos: "relative", style: { display: 'inline-grid' }, children: Object.keys(labels).map((key) => (_jsx(Box, { component: "span", "aria-hidden": key === shown ? undefined : true, style: {
45
- gridArea: '1 / 1',
46
- display: 'inline-flex',
47
- alignItems: 'center',
48
- justifyContent: 'center',
49
- gap: 6,
50
- visibility: key === shown ? 'visible' : 'hidden',
51
- }, children: labels[key] }, key))) }));
52
- }
53
- /**
54
- * Save with a caret offering "Save all". While the open entry has unsaved changes, Save's Tooltip
55
- * and description say they are kept on this device. Only when keeping them failed does the bar
56
- * carry a warning before Save, icon-only below `lg`, its text then its Tooltip and accessible name.
57
- */
58
- export function SaveSplitButton({ onSave, saveBlockedReason, busy, onSaveAll, unsavedCount, saveAllBlockedReason, saveAllProgress, saving = false, savedSignal, savedHere = false, entryDirty, draftStorageFailed, }) {
59
- const caret = useRef(null);
60
- const keptHintId = useId();
61
- const menuFocus = useMenuItemFocusReturn(caret);
62
- const reducedMotion = useReducedMotion();
63
- // Each landed Save shows "Saved" once, as it lands and only on its own entry: one that landed
64
- // before mount or while another entry was open shows nothing later.
65
- const [seenSignal, setSeenSignal] = useState(savedSignal ?? 0);
66
- const [flashing, setFlashing] = useState();
67
- // Leaving the saved entry ends its "Saved", so a quick return doesn't show or announce it again.
68
- const [wasHere, setWasHere] = useState(savedHere);
69
- if (wasHere !== savedHere) {
70
- setWasHere(savedHere);
71
- if (!savedHere)
72
- setFlashing(undefined);
73
- }
74
- if (savedSignal !== undefined && savedSignal > seenSignal) {
75
- setSeenSignal(savedSignal);
76
- setFlashing(savedHere ? savedSignal : undefined);
77
- }
78
- useEffect(() => {
79
- if (flashing === undefined)
80
- return;
81
- const timer = setTimeout(() => setFlashing(undefined), SAVED_SHOWN_MS);
82
- return () => clearTimeout(timer);
83
- }, [flashing]);
84
- // Blocked, Save stays focusable (`aria-disabled`, not `disabled`), so its reason opens on focus.
85
- const saveBlocked = !!saveBlockedReason || busy || !!saveAllProgress;
86
- const label = saving
87
- ? 'saving'
88
- : flashing !== undefined &&
89
- flashing === savedSignal &&
90
- savedHere &&
91
- !entryDirty &&
92
- !saveAllProgress
93
- ? 'saved'
94
- : 'save';
95
- const saveGreyed = saveBlocked && !saveAllProgress && label === 'save';
96
- const storageWarning = entryDirty && draftStorageFailed;
97
- // Why Save is blocked, else where unsaved changes are held; a blocked Save's reason leads.
98
- const keptHint = entryDirty && !draftStorageFailed && !saveBlockedReason ? KEPT_HINT : '';
99
- const saveTooltip = saveBlockedReason || keptHint;
100
- return (_jsxs(_Fragment, { children: [_jsx(Box, { role: "status", ta: "right", w: { base: 20, lg: 112 }, style: storageWarning ? { flexShrink: 0 } : HIDDEN_STYLE, children: storageWarning && (_jsx(Tooltip, { label: `${STORAGE_FAILED_TEXT}. ${STORAGE_FAILED_HINT}`, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", lh: 1.2, c: "var(--canopy-danger-text)", "data-testid": "unsaved-indicator", children: [_jsx(Box, { component: "span", visibleFrom: "lg", children: STORAGE_FAILED_TEXT }), _jsx(Box, { component: "span", hiddenFrom: "lg", display: "inline-flex", "aria-hidden": "true", children: _jsx(IconAlertTriangle, { size: 18 }) }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "lg", children: STORAGE_FAILED_TEXT }), _jsxs(VisuallyHidden, { component: "span", children: [". ", STORAGE_FAILED_HINT] })] }) })) }), _jsx(VisuallyHidden, { role: "status", children: saveAllProgress
101
- ? `Saving ${saveAllProgress.total} ${saveAllProgress.total === 1 ? 'entry' : 'entries'}…`
102
- : label === 'saved'
103
- ? 'Saved'
104
- : '' }), keptHint && (_jsx("span", { hidden: true, id: keptHintId, children: keptHint })), _jsx("style", { children: LABEL_STYLES }), _jsxs(Button.Group, { style: { flexShrink: 0 }, children: [_jsx(Tooltip, { label: saveTooltip,
105
- // Not beside "Saving…" or "Saved", which already say what happened.
106
- disabled: !saveTooltip || busy || label !== 'save', multiline: true, maw: 280, children: _jsx(Button, { "data-testid": "save-button", variant: "filled", size: "sm", leftSection: saveAllProgress && !reducedMotion ? (_jsx(Loader, { size: 14, color: "currentColor" })) : undefined, onClick: () => {
107
- if (!saveBlocked)
108
- onSave();
109
- }, "data-disabled": saveGreyed ? true : undefined, "aria-disabled": saveBlocked ? true : undefined, ...(keptHint ? { 'aria-describedby': keptHintId } : {}), children: saveAllProgress ? (`Saving ${Math.min(saveAllProgress.done + 1, saveAllProgress.total)} of ${saveAllProgress.total}…`) : (_jsx(SaveLabels, { shown: label, spinner: !reducedMotion })) }) }), _jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", ...menuFocus.menu, children: [_jsx(Tooltip, { label: "Save options", events: { hover: true, focus: true, touch: false }, children: _jsx(Menu.Target, { children: _jsx(Button, { ref: caret,
110
- // Light beside a greyed Save, so a blocked split button shows no filled half.
111
- variant: saveGreyed ? 'light' : 'filled', size: "sm", px: 8, "aria-label": "Save options", "data-testid": "save-options-button", style: { borderInlineStart: '1px solid var(--mantine-color-body)' }, children: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }) }) }) }), _jsx(Menu.Dropdown, { ...menuFocus.dropdown, children: _jsx(ReasonedMenuItem, { "data-testid": "save-all-menu-item", onClick: onSaveAll, blockedReason: saveAllBlockedReason ??
112
- (saveAllProgress
113
- ? `${EDITOR_ACTIONS.saveAll} is already running.`
114
- : unsavedCount === 0
115
- ? 'No entry on this branch has unsaved changes.'
116
- : busy
117
- ? 'Wait for the current save or load to finish.'
118
- : undefined), children: unsavedCount > 0
119
- ? `${EDITOR_ACTIONS.saveAll} (${unsavedCount})`
120
- : EDITOR_ACTIONS.saveAll }) })] })] })] }));
121
- }