canopycms 0.1.0 → 0.2.0-int.119

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 (113) hide show
  1. package/CHANGELOG.md +144 -41
  2. package/dist/api/assets.d.ts +27 -7
  3. package/dist/api/assets.js +98 -37
  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/finalize.js +14 -1
  10. package/dist/assets/keys.d.ts +4 -1
  11. package/dist/assets/keys.js +4 -1
  12. package/dist/assets/store-local.d.ts +18 -9
  13. package/dist/assets/store-local.js +58 -26
  14. package/dist/assets/store-s3.d.ts +19 -4
  15. package/dist/assets/store-s3.js +56 -13
  16. package/dist/assets/types.d.ts +26 -2
  17. package/dist/cli/cli.js +112 -34
  18. package/dist/editor/BranchManager.js +2 -1
  19. package/dist/editor/CommentsPanel.js +67 -29
  20. package/dist/editor/Editor.d.ts +3 -0
  21. package/dist/editor/Editor.js +58 -36
  22. package/dist/editor/EntryNavigator.d.ts +8 -1
  23. package/dist/editor/EntryNavigator.js +31 -16
  24. package/dist/editor/FormHeader.d.ts +2 -1
  25. package/dist/editor/FormHeader.js +9 -5
  26. package/dist/editor/FormRenderer.d.ts +7 -2
  27. package/dist/editor/FormRenderer.js +51 -16
  28. package/dist/editor/PreviewFrame.d.ts +11 -2
  29. package/dist/editor/PreviewFrame.js +20 -4
  30. package/dist/editor/PreviewPane.d.ts +15 -1
  31. package/dist/editor/PreviewPane.js +55 -8
  32. package/dist/editor/PreviewToolbarTools.d.ts +8 -0
  33. package/dist/editor/PreviewToolbarTools.js +9 -1
  34. package/dist/editor/branch-status.d.ts +6 -0
  35. package/dist/editor/branch-status.js +10 -0
  36. package/dist/editor/comments/BranchComments.d.ts +2 -4
  37. package/dist/editor/comments/BranchComments.js +7 -11
  38. package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
  39. package/dist/editor/comments/ThreadCarousel.js +4 -49
  40. package/dist/editor/comments/ThreadReplyControls.js +5 -2
  41. package/dist/editor/components/BranchLockBanner.d.ts +2 -0
  42. package/dist/editor/components/BranchLockBanner.js +6 -2
  43. package/dist/editor/components/BranchPill.d.ts +5 -4
  44. package/dist/editor/components/BranchPill.js +6 -8
  45. package/dist/editor/components/BranchesDrawer.js +3 -3
  46. package/dist/editor/components/EditorHeader.d.ts +2 -6
  47. package/dist/editor/components/EditorHeader.js +29 -28
  48. package/dist/editor/components/EditorRail.d.ts +3 -4
  49. package/dist/editor/components/EditorRail.js +3 -4
  50. package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
  51. package/dist/editor/components/EntryBreadcrumb.js +8 -11
  52. package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
  53. package/dist/editor/components/QuickOpenPalette.js +6 -1
  54. package/dist/editor/components/RenameEntryModal.d.ts +6 -1
  55. package/dist/editor/components/RenameEntryModal.js +20 -5
  56. package/dist/editor/components/SaveSplitButton.d.ts +3 -2
  57. package/dist/editor/components/SaveSplitButton.js +25 -12
  58. package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
  59. package/dist/editor/components/StagedChangesDrawer.js +5 -4
  60. package/dist/editor/components/UndoToastMessage.d.ts +6 -5
  61. package/dist/editor/components/UndoToastMessage.js +6 -4
  62. package/dist/editor/components/UnsavedMark.d.ts +9 -0
  63. package/dist/editor/components/UnsavedMark.js +20 -0
  64. package/dist/editor/components/UserBadge.js +13 -11
  65. package/dist/editor/copy.d.ts +4 -0
  66. package/dist/editor/copy.js +4 -0
  67. package/dist/editor/editor-utils.d.ts +49 -9
  68. package/dist/editor/editor-utils.js +117 -16
  69. package/dist/editor/entry-search.d.ts +4 -2
  70. package/dist/editor/entry-search.js +2 -3
  71. package/dist/editor/fields/FieldLabel.d.ts +1 -1
  72. package/dist/editor/fields/ObjectField.d.ts +13 -2
  73. package/dist/editor/fields/ObjectField.js +38 -9
  74. package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
  75. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
  76. package/dist/editor/group-manager/index.js +1 -1
  77. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  78. package/dist/editor/hooks/useEditorLayout.js +5 -0
  79. package/dist/editor/hooks/useEntryManager.d.ts +2 -0
  80. package/dist/editor/hooks/useEntryManager.js +10 -8
  81. package/dist/editor/media/AssetCard.d.ts +6 -3
  82. package/dist/editor/media/AssetCard.js +20 -7
  83. package/dist/editor/media/AssetListRow.d.ts +9 -4
  84. package/dist/editor/media/AssetListRow.js +14 -7
  85. package/dist/editor/media/MediaLibrary.js +5 -4
  86. package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
  87. package/dist/editor/media/MediaLibraryBody.js +65 -37
  88. package/dist/editor/media/useAssetLibrary.d.ts +6 -3
  89. package/dist/editor/media/useAssetLibrary.js +17 -10
  90. package/dist/editor/pane-header.d.ts +6 -0
  91. package/dist/editor/pane-header.js +6 -0
  92. package/dist/editor/permission-manager/index.js +1 -1
  93. package/dist/editor/preview-bridge.d.ts +16 -0
  94. package/dist/editor/preview-bridge.js +12 -3
  95. package/dist/editor/preview-path.d.ts +11 -4
  96. package/dist/editor/preview-path.js +62 -19
  97. package/dist/editor/shell/ActivityRail.d.ts +1 -3
  98. package/dist/editor/shell/ActivityRail.js +3 -3
  99. package/dist/editor/shell/EditorShell.d.ts +15 -0
  100. package/dist/editor/shell/EditorShell.js +17 -1
  101. package/dist/editor/shell/OverlayHeader.d.ts +19 -0
  102. package/dist/editor/shell/OverlayHeader.js +15 -0
  103. package/dist/editor/shell/PaneLayout.d.ts +6 -1
  104. package/dist/editor/shell/PaneLayout.js +68 -13
  105. package/dist/editor/shell/SidePanelHost.d.ts +8 -6
  106. package/dist/editor/shell/SidePanelHost.js +70 -33
  107. package/dist/editor/shell/TopBar.js +2 -1
  108. package/dist/editor/theme.js +2 -0
  109. package/dist/editor/undo-removal.d.ts +19 -10
  110. package/dist/editor/undo-removal.js +44 -16
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +1 -1
@@ -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>>;
@@ -0,0 +1,20 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { forwardRef } from 'react';
4
+ import { Box } from '@mantine/core';
5
+ /**
6
+ * The editor's one "unsaved" mark, a dot, in the breadcrumb, the branch pill and the content tree.
7
+ * `--canopy-unsaved-mark` is the brand colour at 3:1 on the shell's surfaces in either scheme.
8
+ * The caller says whether it is named (`role="img"` and a label) or decoration (`aria-hidden`).
9
+ * It forwards its ref, so a Tooltip can wrap it.
10
+ */
11
+ export const UnsavedMark = forwardRef(function UnsavedMark(props, ref) {
12
+ return (_jsx(Box, { ref: ref, component: "span", ...props, style: {
13
+ display: 'inline-block',
14
+ flexShrink: 0,
15
+ width: 8,
16
+ height: 8,
17
+ borderRadius: '50%',
18
+ backgroundColor: 'var(--canopy-unsaved-mark, var(--mantine-color-brand-6))',
19
+ } }));
20
+ });
@@ -29,11 +29,14 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
29
29
  // A light or outline badge tints its label with the badge colour, too faint for a name.
