canopycms 0.1.0 → 0.2.0-int.121

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 (136) hide show
  1. package/CHANGELOG.md +202 -43
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +118 -47
  4. package/dist/api/client.d.ts +5 -3
  5. package/dist/api/client.js +7 -3
  6. package/dist/api/index.d.ts +1 -1
  7. package/dist/assets/asset-prefixes.d.ts +4 -1
  8. package/dist/assets/asset-prefixes.js +3 -1
  9. package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
  10. package/dist/assets/editor-thumbnail-widths.js +7 -0
  11. package/dist/assets/finalize.d.ts +7 -6
  12. package/dist/assets/finalize.js +29 -10
  13. package/dist/assets/keys.d.ts +4 -1
  14. package/dist/assets/keys.js +4 -1
  15. package/dist/assets/materialize.d.ts +17 -2
  16. package/dist/assets/materialize.js +57 -4
  17. package/dist/assets/store-local.d.ts +18 -9
  18. package/dist/assets/store-local.js +58 -26
  19. package/dist/assets/store-s3.d.ts +25 -6
  20. package/dist/assets/store-s3.js +81 -19
  21. package/dist/assets/types.d.ts +33 -4
  22. package/dist/cli/cli.js +151 -56
  23. package/dist/config/types.d.ts +3 -2
  24. package/dist/editor/BranchManager.js +2 -1
  25. package/dist/editor/CommentsPanel.js +67 -29
  26. package/dist/editor/Editor.d.ts +3 -0
  27. package/dist/editor/Editor.js +93 -39
  28. package/dist/editor/EntryNavigator.d.ts +17 -5
  29. package/dist/editor/EntryNavigator.js +66 -31
  30. package/dist/editor/FormHeader.d.ts +2 -1
  31. package/dist/editor/FormHeader.js +9 -5
  32. package/dist/editor/FormRenderer.d.ts +7 -2
  33. package/dist/editor/FormRenderer.js +51 -16
  34. package/dist/editor/PreviewFrame.d.ts +13 -2
  35. package/dist/editor/PreviewFrame.js +62 -5
  36. package/dist/editor/PreviewPane.d.ts +17 -1
  37. package/dist/editor/PreviewPane.js +55 -8
  38. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  39. package/dist/editor/PreviewToolbarTools.js +9 -1
  40. package/dist/editor/branch-status.d.ts +6 -0
  41. package/dist/editor/branch-status.js +10 -0
  42. package/dist/editor/comments/BranchComments.d.ts +2 -4
  43. package/dist/editor/comments/BranchComments.js +7 -11
  44. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  45. package/dist/editor/comments/ThreadCarousel.js +4 -49
  46. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  47. package/dist/editor/components/BranchLockBanner.d.ts +8 -7
  48. package/dist/editor/components/BranchLockBanner.js +30 -3
  49. package/dist/editor/components/BranchPill.d.ts +5 -4
  50. package/dist/editor/components/BranchPill.js +6 -8
  51. package/dist/editor/components/BranchRowLock.d.ts +12 -0
  52. package/dist/editor/components/BranchRowLock.js +19 -0
  53. package/dist/editor/components/BranchesDrawer.js +3 -3
  54. package/dist/editor/components/EditorHeader.d.ts +2 -6
  55. package/dist/editor/components/EditorHeader.js +14 -28
  56. package/dist/editor/components/EditorRail.d.ts +3 -4
  57. package/dist/editor/components/EditorRail.js +3 -4
  58. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  59. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  60. package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
  61. package/dist/editor/components/PreviewLinkToast.js +33 -0
  62. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  63. package/dist/editor/components/QuickOpenPalette.js +6 -1
  64. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  65. package/dist/editor/components/RenameEntryModal.js +20 -5
  66. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  67. package/dist/editor/components/SaveSplitButton.js +25 -12
  68. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  69. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  70. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  71. package/dist/editor/components/UndoToastMessage.js +6 -4
  72. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  73. package/dist/editor/components/UnsavedMark.js +20 -0
  74. package/dist/editor/components/UserBadge.js +13 -11
  75. package/dist/editor/copy.d.ts +4 -0
  76. package/dist/editor/copy.js +4 -0
  77. package/dist/editor/editor-utils.d.ts +49 -9
  78. package/dist/editor/editor-utils.js +117 -16
  79. package/dist/editor/entry-search.d.ts +4 -2
  80. package/dist/editor/entry-search.js +2 -3
  81. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  82. package/dist/editor/fields/FieldLabel.js +1 -1
  83. package/dist/editor/fields/ImageField.d.ts +1 -1
  84. package/dist/editor/fields/ImageField.js +36 -27
  85. package/dist/editor/fields/ObjectField.d.ts +13 -2
  86. package/dist/editor/fields/ObjectField.js +38 -9
  87. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  88. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  89. package/dist/editor/group-manager/index.js +1 -1
  90. package/dist/editor/hooks/useBranchManager.d.ts +2 -0
  91. package/dist/editor/hooks/useBranchManager.js +1 -0
  92. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  93. package/dist/editor/hooks/useEditorLayout.js +5 -0
  94. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  95. package/dist/editor/hooks/useEntryManager.js +10 -8
  96. package/dist/editor/media/AssetCard.d.ts +6 -3
  97. package/dist/editor/media/AssetCard.js +24 -9
  98. package/dist/editor/media/AssetListRow.d.ts +9 -4
  99. package/dist/editor/media/AssetListRow.js +18 -9
  100. package/dist/editor/media/CropStep.d.ts +8 -10
  101. package/dist/editor/media/CropStep.js +39 -27
  102. package/dist/editor/media/MediaLibrary.js +5 -4
  103. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  104. package/dist/editor/media/MediaLibraryBody.js +65 -37
  105. package/dist/editor/media/crop-availability.d.ts +6 -0
  106. package/dist/editor/media/crop-availability.js +19 -0
  107. package/dist/editor/media/crop-math.d.ts +19 -13
  108. package/dist/editor/media/crop-math.js +50 -11
  109. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  110. package/dist/editor/media/useAssetLibrary.js +17 -10
  111. package/dist/editor/pane-header.d.ts +6 -0
  112. package/dist/editor/pane-header.js +6 -0
  113. package/dist/editor/permission-manager/index.js +1 -1
  114. package/dist/editor/preview-bridge.d.ts +23 -0
  115. package/dist/editor/preview-bridge.js +105 -4
  116. package/dist/editor/preview-path.d.ts +16 -4
  117. package/dist/editor/preview-path.js +80 -19
  118. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  119. package/dist/editor/shell/ActivityRail.js +3 -3
  120. package/dist/editor/shell/EditorShell.d.ts +15 -0
  121. package/dist/editor/shell/EditorShell.js +17 -1
  122. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  123. package/dist/editor/shell/OverlayHeader.js +15 -0
  124. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  125. package/dist/editor/shell/PaneLayout.js +68 -13
  126. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  127. package/dist/editor/shell/SidePanelHost.js +70 -33
  128. package/dist/editor/shell/TopBar.js +2 -1
  129. package/dist/editor/theme.d.ts +1 -0
  130. package/dist/editor/theme.js +19 -1
  131. package/dist/editor/undo-removal.d.ts +19 -10
  132. package/dist/editor/undo-removal.js +44 -16
  133. package/dist/server.d.ts +2 -0
  134. package/dist/version.d.ts +1 -1
  135. package/dist/version.js +1 -1
  136. package/package.json +2 -2
@@ -2,11 +2,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { Button, Tooltip } from '@mantine/core';
3
3
  import { clientOperatingStrategy } from '../../operating-mode/client.js';
4
4
  import { isAdmin, isLockedOutOfBranch, isReviewer } from '../../authorization/helpers.js';
5
- import { branchStatusPresentation } from '../branch-status.js';
5
+ import { branchBarStatusPresentation } from '../branch-status.js';
6
6
  import { EDITOR_ACTIONS } from '../copy.js';
7
7
  import { SYSTEM_USER_ID } from '../../system-users.js';
8
8
  import { TopBar } from '../shell/TopBar.js';
9
- import { branchLockState } from './BranchLockBanner.js';
9
+ import { describeBranchLock } from './BranchLockBanner.js';
10
10
  import { BranchPill } from './BranchPill.js';
11
11
  import { EntryBreadcrumb } from './EntryBreadcrumb.js';
12
12
  import { SaveSplitButton } from './SaveSplitButton.js';
@@ -14,24 +14,9 @@ import { SaveSplitButton } from './SaveSplitButton.js';
14
14
  * The top bar's contents: the breadcrumb with the entry menu and "Read-only" chip, then the branch
15
15
  * pill, Submit or Withdraw, and Save with Save all.
16
16
  */
17
- export function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onNavigatorOpen, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, allContentRef, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving = false, savedSignal, savedHere = false, entryListFailed = false, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected = false, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, }) {
18
- const { statusLocked, branchDataUnavailable } = branchLockState({
19
- branchStatus,
20
- branchReadOnly,
21
- branchWriteBlocked,
22
- });
23
- // Why the branch takes no writes, with the way forward; shared by Save, Save all and the chip.
24
- const branchLockReason = branchReadOnly
25
- ? 'The base branch is read-only. Create a branch to make changes.'
26
- : branchDataUnavailable
27
- ? 'Branch details could not be loaded. Use "Switch branch…" to retry.'
28
- : statusLocked
29
- ? branchStatus === 'submitted'
30
- ? 'This branch is in review. Withdraw it to make changes.'
31
- : branchStatus === 'approved'
32
- ? 'This branch is approved. Withdraw it to make changes.'
33
- : `This branch is ${branchStatus}. Its content is read-only.`
34
- : undefined;
17
+ export function EditorHeader({ currentEntry, branchName, operatingMode, busy, breadcrumbSegments, editedFiles, modifiedCount, onCollectionOpen, navigatorToggleRef, branchToggleRef, onFileReload, onFileDiscardDraft, onEntryRename, onEntryDelete, onEntrySelect, onBranchReloadData, onBranchDiscardDrafts, onBranchManagerOpen, onSave, onSaveAll, saveAllProgress, saving = false, savedSignal, savedHere = false, entryListFailed = false, onSubmit, onWithdraw, hasUnsavedChanges, entryHasDraft = hasUnsavedChanges, draftStorageFailed = false, entryReadOnly = false, entryUnavailable = false, branchStatus, userContext, branchCreatedBy, branchAccess, branchIsProtected = false, branchReadOnly = false, branchWriteBlocked = false, branchLoading = false, }) {
18
+ // Shared by Save and Save all; set exactly when BranchLockBanner renders.
19
+ const branchLockReason = describeBranchLock({ branchStatus, branchReadOnly, branchWriteBlocked });
35
20
  const entryLockReason = entryReadOnly
36
21
  ? "You don't have edit access to this entry. Ask a CanopyCMS admin to grant it."
37
22
  : undefined;
@@ -42,7 +27,7 @@ export function EditorHeader({ currentEntry, branchName, operatingMode, busy, br
42
27
  : !currentEntry
43
28
  ? 'Open an entry to save it.'
44
29
  : (entryLockReason ?? (!hasUnsavedChanges ? 'No changes to save.' : '')));
45
- const presentation = branchStatusPresentation(branchStatus, branchIsProtected);
30
+ const presentation = branchBarStatusPresentation(branchStatus, branchIsProtected);
46
31
  const status = !branchLoading &&
47
32
  clientOperatingStrategy(operatingMode ?? 'prod').supportsStatusBadge() &&
48
33
  presentation
@@ -52,16 +37,17 @@ export function EditorHeader({ currentEntry, branchName, operatingMode, busy, br
52
37
  testId: `header-status-badge-${branchIsProtected ? 'protected' : branchStatus}`,
53
38
  }
54
39
  : undefined;
55
- return (_jsx(TopBar, { leading: _jsx(EntryBreadcrumb, { segments: breadcrumbSegments, entryLabel: currentEntry ? (currentEntry.label ?? currentEntry.slug) : undefined, entryDirty: hasUnsavedChanges, readOnlyReason: branchLoading
56
- ? undefined
57
- : currentEntry || branchName
58
- ? (branchLockReason ?? entryLockReason)
59
- : undefined, onAllContent: onNavigatorOpen, onCollection: onCollectionOpen, onRefresh: onFileReload, onDiscard: onFileDiscardDraft,
40
+ return (_jsx(TopBar, { leading: _jsx(EntryBreadcrumb, { segments: breadcrumbSegments, entryLabel: currentEntry ? (currentEntry.label ?? currentEntry.slug) : undefined, entryDirty: hasUnsavedChanges,
41
+ // BranchLockBanner already says why a locked branch is read-only, so the chip carries
42
+ // only the entry's own reason, even beside the banner: "Withdraw it" would not unlock it.
43
+ readOnlyReason: branchLoading ? undefined : currentEntry || branchName ? entryLockReason : undefined, onCollection: onCollectionOpen, onRefresh: onFileReload, onDiscard: onFileDiscardDraft,
60
44
  // A path rule's refusal hides them, as the content tree hides that entry's "…" menu.
61
- onRename: entryReadOnly ? undefined : onEntryRename, onDelete: entryReadOnly ? undefined : onEntryDelete, refreshBlockedReason: branchName ? undefined : 'Open a branch first.', discardBlockedReason: entryHasDraft ? undefined : 'This entry has no unsaved changes.', changeBlockedReason: branchLockReason ??
45
+ onRename: entryReadOnly ? undefined : onEntryRename,
46
+ // An entry with a page has a preview `src`; one without changes only its slug.
47
+ entryNoPage: !!currentEntry && !currentEntry.previewSrc, onDelete: entryReadOnly ? undefined : onEntryDelete, refreshBlockedReason: branchName ? undefined : 'Open a branch first.', discardBlockedReason: entryHasDraft ? undefined : 'This entry has no unsaved changes.', changeBlockedReason: branchLockReason ??
62
48
  (entryUnavailable
63
49
  ? "This entry's content type isn't known to this editor version yet. Reload in a few minutes."
64
- : undefined), entryMenuRef: navigatorToggleRef, allContentRef: allContentRef }), trailing: _jsxs(_Fragment, { children: [_jsx(BranchPill, { branchName: branchName, loading: busy, status: status, unsavedCount: modifiedCount, unsavedEntries: editedFiles, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, onEntrySelect: onEntrySelect, pillRef: branchToggleRef }), _jsx(WorkflowButton, { branchName: branchName, busy: busy, branchStatus: branchStatus, userContext: userContext, branchCreatedBy: branchCreatedBy, branchAccess: branchAccess, branchIsProtected: branchIsProtected, onSubmit: onSubmit, onWithdraw: onWithdraw, savingAll: !!saveAllProgress }), _jsx(SaveSplitButton, { onSave: onSave, saveBlockedReason: saveBlockedReason, busy: busy, onSaveAll: onSaveAll, unsavedCount: modifiedCount, saveAllBlockedReason: branchLockReason ??
50
+ : undefined), entryMenuRef: navigatorToggleRef }), trailing: _jsxs(_Fragment, { children: [_jsx(BranchPill, { branchName: branchName, loading: busy, status: status, unsavedCount: modifiedCount, unsavedEntries: editedFiles, onSwitchBranch: onBranchManagerOpen, onRefreshBranch: onBranchReloadData, onDiscardAll: onBranchDiscardDrafts, onEntrySelect: onEntrySelect, pillRef: branchToggleRef }), _jsx(WorkflowButton, { branchName: branchName, busy: busy, branchStatus: branchStatus, userContext: userContext, branchCreatedBy: branchCreatedBy, branchAccess: branchAccess, branchIsProtected: branchIsProtected, onSubmit: onSubmit, onWithdraw: onWithdraw, savingAll: !!saveAllProgress }), _jsx(SaveSplitButton, { onSave: onSave, saveBlockedReason: saveBlockedReason, busy: busy, onSaveAll: onSaveAll, unsavedCount: modifiedCount, saveAllBlockedReason: branchLockReason ??
65
51
  (!branchName
66
52
  ? 'Open a branch first.'
67
53
  : entryListFailed && modifiedCount > 0
@@ -1,13 +1,12 @@
1
1
  import { type ReactElement, type Ref } from 'react';
2
2
  import type { PaneArrangement } from '../shell/PaneLayout.js';
3
3
  export interface EditorRailProps {
4
- siteTitle: string;
5
- /** Opens the site's first entry, which is also what the editor opens with no entry asked for. */
6
- onHomeOpen: () => void;
7
4
  contentPanelOpen: boolean;
8
5
  /** The content panel's element id, for `aria-controls`. */
9
6
  contentPanelId: string;
10
7
  onContentToggle: () => void;
8
+ /** The Content button, which takes focus when the open entry it was reached from is gone. */
9
+ contentToggleRef?: Ref<HTMLButtonElement>;
11
10
  onQuickOpen: () => void;
12
11
  /** The quick open shortcut as the platform writes it, such as "⌘P". */
13
12
  quickOpenShortcut: string;
@@ -46,4 +45,4 @@ export declare function SettingsMenu({ items, toggleRef, }: {
46
45
  toggleRef?: Ref<HTMLButtonElement>;
47
46
  }): import("react/jsx-runtime").JSX.Element | null;
48
47
  /** The editor's activity rail: what `ActivityRail` lays out, wired to the editor's surfaces. */
49
- export declare function EditorRail({ siteTitle, onHomeOpen, contentPanelOpen, contentPanelId, onContentToggle, onQuickOpen, quickOpenShortcut, quickOpenKeys, branchesOpen, onBranchesOpen, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }: EditorRailProps): import("react/jsx-runtime").JSX.Element;
48
+ export declare function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, onQuickOpen, quickOpenShortcut, quickOpenKeys, branchesOpen, onBranchesOpen, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }: EditorRailProps): import("react/jsx-runtime").JSX.Element;
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useRef, useState } from 'react';
4
- import { ActionIcon, Menu, Stack, Text, Tooltip, VisuallyHidden } from '@mantine/core';
4
+ import { ActionIcon, Menu, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
5
5
  import { useMergedRef } from '@mantine/hooks';
6
6
  import { IconActivity, IconExternalLink, IconFiles, IconGitBranch, IconInfoCircle, IconLayoutColumns, IconLayoutRows, IconLogout, IconMessageCircle, IconPhoto, IconSearch, IconSettings, IconUserCircle, } from '@tabler/icons-react';
7
7
  import { ActivityRail, RailButton } from '../shell/ActivityRail.js';
@@ -40,9 +40,8 @@ function LayoutButton({ layout, onChange, blocked = false, }) {
40
40
  setSwitchedTo(LAYOUTS[other].title);
41
41
  } }), _jsx(VisuallyHidden, { role: "status", "data-testid": "rail-layout-status", children: switchedTo })] }));
42
42
  }
43
- const monogram = (title) => Array.from(title.trim())[0]?.toUpperCase() ?? '?';
44
43
  /** The editor's activity rail: what `ActivityRail` lays out, wired to the editor's surfaces. */
45
- export function EditorRail({ siteTitle, onHomeOpen, contentPanelOpen, contentPanelId, onContentToggle, onQuickOpen, quickOpenShortcut, quickOpenKeys, branchesOpen, onBranchesOpen, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }) {
44
+ export function EditorRail({ contentPanelOpen, contentPanelId, onContentToggle, contentToggleRef, onQuickOpen, quickOpenShortcut, quickOpenKeys, branchesOpen, onBranchesOpen, onCommentsOpen, commentsOpen, unresolvedCommentCount, mediaLibraryOpen, onMediaLibraryOpen, mediaButtonRef, layout, onLayoutChange, layoutBlocked, onPermissionManagerOpen, onGroupManagerOpen, onSystemHealthOpen, settingsToggleRef, AccountComponent, onAccountClick, onLogoutClick, }) {
46
45
  const adminItems = [
47
46
  onPermissionManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-permissions", onClick: onPermissionManagerOpen, children: "Manage permissions" }, "permissions")),
48
47
  onGroupManagerOpen && (_jsx(Menu.Item, { "data-testid": "settings-menu-groups", onClick: onGroupManagerOpen, children: "Manage groups" }, "groups")),
@@ -61,5 +60,5 @@ export function EditorRail({ siteTitle, onHomeOpen, contentPanelOpen, contentPan
61
60
  _jsxs(Menu.Item, { component: "a", href: ABOUT_URL, target: "_blank", rel: "noopener noreferrer", "data-testid": "settings-menu-about", leftSection: _jsx(IconInfoCircle, { size: 14 }), rightSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), children: ["About CanopyCMS", _jsx(VisuallyHidden, { component: "span", children: " (opens in a new tab)" })] }, "about"),
62
61
  ];
63
62
  const commentsLabel = unresolvedCommentCount > 0 ? `Comments, ${unresolvedCommentCount} unresolved` : 'Comments';
64
- return (_jsx(ActivityRail, { top: _jsxs(_Fragment, { children: [_jsx(RailButton, { railId: "home", label: `Open the first entry of ${siteTitle}`, icon: _jsx(Text, { component: "span", fw: 700, size: "md", "aria-hidden": true, children: monogram(siteTitle) }), tinted: true, "data-testid": "rail-home-button", onClick: onHomeOpen }), _jsx(RailButton, { railId: "content", label: "Content", icon: _jsx(IconFiles, { size: 20 }), active: contentPanelOpen, "aria-expanded": contentPanelOpen, "aria-controls": contentPanelOpen ? contentPanelId : undefined, "data-testid": "content-panel-toggle", onClick: onContentToggle }), _jsx(RailButton, { railId: "search", label: EDITOR_ACTIONS.openEntry, shortcut: quickOpenShortcut, "aria-keyshortcuts": quickOpenKeys, icon: _jsx(IconSearch, { size: 20 }), "aria-haspopup": "dialog", "data-testid": "rail-search-button", onClick: onQuickOpen }), _jsx(RailButton, { railId: "branches", label: "Branches", icon: _jsx(IconGitBranch, { size: 20 }), active: branchesOpen, "aria-haspopup": "dialog", "data-testid": "rail-branches-button", onClick: onBranchesOpen }), onCommentsOpen && (_jsx(RailButton, { railId: "comments", label: commentsLabel, icon: _jsx(IconMessageCircle, { size: 20 }), active: commentsOpen, count: unresolvedCommentCount, countTestId: "comments-unresolved-count", "aria-haspopup": "dialog", "data-testid": "comments-button", onClick: onCommentsOpen })), _jsx(RailButton, { railId: "media", label: "Media library", icon: _jsx(IconPhoto, { size: 20 }), active: mediaLibraryOpen, "aria-haspopup": "dialog", "data-testid": "media-library-button", ref: mediaButtonRef, onClick: onMediaLibraryOpen })] }), bottom: _jsxs(_Fragment, { children: [_jsx(LayoutButton, { layout: layout, onChange: onLayoutChange, blocked: layoutBlocked }), _jsx(SettingsMenu, { items: settingsItems, toggleRef: settingsToggleRef })] }), account: AccountComponent ? (_jsx(AccountComponent, {})) : onAccountClick || onLogoutClick ? (_jsxs(Stack, { gap: 6, align: "center", children: [onAccountClick && (_jsx(Tooltip, { label: "Account", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Account", onClick: onAccountClick, children: _jsx(IconUserCircle, { size: 20 }) }) })), onLogoutClick && (_jsx(Tooltip, { label: "Sign out", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Sign out", onClick: onLogoutClick, children: _jsx(IconLogout, { size: 20 }) }) }))] })) : undefined }));
63
+ return (_jsx(ActivityRail, { top: _jsxs(_Fragment, { children: [_jsx(RailButton, { railId: "content", label: "Content", icon: _jsx(IconFiles, { size: 20 }), active: contentPanelOpen, "aria-expanded": contentPanelOpen, "aria-controls": contentPanelOpen ? contentPanelId : undefined, "data-testid": "content-panel-toggle", ref: contentToggleRef, onClick: onContentToggle }), _jsx(RailButton, { railId: "search", label: EDITOR_ACTIONS.openEntry, shortcut: quickOpenShortcut, "aria-keyshortcuts": quickOpenKeys, icon: _jsx(IconSearch, { size: 20 }), "aria-haspopup": "dialog", "data-testid": "rail-search-button", onClick: onQuickOpen }), _jsx(RailButton, { railId: "branches", label: "Branches", icon: _jsx(IconGitBranch, { size: 20 }), active: branchesOpen, "aria-haspopup": "dialog", "data-testid": "rail-branches-button", onClick: onBranchesOpen }), onCommentsOpen && (_jsx(RailButton, { railId: "comments", label: commentsLabel, icon: _jsx(IconMessageCircle, { size: 20 }), active: commentsOpen, count: unresolvedCommentCount, countTestId: "comments-unresolved-count", "aria-haspopup": "dialog", "data-testid": "comments-button", onClick: onCommentsOpen })), _jsx(RailButton, { railId: "media", label: "Media library", icon: _jsx(IconPhoto, { size: 20 }), active: mediaLibraryOpen, "aria-haspopup": "dialog", "data-testid": "media-library-button", ref: mediaButtonRef, onClick: onMediaLibraryOpen })] }), bottom: _jsxs(_Fragment, { children: [_jsx(LayoutButton, { layout: layout, onChange: onLayoutChange, blocked: layoutBlocked }), _jsx(SettingsMenu, { items: settingsItems, toggleRef: settingsToggleRef })] }), account: AccountComponent ? (_jsx(AccountComponent, {})) : onAccountClick || onLogoutClick ? (_jsxs(Stack, { gap: 6, align: "center", children: [onAccountClick && (_jsx(Tooltip, { label: "Account", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Account", onClick: onAccountClick, children: _jsx(IconUserCircle, { size: 20 }) }) })), onLogoutClick && (_jsx(Tooltip, { label: "Sign out", position: "right", children: _jsx(ActionIcon, { variant: "subtle", color: "gray", size: "lg", radius: "md", "aria-label": "Sign out", onClick: onLogoutClick, children: _jsx(IconLogout, { size: 20 }) }) }))] })) : undefined }));
65
64
  }
@@ -7,27 +7,26 @@ export interface EntryBreadcrumbProps {
7
7
  entryLabel: string | undefined;
8
8
  /** The open entry has unsaved changes. */
9
9
  entryDirty: boolean;
10
- /** Why the open entry or its branch is read-only; set, the "Read-only" chip shows. */
10
+ /** Why the open entry is read-only; set, the "Read-only" chip shows. */
11
11
  readOnlyReason?: string;
12
- onAllContent: () => void;
13
12
  onCollection: (collectionPath: string) => void;
14
13
  onRefresh: () => void;
15
14
  onDiscard: () => void;
16
- /** With `onDelete`, offers "Rename…" and "Delete entry"; absent where a path rule refuses them. */
15
+ /** With `onDelete`, offers "Change URL…" and "Delete entry"; absent where a path rule refuses them. */
17
16
  onRename?: () => void;
17
+ /** The entry has no page on the site, so "Change URL…" reads "Change slug…". */
18
+ entryNoPage?: boolean;
18
19
  onDelete?: () => void;
19
20
  refreshBlockedReason?: string;
20
21
  discardBlockedReason?: string;
21
- /** Why the entry can't be renamed or deleted now. */
22
+ /** Why the entry's URL can't be changed, or the entry deleted, now. */
22
23
  changeBlockedReason?: string;
23
24
  /** The entry menu's trigger, which stands in for a menu item that opened something. */
24
25
  entryMenuRef?: Ref<HTMLButtonElement>;
25
- /** The "All content" crumb, where focus goes once the open entry is gone. */
26
- allContentRef?: Ref<HTMLButtonElement>;
27
26
  }
28
27
  /**
29
- * Where the open entry sits: "All content" opens the content panel, a collection crumb reveals
30
- * that collection in it, and the entry's own crumb is current and opens the entry menu. Crumbs
31
- * shrink and truncate before the entry's own does.
28
+ * Where the open entry sits: a collection crumb reveals that collection in the content panel, and
29
+ * the entry's own crumb is current and opens the entry menu. Crumbs shrink and truncate before the
30
+ * entry's own does.
32
31
  */
33
- export declare function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onAllContent, onCollection, onRefresh, onDiscard, onRename, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, allContentRef, }: EntryBreadcrumbProps): import("react/jsx-runtime").JSX.Element;
32
+ export declare function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onCollection, onRefresh, onDiscard, onRename, entryNoPage, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }: EntryBreadcrumbProps): import("react/jsx-runtime").JSX.Element;
@@ -7,6 +7,7 @@ import { IconChevronDown, IconChevronRight, IconLock } from '@tabler/icons-react
7
7
  import { EDITOR_ACTIONS } from '../copy.js';
8
8
  import { DestructiveMenuItem } from './DestructiveMenuItem.js';
9
9
  import { ReasonedMenuItem } from './ReasonedMenuItem.js';
10
+ import { UnsavedMark } from './UnsavedMark.js';
10
11
  import { useMenuItemFocusReturn } from '../utils/focus-return.js';
11
12
  /** Whether the element's text is cut short, kept current as it resizes. */
12
13
  function useTruncated(label) {
@@ -29,17 +30,17 @@ const crumbText = (label, ref) => (_jsx(Text, { ref: ref, span: true, inherit: t
29
30
  function CrumbTooltip({ label, truncated, children, }) {
30
31
  return (_jsx(Tooltip, { label: label, disabled: !truncated, events: { hover: true, focus: true, touch: false }, children: children }));
31
32
  }
32
- function CollectionCrumb({ label, onClick, buttonRef, }) {
33
+ function CollectionCrumb({ label, onClick }) {
33
34
  const [ref, truncated] = useTruncated(label);
34
- return (_jsx(CrumbTooltip, { label: label, truncated: truncated, children: _jsx(Button, { ref: buttonRef, variant: "subtle", color: "gray", size: "compact-sm", fw: 400, miw: 0, maw: 180, onClick: onClick, "data-testid": "breadcrumb-segment", styles: { label: { minWidth: 0 } }, children: crumbText(label, ref) }) }));
35
+ return (_jsx(CrumbTooltip, { label: label, truncated: truncated, children: _jsx(Button, { variant: "subtle", color: "gray", size: "compact-sm", fw: 400, miw: 0, maw: 180, onClick: onClick, "data-testid": "breadcrumb-segment", styles: { label: { minWidth: 0 } }, children: crumbText(label, ref) }) }));
35
36
  }
36
37
  const separator = (_jsx(IconChevronRight, { size: 14, "aria-hidden": "true", style: { flexShrink: 0, color: 'var(--mantine-color-dimmed)' } }));
37
38
  /**
38
- * Where the open entry sits: "All content" opens the content panel, a collection crumb reveals
39
- * that collection in it, and the entry's own crumb is current and opens the entry menu. Crumbs
40
- * shrink and truncate before the entry's own does.
39
+ * Where the open entry sits: a collection crumb reveals that collection in the content panel, and
40
+ * the entry's own crumb is current and opens the entry menu. Crumbs shrink and truncate before the
41
+ * entry's own does.
41
42
  */
42
- export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onAllContent, onCollection, onRefresh, onDiscard, onRename, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, allContentRef, }) {
43
+ export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReason, onCollection, onRefresh, onDiscard, onRename, entryNoPage = false, onDelete, refreshBlockedReason, discardBlockedReason, changeBlockedReason, entryMenuRef, }) {
43
44
  const trigger = useRef(null);
44
45
  const triggerRef = useMergedRef(trigger, entryMenuRef);
45
46
  const menuFocus = useMenuItemFocusReturn(trigger);
@@ -48,9 +49,5 @@ export function EntryBreadcrumb({ segments, entryLabel, entryDirty, readOnlyReas
48
49
  // Clips crumbs that overflow, padded by the focus ring's reach so the ring is not clipped.
49
50
  style: { flex: '0 1 auto', overflow: 'hidden', padding: 4, margin: -4 }, children: _jsxs(Group, { component: "ol", gap: 2, wrap: "nowrap", miw: 0, style: { listStyle: 'none', margin: 0, padding: 0 }, children: [segments.map((segment, index) => (_jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 0,
50
51
  // Collections give up their width long before the entry's own crumb does.
51
- style: { flex: '0 100 auto' }, children: [index > 0 && separator, segment.folder ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, miw: 0, truncate: "end", children: segment.label })) : (_jsx(CollectionCrumb, { label: segment.label, buttonRef: index === 0 ? allContentRef : undefined, onClick: () => segment.collectionPath ? onCollection(segment.collectionPath) : onAllContent() }))] }, `${segment.label}-${index}`))), _jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 64, style: { flex: '0 1 auto' }, children: [separator, entryLabel === undefined ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, "data-testid": "file-dropdown-button", children: "No entry open" })) : (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-start", ...menuFocus.menu, children: [_jsx(CrumbTooltip, { label: entryLabel, truncated: entryTruncated, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "subtle", color: "gray", size: "compact-sm", fw: 600, miw: 0, maw: "100%", "aria-current": "page", rightSection: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }), "data-testid": "file-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [crumbText(entryLabel, entryLabelRef), entryDirty && (_jsxs(_Fragment, { children: [_jsx(Box, { component: "span", "aria-hidden": "true", w: 8, h: 8, style: {
52
- borderRadius: '50%',
53
- flexShrink: 0,
54
- background: 'var(--mantine-color-text)',
55
- }, "data-testid": "entry-unsaved-dot" }), _jsx(VisuallyHidden, { component: "span", children: ", unsaved changes" })] }))] }) }) }), _jsxs(Menu.Dropdown, { ...menuFocus.dropdown, children: [_jsx(ReasonedMenuItem, { onClick: onRefresh, blockedReason: refreshBlockedReason, "data-testid": "refresh-entry-menu-item", children: EDITOR_ACTIONS.refreshFromServer }), _jsx(ReasonedMenuItem, { "data-testid": "discard-file-draft-menu-item", onClick: onDiscard, blockedReason: discardBlockedReason, children: "Discard changes" }), onRename && onDelete && (_jsxs(_Fragment, { children: [_jsx(Menu.Divider, {}), _jsx(ReasonedMenuItem, { onClick: onRename, blockedReason: changeBlockedReason, "data-testid": "entry-menu-rename", children: "Rename\u2026" }), _jsx(DestructiveMenuItem, { onClick: onDelete, blockedReason: changeBlockedReason, "data-testid": "entry-menu-delete", children: "Delete entry" })] }))] })] }))] })] }) }), readOnlyReason && (_jsx(Tooltip, { label: readOnlyReason, multiline: true, maw: 300, children: _jsxs(Badge, { variant: "light", color: "gray", radius: "sm", leftSection: _jsx(IconLock, { size: 12, "aria-hidden": "true" }), style: { flexShrink: 0, textTransform: 'none' }, "data-testid": "read-only-chip", children: ["Read-only", _jsxs(VisuallyHidden, { component: "span", children: [". ", readOnlyReason] })] }) }))] }));
52
+ style: { flex: '0 100 auto' }, children: [index > 0 && separator, segment.folder ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, miw: 0, truncate: "end", children: segment.label })) : (_jsx(CollectionCrumb, { label: segment.label, onClick: () => onCollection(segment.collectionPath) }))] }, `${segment.label}-${index}`))), _jsxs(Group, { component: "li", gap: 2, wrap: "nowrap", miw: 64, style: { flex: '0 1 auto' }, children: [segments.length > 0 && separator, entryLabel === undefined ? (_jsx(Text, { size: "sm", c: "dimmed", px: 4, "data-testid": "file-dropdown-button", children: "No entry open" })) : (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-start", ...menuFocus.menu, children: [_jsx(CrumbTooltip, { label: entryLabel, truncated: entryTruncated, children: _jsx(Menu.Target, { children: _jsxs(Button, { ref: triggerRef, variant: "subtle", color: "gray", size: "compact-sm", fw: 600, miw: 0, maw: "100%", "aria-current": "page", rightSection: _jsx(IconChevronDown, { size: 14, "aria-hidden": "true" }), "data-testid": "file-dropdown-button", styles: { label: { minWidth: 0, gap: 6 } }, children: [crumbText(entryLabel, entryLabelRef), entryDirty && (_jsxs(_Fragment, { children: [_jsx(UnsavedMark, { "aria-hidden": "true", "data-testid": "entry-unsaved-dot" }), _jsx(VisuallyHidden, { component: "span", children: ", unsaved changes" })] }))] }) }) }), _jsxs(Menu.Dropdown, { ...menuFocus.dropdown, children: [_jsx(ReasonedMenuItem, { onClick: onRefresh, blockedReason: refreshBlockedReason, "data-testid": "refresh-entry-menu-item", children: EDITOR_ACTIONS.refreshFromServer }), _jsx(ReasonedMenuItem, { "data-testid": "discard-file-draft-menu-item", onClick: onDiscard, blockedReason: discardBlockedReason, children: "Discard changes" }), onRename && onDelete && (_jsxs(_Fragment, { children: [_jsx(Menu.Divider, {}), _jsxs(ReasonedMenuItem, { onClick: onRename, blockedReason: changeBlockedReason, "data-testid": "entry-menu-rename", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] }), _jsx(DestructiveMenuItem, { onClick: onDelete, blockedReason: changeBlockedReason, "data-testid": "entry-menu-delete", children: "Delete entry" })] }))] })] }))] })] }) }), readOnlyReason && (_jsx(Tooltip, { label: readOnlyReason, multiline: true, maw: 300, children: _jsxs(Badge, { variant: "light", color: "gray", radius: "sm", leftSection: _jsx(IconLock, { size: 12, "aria-hidden": "true" }), style: { flexShrink: 0, textTransform: 'none' }, "data-testid": "read-only-chip", children: ["Read-only", _jsxs(VisuallyHidden, { component: "span", children: [". ", readOnlyReason] })] }) }))] }));
56
53
  }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Says a preview link's page has no entry, so it went to a new tab: once the editor is in view, as
3
+ * the toast's timer runs while hidden. A blocked tab's note stays, linking the page for a new click.
4
+ */
5
+ export declare const showPreviewLinkOutcome: (href: string, path: string, opened: boolean) => void;
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+ import { jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Anchor } from '@mantine/core';
4
+ import { notifications } from '@mantine/notifications';
5
+ /**
6
+ * Says a preview link's page has no entry, so it went to a new tab: once the editor is in view, as
7
+ * the toast's timer runs while hidden. A blocked tab's note stays, linking the page for a new click.
8
+ */
9
+ export const showPreviewLinkOutcome = (href, path, opened) => {
10
+ if (!opened) {
11
+ notifications.show({
12
+ color: 'yellow',
13
+ autoClose: false,
14
+ message: (_jsxs(_Fragment, { children: [path, " has no entry here, and your browser blocked the new tab.", ' ', _jsxs(Anchor, { href: href, target: "_blank", rel: "noopener noreferrer", children: ["Open ", path] })] })),
15
+ });
16
+ return;
17
+ }
18
+ const show = () => notifications.show({
19
+ color: 'blue',
20
+ message: `${path} has no entry here, so it opened in a new tab.`,
21
+ });
22
+ setTimeout(() => {
23
+ if (document.visibilityState === 'visible')
24
+ return show();
25
+ const onVisible = () => {
26
+ if (document.visibilityState !== 'visible')
27
+ return;
28
+ document.removeEventListener('visibilitychange', onVisible);
29
+ show();
30
+ };
31
+ document.addEventListener('visibilitychange', onVisible);
32
+ }, 300);
33
+ };
@@ -1,12 +1,18 @@
1
1
  /** @internal Exported for tests. Marks an overlay Mod+P may open over: the palette's own closing dialog. */
2
2
  export declare const QUICK_OPEN_REPLACES_ATTR = "data-quick-open-replaces";
3
- /** @internal Exported for tests and stories. */
3
+ /** One option: an entry, with the type and route its second line shows. */
4
4
  export interface QuickOpenEntry {
5
5
  path: string;
6
6
  label: string;
7
+ /** The entry's title, matched by the search where the label is a singleton's type label. */
8
+ title?: string;
7
9
  slug?: string;
8
10
  collectionPath?: string;
9
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;
10
16
  }
11
17
  export interface QuickOpenPaletteProps {
12
18
  opened: boolean;
@@ -19,6 +19,8 @@ export const QUICK_OPEN_REPLACES_ATTR = 'data-quick-open-replaces';
19
19
  const otherOverlayOpen = () => Array.from(document.querySelectorAll('[role="dialog"][aria-modal="true"]')).some((dialog) => !dialog.closest(`[${QUICK_OPEN_REPLACES_ATTR}]`));
20
20
  /** Options rendered at once; the rest are reached by typing more. */
21
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(' · ');
22
24
  export function QuickOpenPalette({ opened, onOpen, onClose, entries, collectionLabel, onSelect, returnFocusTo, }) {
23
25
  // `[]` ignores no tags and `true` fires from contenteditable, so the shortcut works from inside
24
26
  // a text field or the MDX editor.
@@ -62,5 +64,8 @@ export function QuickOpenPalette({ opened, onOpen, onClose, entries, collectionL
62
64
  onSelect(path);
63
65
  onClose();
64
66
  };
65
- 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) => (_jsxs(Combobox.Option, { value: entry.path, "data-testid": "quick-open-option", children: [_jsx(Highlight, { highlight: query.trim(), size: "sm", children: entry.label }), entry.slug && _jsx(Text, { size: "xs", children: entry.slug })] }, entry.path))) }, 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."] }))] }) }));
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."] }))] }) }));
66
71
  }
@@ -1,8 +1,13 @@
1
+ import type { EntryNameSource } from '../editor-utils.js';
1
2
  export interface RenameEntryModalProps {
2
3
  /** Whether the modal is open */
3
4
  isOpen: boolean;
4
5
  /** Current entry label for display */
5
6
  entryLabel: string;
7
+ /** Where the entry's name comes from (`entryNameSource`); a title field when absent. */
8
+ nameSource?: EntryNameSource;
9
+ /** The entry has a page on the site; without one, the dialog changes only its slug. */
10
+ hasPage?: boolean;
6
11
  /** Current slug value */
7
12
  currentSlug: string;
8
13
  /** Called when save is clicked */
@@ -22,4 +27,4 @@ export interface RenameEntryModalProps {
22
27
  /** Where focus goes once it closes; Mantine's default, the element focused at open, without it. */
23
28
  returnFocusTo?: () => HTMLElement | null;
24
29
  }
25
- export declare function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onClose, isSaving, error, warning, existingSlugs, previewPath, returnFocusTo, }: RenameEntryModalProps): import("react/jsx-runtime").JSX.Element;
30
+ export declare function RenameEntryModal({ isOpen, entryLabel, nameSource, hasPage, currentSlug, onSave, onClose, isSaving, error, warning, existingSlugs, previewPath, returnFocusTo, }: RenameEntryModalProps): import("react/jsx-runtime").JSX.Element;
@@ -1,15 +1,29 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  /**
4
- * Allows changing the slug (middle segment) of an entry filename.
5
- * The content ID is preserved, so drafts and references remain intact.
4
+ * "Change URL", or "Change slug" for an entry with no page: changes an entry's slug, the middle
5
+ * segment of its file name, which sets its URL. Whether its name changes with it depends on where
6
+ * the name comes from (`entryNameSource`). The content ID is preserved, so drafts and references
7
+ * remain intact.
6
8
  */
7
9
  import { useState } from 'react';
8
10
  import { Modal, Stack, TextInput, Group, Button, Alert, Text } from '@mantine/core';
9
11
  import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react';
10
12
  import { validateEntrySlug } from '../slug.js';
11
13
  import { focusReturnProps } from '../utils/focus-return.js';
12
- export function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onClose, isSaving = false, error = null, warning = null, existingSlugs, previewPath, returnFocusTo, }) {
14
+ import { EDITOR_ACTIONS } from '../copy.js';
15
+ /** What the dialog says it changes, and whether the entry's name changes with it. */
16
+ const nameHelp = (entryLabel, source, hasPage) => {
17
+ const changes = `This changes the ${hasPage ? 'URL' : 'slug'} of “${entryLabel}”`;
18
+ if (source?.from === 'type')
19
+ return `${changes}, not its name, which is its type's.`;
20
+ if (source?.from === 'slug') {
21
+ return `${changes}, and its name with it: with no title, its name follows the ${hasPage ? 'URL' : 'slug'}.`;
22
+ }
23
+ const field = source?.fieldLabel ? `“${source.fieldLabel}” field` : 'title field';
24
+ return `${changes}, not its name, which comes from its ${field}. Edit that field in the form to change the name.`;
25
+ };
26
+ export function RenameEntryModal({ isOpen, entryLabel, nameSource, hasPage = true, currentSlug, onSave, onClose, isSaving = false, error = null, warning = null, existingSlugs, previewPath, returnFocusTo, }) {
13
27
  const [newSlug, setNewSlug] = useState(currentSlug);
14
28
  const [touched, setTouched] = useState(false);
15
29
  // Reset state when modal opens (adjust state during render instead of effect)
@@ -38,8 +52,9 @@ export function RenameEntryModal({ isOpen, entryLabel, currentSlug, onSave, onCl
38
52
  await onSave(newSlug);
39
53
  };
40
54
  const slugPath = previewPath?.(newSlug || 'your-slug');
41
- return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: _jsx(Text, { fw: 600, children: "Rename entry" }), size: "md", closeOnClickOutside: !isSaving, closeOnEscape: !isSaving, ...focusReturnProps(returnFocusTo), children: _jsx("form", { onSubmit: handleSubmit, noValidate: true, children: _jsxs(Stack, { gap: "md", "data-testid": "rename-entry-modal", children: [warning ? (_jsx(Alert, { icon: _jsx(IconAlertTriangle, { size: 16 }), color: "yellow", variant: "light", children: warning })) : (error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", variant: "light", children: error }))), _jsxs(Text, { size: "sm", c: "dimmed", children: ["Renaming \u201C", entryLabel, "\u201D"] }), _jsx(TextInput, { label: "Slug", description: slugPath ? `URL path: ${slugPath}` : 'Lowercase letters, numbers and hyphens', value: newSlug, onChange: (e) => {
55
+ const action = hasPage ? EDITOR_ACTIONS.changeUrl : EDITOR_ACTIONS.changeSlug;
56
+ return (_jsx(Modal, { opened: isOpen, onClose: onClose, title: _jsx(Text, { fw: 600, children: action }), size: "md", closeOnClickOutside: !isSaving, closeOnEscape: !isSaving, ...focusReturnProps(returnFocusTo), children: _jsx("form", { onSubmit: handleSubmit, noValidate: true, children: _jsxs(Stack, { gap: "md", "data-testid": "rename-entry-modal", children: [warning ? (_jsx(Alert, { icon: _jsx(IconAlertTriangle, { size: 16 }), color: "yellow", variant: "light", children: warning })) : (error && (_jsx(Alert, { icon: _jsx(IconAlertCircle, { size: 16 }), color: "red", variant: "light", children: error }))), _jsx(Text, { size: "sm", c: "dimmed", "data-testid": "change-url-help", children: nameHelp(entryLabel, nameSource, hasPage) }), _jsx(TextInput, { label: "Slug", description: slugPath ? `URL path: ${slugPath}` : 'Lowercase letters, numbers and hyphens', value: newSlug, onChange: (e) => {
42
57
  setNewSlug(e.currentTarget.value);
43
58
  setTouched(true);
44
- }, error: touched ? validationError : null, placeholder: "my-entry-slug", required: true, disabled: isSaving, "data-autofocus": true, "data-testid": "rename-slug-input" }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { size: "sm", variant: "subtle", onClick: onClose, disabled: isSaving, children: "Cancel" }), _jsx(Button, { size: "sm", type: "submit", loading: isSaving, disabled: newSlug === currentSlug, "data-testid": "rename-entry-submit", children: "Rename" })] })] }) }) }));
59
+ }, error: touched ? validationError : null, placeholder: "my-entry-slug", required: true, disabled: isSaving, "data-autofocus": true, "data-testid": "rename-slug-input" }), _jsxs(Group, { justify: "flex-end", gap: "sm", children: [_jsx(Button, { size: "sm", variant: "subtle", onClick: onClose, disabled: isSaving, children: "Cancel" }), _jsx(Button, { size: "sm", type: "submit", loading: isSaving, disabled: newSlug === currentSlug, "data-testid": "rename-entry-submit", children: action })] })] }) }) }));
45
60
  }