30
30
  const nameColor = inBadge && badgeVariant !== 'filled' ? 'var(--mantine-color-text)' : undefined;
31
31
  const wrap = (content, tooltip, personLabel) => {
32
- const wrapped = inBadge ? (_jsx(Badge, { variant: badgeVariant, color: color, pr: onRemove ? 3 : 8, pl: 4, rightSection: onRemove ? (_jsx(Tooltip, { label: `Remove ${personLabel}`, position: "bottom", children: _jsx(ActionIcon, { size: "xs", radius: "xl", variant: "transparent", onClick: () => onRemove(userId), onMouseEnter: () => setOnRemoveButton(true), onMouseLeave: () => setOnRemoveButton(false), onFocus: () => setOnRemoveButton(true), onBlur: () => setOnRemoveButton(false), "aria-label": `Remove ${personLabel}`, style: { color: nameColor ?? 'var(--mantine-color-white)' }, children: _jsx(IconX, { size: 12, stroke: 2.5 }) }) })) : undefined, styles: {
32
+ const wrapped = inBadge ? (_jsx(Badge, { variant: badgeVariant, color: color, pr: onRemove ? 3 : 8, pl: 4,
33
+ // A long name shrinks the badge to its container instead of wrapping inside its fixed height.
34
+ maw: "100%", rightSection: onRemove ? (_jsx(Tooltip, { label: `Remove ${personLabel}`, position: "bottom", children: _jsx(ActionIcon, { size: "xs", radius: "xl", variant: "transparent", onClick: () => onRemove(userId), onMouseEnter: () => setOnRemoveButton(true), onMouseLeave: () => setOnRemoveButton(false), onFocus: () => setOnRemoveButton(true), onBlur: () => setOnRemoveButton(false), "aria-label": `Remove ${personLabel}`, style: { color: nameColor ?? 'var(--mantine-color-white)' }, children: _jsx(IconX, { size: 12, stroke: 2.5 }) }) })) : undefined, styles: {
33
35
  label: {
34
36
  display: 'flex',
35
37
  alignItems: 'center',
36
38
  gap: '4px',
39
+ minWidth: 0,
37
40
  },
38
41
  }, children: content })) : (content);
39
42
  return showEmailTooltip ? (_jsx(Tooltip, { label: tooltip, disabled: !tooltip || onRemoveButton, children: wrapped })) : (wrapped);
@@ -49,9 +52,9 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
49
52
  const describedWithEmail = emailDetail ? `${described}, ${emailDetail}` : described;
50
53
  // A filled badge keeps its white text; dimmed would not contrast with its fill.
51
54
  const fallbackColor = inBadge && badgeVariant === 'filled' ? undefined : 'dimmed';
52
- const unavailableText = (_jsx(Text, { size: textSize, c: fallbackColor, fs: "italic", children: unavailable }));
55
+ const unavailableText = (_jsx(Text, { size: textSize, c: fallbackColor, fs: "italic", truncate: true, children: unavailable }));
53
56
  const hiddenDetail = `${revealIdOnFallback ? ` · ${userId}` : ''}${emailDetail ? `, ${emailDetail}` : ''}`;
54
- return wrap(_jsxs(Group, { gap: 4, children: [_jsx(Avatar, { src: record?.avatarUrl, alt: "", size: avatarSize, color: "gray", "aria-hidden": true }), variant === 'avatar-only' ? (_jsx(VisuallyHidden, { component: "span", children: describedWithEmail })) : (_jsxs(_Fragment, { children: [variant === 'full' && record?.email ? (_jsxs("div", { children: [unavailableText, _jsx(Text, { size: "xs", c: "dimmed", children: record.email })] })) : (unavailableText), hiddenDetail && _jsx(VisuallyHidden, { component: "span", children: hiddenDetail })] }))] }), variant === 'avatar-only'
57
+ return wrap(_jsxs(Group, { gap: 4, wrap: "nowrap", miw: 0, children: [_jsx(Avatar, { src: record?.avatarUrl, alt: "", size: avatarSize, color: "gray", "aria-hidden": true }), variant === 'avatar-only' ? (_jsx(VisuallyHidden, { component: "span", children: describedWithEmail })) : (_jsxs(_Fragment, { children: [variant === 'full' && record?.email ? (_jsxs("div", { children: [unavailableText, _jsx(Text, { size: "xs", c: "dimmed", children: record.email })] })) : (unavailableText), hiddenDetail && _jsx(VisuallyHidden, { component: "span", children: hiddenDetail })] }))] }), variant === 'avatar-only'
55
58
  ? describedWithEmail
56
59
  : [revealIdOnFallback ? described : '', emailDetail].filter(Boolean).join(', '), described);
57
60
  }
@@ -89,23 +92,22 @@ export const UserBadge = ({ userId, getUserMetadata, variant = 'avatar-name', sh
89
92
  const detail = showEmailTooltip && variant !== 'full' && userId !== 'anonymous' ? record.email : '';
90
93
  const detailText = detail ? (_jsx(VisuallyHidden, { component: "span", children: `, ${detail}` })) : null;
91
94
  const personLabel = userId === 'anonymous' ? 'Anonymous (Public)' : displayName;
95
+ // Alone, the avatar shows no name, so its tooltip gives it before the email. A name beside the
96
+ // avatar is cut with an ellipsis when long, so its tooltip gives the full name too; with no
97
+ // tooltip, the name carries it as a title.
98
+ const tooltip = variant === 'full' ? '' : [personLabel, detail].filter(Boolean).join(', ');
99
+ const nameTitle = showEmailTooltip && tooltip ? undefined : personLabel;
92
100
  const renderContent = () => {
93
101
  const avatar = renderAvatar();
94
102
  switch (variant) {
95
103
  case 'avatar-only':
96
104
  return (_jsxs(Group, { gap: 0, children: [avatar, _jsx(VisuallyHidden, { component: "span", children: personLabel }), detailText] }));
97
105
  case 'full':
98
- return (_jsxs(Group, { gap: 4, children: [avatar, _jsxs("div", { children: [_jsx(Text, { size: textSize, fw: 500, children: displayName }), _jsx(Text, { size: "xs", c: "dimmed", children: record.email })] })] }));
106
+ return (_jsxs(Group, { gap: 4, wrap: "nowrap", miw: 0, children: [avatar, _jsxs("div", { style: { minWidth: 0 }, children: [_jsx(Text, { size: textSize, fw: 500, truncate: true, title: displayName, children: displayName }), _jsx(Text, { size: "xs", c: "dimmed", truncate: true, title: record.email, children: record.email })] })] }));
99
107
  case 'avatar-name':
100
108
  default:
101
- return (_jsxs(Group, { gap: 4, children: [avatar, _jsx(Text, { size: textSize, fw: 500, c: nameColor, children: personLabel }), detailText] }));
109
+ return (_jsxs(Group, { gap: 4, wrap: "nowrap", miw: 0, children: [avatar, _jsx(Text, { size: textSize, fw: 500, c: nameColor, truncate: true, title: nameTitle, children: personLabel }), detailText] }));
102
110
  }
103
111
  };
104
- // Alone, the avatar shows no name, so its tooltip gives it before the email.
105
- const tooltip = variant === 'full'
106
- ? ''
107
- : variant === 'avatar-only'
108
- ? [personLabel, detail].filter(Boolean).join(', ')
109
- : record.email;
110
112
  return wrap(renderContent(), tooltip, personLabel);
111
113
  };
@@ -13,6 +13,10 @@ export declare const EDITOR_ACTIONS: {
13
13
  readonly switchBranch: "Switch branch";
14
14
  readonly newBranch: "New branch";
15
15
  readonly remove: "Remove";
16
+ readonly restore: "Restore";
16
17
  readonly clear: "Clear";
18
+ readonly undoClear: "Undo clear";
17
19
  readonly openEntry: "Open entry";
20
+ readonly changeUrl: "Change URL";
21
+ readonly changeSlug: "Change slug";
18
22
  };
@@ -13,6 +13,10 @@ export const EDITOR_ACTIONS = {
13
13
  switchBranch: 'Switch branch',
14
14
  newBranch: 'New branch',
15
15
  remove: 'Remove',
16
+ restore: 'Restore',
16
17
  clear: 'Clear',
18
+ undoClear: 'Undo clear',
17
19
  openEntry: 'Open entry',
20
+ changeUrl: 'Change URL',
21
+ changeSlug: 'Change slug',
18
22
  };
@@ -31,6 +31,14 @@ type PreviewEntry = {
31
31
  export declare const buildPreviewRoute: (entry: PreviewEntry, { previewBaseByCollection, contentRoot }: PreviewContext & {
32
32
  contentRoot?: string;
33
33
  }) => string | undefined;
34
+ /**
35
+ * The entry the editor opens when none is asked for: the one whose route is `/`, else the first
36
+ * with a preview, else the first. `routeFor` gives the route before any prefix or base path
37
+ * (`buildPreviewRoute`), so `previewPrefix` and `basePath` never hide the root page.
38
+ */
39
+ export declare const resolveHomeEntry: <T extends {
40
+ previewSrc?: string;
41
+ }>(entries: readonly T[], routeFor?: (entry: T) => string | undefined) => T | undefined;
34
42
  /**
35
43
  * Builds the preview iframe `src` for an entry: its route under `previewPrefix`, under the
36
44
  * deployment `basePath` (`CanopyClientConfig.basePath`, e.g. `/preview-123`), in the host's
@@ -67,19 +75,51 @@ export declare const buildCollectionLabels: (collections?: EditorCollection[]) =
67
75
  * untyped.
68
76
  */
69
77
  export declare const entryTypeLabel: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType">) => string | undefined;
70
- /** An entry's navigator badge: "Missing" when its file is gone, else its type's label or name. */
78
+ /**
79
+ * The type shown under an entry's label, in the content tree and quick open: `entryTypeLabel`,
80
+ * except for a singleton, whose label already is its type's (`singletonLabel`).
81
+ */
82
+ export declare const entryTypeLine: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType">) => string | undefined;
83
+ /**
84
+ * An entry's second line in the content tree: "Missing" when its file is gone, else its
85
+ * `entryTypeLine`, else its status read as words.
86
+ */
71
87
  export declare const entryStatusLabel: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "status" | "collectionPath" | "entryType">) => string | undefined;
72
- /** One crumb before the entry's own: "All content", a collection, or a folder of a nested slug. */
73
- export interface BreadcrumbSegment {
88
+ /**
89
+ * Where an entry's name comes from, which "Change URL" explains. The title chain is the server's
90
+ * (`resolveEntryTitle`): a non-empty title-field value, else a non-empty string `title` or `name`
91
+ * value whatever its field's type, else the type's label, else the slug. A labelled singleton is
92
+ * named by its type whatever its title. `fieldLabel` is absent when no field of that name is in the
93
+ * schema.
94
+ */
95
+ export type EntryNameSource = {
96
+ from: 'type';
97
+ } | {
98
+ from: 'title';
99
+ fieldLabel?: string;
100
+ } | {
101
+ from: 'slug';
102
+ };
103
+ /**
104
+ * `data` is the entry's value where it is at hand; without it, a schema with a title field, or a
105
+ * `title` or `name` field, is taken to name the entry from it.
106
+ */
107
+ export declare const entryNameSource: (collections: EditorCollection[] | undefined, entry: Pick<EditorEntry, "collectionPath" | "entryType" | "schema">, data?: Record<string, unknown>) => EntryNameSource;
108
+ /** One crumb before the entry's own: a collection, or a folder of a nested slug. */
109
+ export type BreadcrumbSegment = {
110
+ label: string;
111
+ /** The collection the crumb reveals in the content panel. */
112
+ collectionPath: string;
113
+ folder?: undefined;
114
+ } | {
74
115
  label: string;
75
- /** The collection the crumb opens in the content panel; absent on "All content" and slug folders. */
76
- collectionPath?: string;
77
116
  /** A slug folder, which is not a node of the content tree, so it navigates nowhere. */
78
- folder?: true;
79
- }
117
+ folder: true;
118
+ collectionPath?: undefined;
119
+ };
80
120
  /**
81
- * Builds breadcrumb segments for an entry based on its collection hierarchy, starting with
82
- * "All content". The first path part is skipped as the content root, which the tree does not show.
121
+ * Builds breadcrumb segments for an entry based on its collection hierarchy. The first path part
122
+ * is skipped as the content root, which the tree does not show, so a root entry has none.
83
123
  */
84
124
  export declare const buildBreadcrumbSegments: (currentEntry: EditorEntry | undefined, collectionLabels: Map<string, string>) => BreadcrumbSegment[];
85
125
  /**
@@ -1,7 +1,8 @@
1
1
  // Import directly from normalize to avoid pulling in server-only branch.ts
2
2
  import { normalizeFilesystemPath } from '../paths/normalize.js';
3
- import { humanizeName } from './schema-label.js';
3
+ import { humanizeName, schemaLabel } from './schema-label.js';
4
4
  import { computeEntryUrl, isIndexSlug } from '../utils/entry-url.js';
5
+ import { titleFieldPath } from '../utils/title-field.js';
5
6
  import { isDataOnlyFormat } from '../utils/format.js';
6
7
  import { isAbsoluteUrl, joinUrlPrefix, matchTrailingSlash, readTrailingSlashEnv, stripTrailingSlashes, } from '../utils/url-prefix.js';
7
8
  /**
@@ -67,6 +68,18 @@ export const buildPreviewRoute = (entry, { previewBaseByCollection, contentRoot
67
68
  const trimmed = stripTrailingSlashes(basePathPart);
68
69
  return `${encoded ? `${trimmed}/${encoded}` : basePathPart || '/'}${suffix}`;
69
70
  };
71
+ /** Whether a `buildPreviewRoute` route is the site's root page: `/`, with any query or fragment. */
72
+ const isHomeRoute = (route) => route !== undefined &&
73
+ !isAbsoluteUrl(route) &&
74
+ stripTrailingSlashes(splitPathSuffix(route)[0]) === '';
75
+ /**
76
+ * The entry the editor opens when none is asked for: the one whose route is `/`, else the first
77
+ * with a preview, else the first. `routeFor` gives the route before any prefix or base path
78
+ * (`buildPreviewRoute`), so `previewPrefix` and `basePath` never hide the root page.
79
+ */
80
+ export const resolveHomeEntry = (entries, routeFor) => (routeFor && entries.find((entry) => isHomeRoute(routeFor(entry)))) ||
81
+ entries.find((entry) => entry.previewSrc) ||
82
+ entries[0];
70
83
  /**
71
84
  * Builds the preview iframe `src` for an entry: its route under `previewPrefix`, under the
72
85
  * deployment `basePath` (`CanopyClientConfig.basePath`, e.g. `/preview-123`), in the host's
@@ -129,11 +142,18 @@ export const buildWritePayload = (entry, value) => {
129
142
  body: typeof body === 'string' ? body : '',
130
143
  };
131
144
  };
145
+ /**
146
+ * The label an entry of this type goes by when the type allows only one entry (`maxItems: 1`):
147
+ * the type's own, as "Site settings" names the site's one settings entry. Undefined for any other
148
+ * type, and for an unlabelled one, whose entry keeps its title.
149
+ */
150
+ const singletonLabel = (type) => type.maxItems === 1 && type.label ? type.label : undefined;
132
151
  export const buildEntriesFromListResponse = ({ response, resolvePreviewSrc, flatSchema, }) => {
133
152
  return response.entries.map((entry) => {
134
153
  // Resolve schema from flatSchema using parentPath + name
135
154
  let schema = [];
136
155
  let unavailable;
156
+ let typeName;
137
157
  if (entry.collectionPath && entry.entryType) {
138
158
  const entryTypeItem = flatSchema.find((item) => item.type === 'entry-type' &&
139
159
  item.parentPath === entry.collectionPath &&
@@ -141,12 +161,14 @@ export const buildEntriesFromListResponse = ({ response, resolvePreviewSrc, flat
141
161
  if (entryTypeItem && entryTypeItem.type === 'entry-type') {
142
162
  schema = entryTypeItem.schema;
143
163
  unavailable = entryTypeItem.unavailable;
164
+ typeName = singletonLabel(entryTypeItem);
144
165
  }
145
166
  }
146
167
  return {
147
168
  path: entry.logicalPath,
148
169
  contentId: entry.contentId,
149
- label: entry.title || entry.slug || entry.collectionName || entry.collectionPath,
170
+ label: typeName || entry.title || entry.slug || entry.collectionName || entry.collectionPath,
171
+ ...(entry.title && { title: entry.title }),
150
172
  status: entry.exists === false ? 'missing' : (entry.entryType ?? 'entry'),
151
173
  schema: schema,
152
174
  previewSrc: resolvePreviewSrc(entry),
@@ -176,13 +198,7 @@ export const buildCollectionLabels = (collections) => {
176
198
  walk(collections);
177
199
  return map;
178
200
  };
179
- /**
180
- * An entry's type as its collection labels it, else its name read as words; undefined when
181
- * untyped.
182
- */
183
- export const entryTypeLabel = (collections, entry) => {
184
- if (!entry.entryType)
185
- return undefined;
201
+ const findEntryType = (collections, entry) => {
186
202
  const find = (nodes) => {
187
203
  for (const c of nodes) {
188
204
  if (c.path === entry.collectionPath)
@@ -194,21 +210,106 @@ export const entryTypeLabel = (collections, entry) => {
194
210
  return undefined;
195
211
  };
196
212
  const collection = collections ? find(collections) : undefined;
197
- const type = collection?.entryTypes?.find((t) => t.name === entry.entryType);
198
- return type?.label ?? humanizeName(entry.entryType);
213
+ return collection?.entryTypes?.find((t) => t.name === entry.entryType);
214
+ };
215
+ /**
216
+ * An entry's type as its collection labels it, else its name read as words; undefined when
217
+ * untyped.
218
+ */
219
+ export const entryTypeLabel = (collections, entry) => {
220
+ if (!entry.entryType)
221
+ return undefined;
222
+ return findEntryType(collections, entry)?.label ?? humanizeName(entry.entryType);
199
223
  };
200
- /** An entry's navigator badge: "Missing" when its file is gone, else its type's label or name. */
224
+ /**
225
+ * The type shown under an entry's label, in the content tree and quick open: `entryTypeLabel`,
226
+ * except for a singleton, whose label already is its type's (`singletonLabel`).
227
+ */
228
+ export const entryTypeLine = (collections, entry) => {
229
+ const type = findEntryType(collections, entry);
230
+ return type && singletonLabel(type) ? undefined : entryTypeLabel(collections, entry);
231
+ };
232
+ /**
233
+ * An entry's second line in the content tree: "Missing" when its file is gone, else its
234
+ * `entryTypeLine`, else its status read as words.
235
+ */
201
236
  export const entryStatusLabel = (collections, entry) => {
202
237
  if (entry.status === 'missing')
203
238
  return 'Missing';
204
- return entryTypeLabel(collections, entry) ?? (entry.status && humanizeName(entry.status));
239
+ if (entry.entryType)
240
+ return entryTypeLine(collections, entry);
241
+ return entry.status && humanizeName(entry.status);
242
+ };
243
+ /**
244
+ * `data` is the entry's value where it is at hand; without it, a schema with a title field, or a
245
+ * `title` or `name` field, is taken to name the entry from it.
246
+ */
247
+ export const entryNameSource = (collections, entry, data) => {
248
+ const type = findEntryType(collections, entry);
249
+ if (type && singletonLabel(type))
250
+ return { from: 'type' };
251
+ const titlePath = titleFieldPath(entry.schema);
252
+ if (data) {
253
+ const plain = data.title ?? data.name;
254
+ const plainName = isNonEmptyString(plain) ? (data.title != null ? 'title' : 'name') : undefined;
255
+ if (titlePath && isNonEmptyString(valueAt(data, titlePath))) {
256
+ return { from: 'title', fieldLabel: pathLabel(entry.schema, titlePath) };
257
+ }
258
+ if (plainName)
259
+ return { from: 'title', fieldLabel: pathLabel(entry.schema, [plainName]) };
260
+ }
261
+ else {
262
+ if (titlePath)
263
+ return { from: 'title', fieldLabel: pathLabel(entry.schema, titlePath) };
264
+ const named = ['title', 'name'].find((name) => findField(entry.schema, name));
265
+ if (named)
266
+ return { from: 'title', fieldLabel: pathLabel(entry.schema, [named]) };
267
+ }
268
+ if (type?.label)
269
+ return { from: 'type' };
270
+ return { from: 'slug' };
271
+ };
272
+ const isNonEmptyString = (value) => typeof value === 'string' && value !== '';
273
+ /** The value at a data `path`, through nested objects. */
274
+ const valueAt = (data, path) => path.reduce((value, key) => value && typeof value === 'object' && !Array.isArray(value)
275
+ ? value[key]
276
+ : undefined, data);
277
+ /**
278
+ * The label of the field at a data `path`, with its enclosing objects' labels before it
279
+ * ("Hero › Heading"); undefined when the schema has no such field.
280
+ */
281
+ const pathLabel = (schema, path) => {
282
+ const labels = [];
283
+ let fields = schema;
284
+ for (const name of path) {
285
+ const field = fields && findField(fields, name);
286
+ if (!field)
287
+ return undefined;
288
+ labels.push(schemaLabel(field));
289
+ fields = field.type === 'object' && 'fields' in field && !field.list ? field.fields : undefined;
290
+ }
291
+ return labels.join(' › ');
292
+ };
293
+ const isInlineGroup = (field) => field.type === 'group';
294
+ /** The field named `name` among `fields`, looking through inline groups, which hold no data. */
295
+ const findField = (fields, name) => {
296
+ for (const field of fields) {
297
+ if (isInlineGroup(field)) {
298
+ const inner = findField(field.fields, name);
299
+ if (inner)
300
+ return inner;
301
+ }
302
+ else if (field.name === name)
303
+ return field;
304
+ }
305
+ return undefined;
205
306
  };
206
307
  /**
207
- * Builds breadcrumb segments for an entry based on its collection hierarchy, starting with
208
- * "All content". The first path part is skipped as the content root, which the tree does not show.
308
+ * Builds breadcrumb segments for an entry based on its collection hierarchy. The first path part
309
+ * is skipped as the content root, which the tree does not show, so a root entry has none.
209
310
  */
210
311
  export const buildBreadcrumbSegments = (currentEntry, collectionLabels) => {
211
- const segments = [{ label: 'All content' }];
312
+ const segments = [];
212
313
  if (!currentEntry)
213
314
  return segments;
214
315
  if (currentEntry.collectionPath) {
@@ -1,12 +1,14 @@
1
1
  /** Entry search shared by the navigator filter and the quick-open palette. */
2
2
  export interface SearchableEntry {
3
3
  label: string;
4
+ /** The entry's title, when its label is something else (a singleton's type label). */
5
+ title?: string;
4
6
  slug?: string;
5
7
  collectionPath?: string;
6
8
  collectionName?: string;
7
9
  }
8
- /** Case-insensitive substring match over label and slug; an empty query matches everything. */
9
- export declare function matchesEntryQuery(entry: Pick<SearchableEntry, 'label' | 'slug'>, query: string): boolean;
10
+ /** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
11
+ export declare function matchesEntryQuery(entry: Pick<SearchableEntry, 'label' | 'title' | 'slug'>, query: string): boolean;
10
12
  export declare function filterEntries<T extends SearchableEntry>(entries: readonly T[], query: string): T[];
11
13
  export interface EntryGroup<T> {
12
14
  heading: string;
@@ -1,11 +1,10 @@
1
1
  /** Entry search shared by the navigator filter and the quick-open palette. */
2
- /** Case-insensitive substring match over label and slug; an empty query matches everything. */
2
+ /** Case-insensitive substring match over label, title and slug; an empty query matches everything. */
3
3
  export function matchesEntryQuery(entry, query) {
4
4
  const needle = query.trim().toLowerCase();
5
5
  if (!needle)
6
6
  return true;
7
- return (entry.label.toLowerCase().includes(needle) ||
8
- (entry.slug?.toLowerCase().includes(needle) ?? false));
7
+ return [entry.label, entry.title, entry.slug].some((text) => text?.toLowerCase().includes(needle));
9
8
  }
10
9
  export function filterEntries(entries, query) {
11
10
  return entries.filter((entry) => matchesEntryQuery(entry, query));
@@ -18,7 +18,7 @@ export interface FieldLabelProps {
18
18
  labelId?: string;
19
19
  /** Slot right after the label text, for the field's comment control. */
20
20
  commentControl?: React.ReactNode;
21
- /** Right-aligned slot for field actions (Expand all, Clear). */
21
+ /** Right-aligned slot for field actions (Expand all, an object's "…" menu). */
22
22
  actions?: React.ReactNode;
23
23
  }
24
24
  export declare const FieldLabel: React.FC<FieldLabelProps>;
@@ -11,7 +11,18 @@ export interface ObjectFieldProps {
11
11
  renderField: RenderField;
12
12
  path: Array<string | number>;
13
13
  dataCanopyField?: string;
14
- /** Set, shows a gray "Clear" resetting the field to `undefined`; omitted for list items. */
15
- onRemove?: () => void;
14
+ /** Set, a "…" menu offers Clear, resetting the field to `undefined`; omitted for list items. */
15
+ onClear?: () => void;
16
+ /** Set, an "Undo clear" button stands in the label row in place of the "…" menu. */
17
+ onUndoClear?: () => void;
18
+ /** Said with the Undo, such as where the object's comment threads went. */
19
+ clearNote?: string;
16
20
  }
21
+ /**
22
+ * An object field's group of fields. Its Clear is an Undo-instead-of-confirm removal
23
+ * (docs/ux-guidelines.md, Destructive actions) whose Undo stays in the label row, where the clear
24
+ * happened, with no timer, until the caller drops `onUndoClear`. FormRenderer drops it once it
25
+ * can no longer put the object back where it was, another entry or branch opens, or the entry is
26
+ * saved or its draft discarded.
27
+ */
17
28
  export declare const ObjectField: React.FC<ObjectFieldProps>;