@@ -24,7 +24,8 @@ export interface SaveSplitButtonProps {
24
24
  draftStorageFailed: boolean;
25
25
  }
26
26
  /**
27
- * The unsaved indicator, then Save with a caret offering "Save all". The indicator is icon-only
28
- * below `lg`, its text then its Tooltip and accessible name.
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.
29
30
  */
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,13 +1,24 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { useEffect, useId, useRef, useState } from 'react';
4
4
  import { Box, Button, Loader, Menu, Text, Tooltip, VisuallyHidden } from '@mantine/core';
5
5
  import { useReducedMotion } from '@mantine/hooks';
6
- import { IconAlertTriangle, IconCheck, IconChevronDown, IconCircleDot, IconLoader2, } from '@tabler/icons-react';
6
+ import { IconAlertTriangle, IconCheck, IconChevronDown, IconLoader2 } from '@tabler/icons-react';
7
7
  import { EDITOR_ACTIONS } from '../copy.js';
8
8
  import { ReasonedMenuItem } from './ReasonedMenuItem.js';
9
9
  import { useMenuItemFocusReturn } from '../utils/focus-return.js';
10
+ const STORAGE_FAILED_TEXT = 'Unsaved · not kept on this device';
10
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
+ };
11
22
  const SAVED_SHOWN_MS = 2000;
12
23
  // Below `sm` the status words are visually hidden, so Save keeps its own width beside an icon.
13
24
  const LABEL_TEXT_CLASS = 'canopy-save-label-text';
@@ -40,11 +51,13 @@ function SaveLabels({ shown, spinner }) {
40
51
  }, children: labels[key] }, key))) }));
41
52
  }
42
53
  /**
43
- * The unsaved indicator, then Save with a caret offering "Save all". The indicator is icon-only
44
- * below `lg`, its text then its Tooltip and accessible name.
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.
45
57
  */
46
58
  export function SaveSplitButton({ onSave, saveBlockedReason, busy, onSaveAll, unsavedCount, saveAllBlockedReason, saveAllProgress, saving = false, savedSignal, savedHere = false, entryDirty, draftStorageFailed, }) {
47
59
  const caret = useRef(null);
60
+ const keptHintId = useId();
48
61
  const menuFocus = useMenuItemFocusReturn(caret);
49
62
  const reducedMotion = useReducedMotion();
50
63
  // Each landed Save shows "Saved" once, as it lands and only on its own entry: one that landed
@@ -80,20 +93,20 @@ export function SaveSplitButton({ onSave, saveBlockedReason, busy, onSaveAll, un
80
93
  ? 'saved'
81
94
  : 'save';
82
95
  const saveGreyed = saveBlocked && !saveAllProgress && label === 'save';
83
- const indicatorText = draftStorageFailed
84
- ? 'Unsaved · not kept on this device'
85
- : 'Unsaved · kept on this device';
86
- const indicatorColor = draftStorageFailed ? 'var(--canopy-danger-text)' : 'dimmed';
87
- return (_jsxs(_Fragment, { children: [_jsx(Box, { role: "status", ta: "right", w: { base: 20, lg: 112 }, style: { flexShrink: 0 }, children: entryDirty && (_jsx(Tooltip, { label: draftStorageFailed ? `${indicatorText}. ${STORAGE_FAILED_HINT}` : indicatorText, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", lh: 1.2, c: indicatorColor, "data-testid": "unsaved-indicator", children: [_jsx(Box, { component: "span", visibleFrom: "lg", children: indicatorText }), _jsx(Box, { component: "span", hiddenFrom: "lg", display: "inline-flex", "aria-hidden": "true", children: draftStorageFailed ? _jsx(IconAlertTriangle, { size: 18 }) : _jsx(IconCircleDot, { size: 18 }) }), _jsx(VisuallyHidden, { component: "span", hiddenFrom: "lg", children: indicatorText }), draftStorageFailed && (_jsxs(VisuallyHidden, { component: "span", children: [". ", STORAGE_FAILED_HINT] }))] }) })) }), _jsx(VisuallyHidden, { role: "status", children: saveAllProgress
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
88
101
  ? `Saving ${saveAllProgress.total} ${saveAllProgress.total === 1 ? 'entry' : 'entries'}…`
89
102
  : label === 'saved'
90
103
  ? 'Saved'
91
- : '' }), _jsx("style", { children: LABEL_STYLES }), _jsxs(Button.Group, { style: { flexShrink: 0 }, children: [_jsx(Tooltip, { label: saveBlockedReason,
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,
92
105
  // Not beside "Saving…" or "Saved", which already say what happened.
93
- disabled: !saveBlockedReason || 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: () => {
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: () => {
94
107
  if (!saveBlocked)
95
108
  onSave();
96
- }, "data-disabled": saveGreyed ? true : undefined, "aria-disabled": saveBlocked ? true : undefined, 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,
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,
97
110
  // Light beside a greyed Save, so a blocked split button shows no filled half.
98
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 ??
99
112
  (saveAllProgress
@@ -4,7 +4,6 @@ export interface StagedChangesDrawerProps {
4
4
  /** Closes the drawer. Called directly when clean, only after a confirmed discard when dirty, and never while saving. */
5
5
  onClose: () => void;
6
6
  title: string;
7
- description: string;
8
7
  size: number;
9
8
  isDirty: boolean;
10
9
  isSaving: boolean;
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useReducer, useRef } from 'react';
4
- import { Badge, Button, Drawer, Fieldset, Group, Text, Title } from '@mantine/core';
4
+ import { Badge, Button, Fieldset, Group, Text } from '@mantine/core';
5
5
  import { openConfirm } from '../utils/confirm-modal.js';
6
6
  import { focusReturnProps, rememberOpener, returnTarget } from '../utils/focus-return.js';
7
+ import { OverlayDrawer } from '../shell/OverlayHeader.js';
7
8
  /**
8
9
  * A key that changes each time the drawer finishes closing. A panel keyed with it, and passing
9
10
  * `onExited` through, gets fresh state for each opening, as the drawer's own children do. A
@@ -33,7 +34,7 @@ function useBeforeUnloadWhileDirty(isDirty) {
33
34
  * them from being lost unnoticed: a title badge and a sticky save bar while dirty, a
34
35
  * discard confirm on close and on the save bar's Discard changes, and a `beforeunload` prompt.
35
36
  */
36
- export const StagedChangesDrawer = ({ opened, onClose, title, description, size, isDirty, isSaving, canEdit, saveLabel, onSave, onDiscard, childModalOpen = false, onExited, returnFocusTo, children, }) => {
37
+ export const StagedChangesDrawer = ({ opened, onClose, title, size, isDirty, isSaving, canEdit, saveLabel, onSave, onDiscard, childModalOpen = false, onExited, returnFocusTo, children, }) => {
37
38
  // A ref, not state: @mantine/modals runs the confirm's onClose inside its reducer, during
38
39
  // render, where setting another component's state draws a React warning. While the confirm is open the
39
40
  // drawer still hears Escape, so requestClose must ignore it.
@@ -76,10 +77,10 @@ export const StagedChangesDrawer = ({ opened, onClose, title, description, size,
76
77
  confirmDiscard('Discarding drops the changes you have not saved.', onDiscard, focusReturnProps(() => returnTarget(opener, () => titleRef.current)));
77
78
  }, [isSaving, onDiscard, confirmDiscard]);
78
79
  const { onExitTransitionEnd: returnFocus, ...focusReturn } = focusReturnProps(returnFocusTo);
79
- return (_jsxs(Drawer, { opened: opened, onClose: requestClose, closeOnEscape: !childModalOpen, ...focusReturn, onExitTransitionEnd: () => {
80
+ return (_jsxs(OverlayDrawer, { opened: opened, onClose: requestClose, closeOnEscape: !childModalOpen, ...focusReturn, onExitTransitionEnd: () => {
80
81
  returnFocus?.();
81
82
  onExited?.();
82
- }, position: "right", title: _jsxs("div", { children: [_jsxs(Group, { gap: "xs", children: [_jsx(Title, { order: 4, ref: titleRef, tabIndex: -1, children: title }), isDirty && (_jsx(Badge, { color: "orange", variant: "light", children: "Unsaved changes" }))] }), _jsx(Text, { size: "xs", c: "dimmed", children: description })] }), padding: "md", size: size, overlayProps: { blur: 2 }, children: [_jsx(Fieldset, { variant: "unstyled", disabled: isSaving, "aria-busy": isSaving, children: children }), canEdit && isDirty && (
83
+ }, position: "right", title: title, titleRef: titleRef, titleTabIndex: -1, adornment: isDirty && (_jsx(Badge, { color: "orange", variant: "light", children: "Unsaved changes" })), size: size, children: [_jsx(Fieldset, { variant: "unstyled", disabled: isSaving, "aria-busy": isSaving, children: children }), canEdit && isDirty && (
83
84
  // The drawer's content box is the scroll container, so a sticky bar stays in view
84
85
  // however long the panel's list grows.
85
86
  _jsxs(Group, { justify: "space-between", py: "sm", gap: "sm", style: {
@@ -1,8 +1,6 @@
1
1
  export interface UndoToastMessageProps {
2
- /** Names what was removed: `"Branch-aware" from Features`, `item 2 from Features`, `"SEO"`. */
2
+ /** Names what was removed: `"Branch-aware" from Features`, `item 2 from Features`, `the image from Hero image`. */
3
3
  label: string;
4
- /** `clear` for an optional object's Clear. */
5
- action?: 'remove' | 'clear';
6
4
  /** A sentence after the message, such as where the item's comments went. */
7
5
  note?: string;
8
6
  onUndo: () => void;
@@ -14,5 +12,8 @@ export interface UndoToastMessageProps {
14
12
  /** Called when focus leaves the toast, so its timer can run again. */
15
13
  onRelease?: () => void;
16
14
  }
17
- /** The body of the toast a form Remove or Clear raises in place of a confirmation. */
18
- export declare function UndoToastMessage({ label, action, note, onUndo, onHold, onRelease, }: UndoToastMessageProps): import("react/jsx-runtime").JSX.Element;
15
+ /**
16
+ * The body of the toast a form Remove raises in place of a confirmation. An object's Clear offers
17
+ * its Undo in the form instead (`ObjectField`), since the object stays where it was.
18
+ */
19
+ export declare function UndoToastMessage({ label, note, onUndo, onHold, onRelease, }: UndoToastMessageProps): import("react/jsx-runtime").JSX.Element;
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Button, Group, Text } from '@mantine/core';
3
- /** The body of the toast a form Remove or Clear raises in place of a confirmation. */
4
- export function UndoToastMessage({ label, action = 'remove', note, onUndo, onHold, onRelease, }) {
5
- const [done, doing] = action === 'clear' ? ['Cleared', 'clearing'] : ['Removed', 'removing'];
3
+ /**
4
+ * The body of the toast a form Remove raises in place of a confirmation. An object's Clear offers
5
+ * its Undo in the form instead (`ObjectField`), since the object stays where it was.
6
+ */
7
+ export function UndoToastMessage({ label, note, onUndo, onHold, onRelease, }) {
6
8
  return (_jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "nowrap", onFocus: onHold, onBlur: (event) => {
7
9
  if (!event.currentTarget.contains(event.relatedTarget))
8
10
  onRelease?.();
9
- }, children: [_jsx(Text, { size: "sm", miw: 0, children: note ? `${done} ${label}. ${note}` : `${done} ${label}` }), _jsx(Button, { size: "compact-sm", variant: "subtle", style: { flexShrink: 0 }, onClick: onUndo, "aria-label": `Undo ${doing} ${label}`, children: "Undo" })] }));
11
+ }, children: [_jsx(Text, { size: "sm", miw: 0, children: note ? `Removed ${label}. ${note}` : `Removed ${label}` }), _jsx(Button, { size: "compact-sm", variant: "subtle", style: { flexShrink: 0 }, onClick: onUndo, "aria-label": `Undo removing ${label}`, children: "Undo" })] }));
10
12
  }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import { type ElementProps } from '@mantine/core';
3
+ /**
4
+ * The editor's one "unsaved" mark, a dot, in the breadcrumb, the branch pill and the content tree.
5
+ * `--canopy-unsaved-mark` is the brand colour at 3:1 on the shell's surfaces in either scheme.
6
+ * The caller says whether it is named (`role="img"` and a label) or decoration (`aria-hidden`).
7
+ * It forwards its ref, so a Tooltip can wrap it.
8
+ */
9
+ export declare const UnsavedMark: React.ForwardRefExoticComponent<Omit<ElementProps<"span">, "color" | "style"> & React.RefAttributes<HTMLSpanElement>>;