canopycms 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +199 -41
  2. package/dist/api/branch.d.ts +2 -2
  3. package/dist/cli/cli.js +0 -18
  4. package/dist/cli/generate-ai-content.js +0 -6
  5. package/dist/cli/init.js +0 -6
  6. package/dist/editor/BranchManager.d.ts +2 -0
  7. package/dist/editor/BranchManager.js +3 -3
  8. package/dist/editor/Editor.js +253 -128
  9. package/dist/editor/EntryNavigator.d.ts +44 -9
  10. package/dist/editor/EntryNavigator.js +275 -103
  11. package/dist/editor/branch-lock.d.ts +42 -0
  12. package/dist/editor/branch-lock.js +79 -0
  13. package/dist/editor/branch-status.d.ts +0 -6
  14. package/dist/editor/branch-status.js +0 -10
  15. package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
  16. package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
  17. package/dist/editor/components/BranchPill.d.ts +3 -18
  18. package/dist/editor/components/BranchPill.js +7 -10
  19. package/dist/editor/components/EditorHeader.d.ts +24 -52
  20. package/dist/editor/components/EditorHeader.js +151 -71
  21. package/dist/editor/components/EditorRail.d.ts +8 -8
  22. package/dist/editor/components/EditorRail.js +7 -5
  23. package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
  24. package/dist/editor/components/EntryBreadcrumb.js +3 -3
  25. package/dist/editor/components/EntryHeader.d.ts +60 -0
  26. package/dist/editor/components/EntryHeader.js +45 -0
  27. package/dist/editor/components/EntrySaveButton.d.ts +24 -0
  28. package/dist/editor/components/EntrySaveButton.js +101 -0
  29. package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
  30. package/dist/editor/components/NoEditPermissionNotice.js +1 -1
  31. package/dist/editor/components/RetryToastMessage.d.ts +3 -1
  32. package/dist/editor/components/RetryToastMessage.js +8 -4
  33. package/dist/editor/components/SaveAllButton.d.ts +25 -0
  34. package/dist/editor/components/SaveAllButton.js +81 -0
  35. package/dist/editor/components/UnsavedMark.d.ts +1 -1
  36. package/dist/editor/components/UnsavedMark.js +1 -1
  37. package/dist/editor/components/index.d.ts +1 -1
  38. package/dist/editor/components/index.js +1 -1
  39. package/dist/editor/copy.d.ts +0 -1
  40. package/dist/editor/copy.js +0 -1
  41. package/dist/editor/editor-utils.d.ts +2 -2
  42. package/dist/editor/editor-utils.js +2 -2
  43. package/dist/editor/entry-search.d.ts +10 -16
  44. package/dist/editor/entry-search.js +4 -24
  45. package/dist/editor/hooks/useBranchManager.d.ts +7 -3
  46. package/dist/editor/hooks/useBranchManager.js +13 -9
  47. package/dist/editor/hooks/useEntryManager.d.ts +3 -0
  48. package/dist/editor/hooks/useEntryManager.js +10 -3
  49. package/dist/editor/shell/ActivityRail.d.ts +4 -0
  50. package/dist/editor/shell/ActivityRail.js +13 -4
  51. package/dist/editor/shell/EditorShell.d.ts +6 -7
  52. package/dist/editor/shell/EditorShell.js +9 -13
  53. package/dist/editor/shell/PaneLayout.d.ts +1 -0
  54. package/dist/editor/shell/PaneLayout.js +23 -5
  55. package/dist/editor/shell/SidePanelHost.d.ts +11 -4
  56. package/dist/editor/shell/SidePanelHost.js +95 -7
  57. package/dist/editor/shell/TopBar.d.ts +4 -4
  58. package/dist/editor/shell/TopBar.js +6 -3
  59. package/dist/editor/utils/focus-return.d.ts +10 -2
  60. package/dist/editor/utils/focus-return.js +40 -6
  61. package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
  62. package/dist/operating-mode/client-safe-strategy.js +1 -10
  63. package/dist/operating-mode/types.d.ts +0 -3
  64. package/dist/version.d.ts +1 -1
  65. package/dist/version.js +1 -1
  66. package/package.json +1 -1
  67. package/dist/editor/components/BranchLockBanner.d.ts +0 -23
  68. package/dist/editor/components/BranchLockBanner.js +0 -66
  69. package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
  70. package/dist/editor/components/QuickOpenPalette.js +0 -71
  71. package/dist/editor/components/SaveSplitButton.d.ts +0 -31
  72. package/dist/editor/components/SaveSplitButton.js +0 -121
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { forwardRef, useId, useLayoutEffect, useRef, useState, } from 'react';
4
- import { ActionIcon, Indicator, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
4
+ import { ActionIcon, Box, Divider, Indicator, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
5
5
  import { useElementSize, useMergedRef } from '@mantine/hooks';
6
6
  const ACTIVITY_RAIL_WIDTH = 44;
7
7
  const RAIL_ITEM_ATTR = 'data-rail-item';
@@ -74,18 +74,27 @@ export function ActivityRail({ top, bottom, account }) {
74
74
  }
75
75
  }, children: [top, _jsx("div", { style: { flex: 1 } }), bottom] }), account && _jsx(Stack, { pb: 8, children: account })] }));
76
76
  }
77
+ /** Sets the buttons after it apart. It carries no `data-rail-item`, so the arrow keys skip it. */
78
+ export function RailDivider() {
79
+ return _jsx(Divider, { w: 24, my: 2 });
80
+ }
77
81
  /** A rail button: icon only, named by `label`, which its tooltip repeats on hover and focus. */
78
- export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, shortcut, count = 0, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
82
+ export const RailButton = forwardRef(function RailButton({ railId, label, icon, active = false, shortcut, count = 0, corner, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
79
83
  const reasonId = useId();
80
84
  const button = (_jsxs(ActionIcon, { ref: ref,
81
85
  // Filled when active: a tint alone would show the state by colour, under 3:1.
82
86
  variant: active ? 'filled' : 'subtle', color: active ? undefined : 'gray', size: "lg", radius: "md", "aria-label": label, ...rest, "aria-disabled": disabledReason ? true : rest['aria-disabled'], "aria-describedby": disabledReason ? reasonId : rest['aria-describedby'], "data-disabled": disabledReason ? true : undefined, onClick: disabledReason ? undefined : onClick, [RAIL_ITEM_ATTR]: railId, children: [icon, disabledReason && (_jsx(VisuallyHidden, { component: "span", id: reasonId, children: disabledReason }))] }));
83
- return (_jsx(Tooltip, { label: disabledReason ?? (shortcut ? `${label} (${shortcut})` : label), position: "right", disabled: tooltipDisabled, children: _jsx(Indicator, { label: _jsx("span", { "aria-hidden": true, "data-testid": countTestId, children: count }), size: 16, offset: 4, withBorder: true, disabled: count === 0, styles: {
87
+ return (_jsx(Tooltip, { label: disabledReason ?? (shortcut ? `${label} (${shortcut})` : label), position: "right", disabled: tooltipDisabled, children: _jsxs(Indicator, { label: _jsx("span", { "aria-hidden": true, "data-testid": countTestId, children: count }), size: 16, offset: 4, withBorder: true, disabled: count === 0, styles: {
84
88
  indicator: {
85
89
  pointerEvents: 'none',
86
90
  // Mantine paints the pill again in a ::before, from this variable.
87
91
  '--indicator-color': 'var(--canopy-count-bg)',
88
92
  '--indicator-text-color': 'var(--mantine-color-text)',
89
93
  },
90
- }, children: button }) }));
94
+ }, children: [button, corner && (_jsx(Box, { component: "span", "aria-hidden": true, pos: "absolute", top: 5, right: 5, style: {
95
+ pointerEvents: 'none',
96
+ lineHeight: 0,
97
+ borderRadius: '50%',
98
+ boxShadow: '0 0 0 2px var(--mantine-color-body)',
99
+ }, children: corner }))] }) }));
91
100
  });
@@ -1,14 +1,13 @@
1
1
  import { type ReactNode } from 'react';
2
2
  export interface EditorShellProps {
3
3
  topBar?: ReactNode;
4
- banner?: ReactNode;
5
4
  rail?: ReactNode;
6
5
  panel?: ReactNode;
7
6
  main: ReactNode;
8
7
  /** Drawers, modals and palettes, each portalling itself into `<body>`. */
9
8
  overlays?: ReactNode;
10
9
  }
11
- /** The line between two shell regions, as under the top bar and a shown banner. */
10
+ /** The line between two shell regions, as under the top bar. */
12
11
  export declare const SHELL_REGION_BORDER = "1px solid var(--canopy-input-border)";
13
12
  /**
14
13
  * Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
@@ -24,9 +23,9 @@ export declare const DIVIDER_TARGET_PX: {
24
23
  /** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
25
24
  export declare const DIVIDER_COLOR = "var(--canopy-input-border)";
26
25
  /**
27
- * The editor's window: a full-viewport grid of regions (top bar, banner, rail, panel, main,
28
- * overlays), portalled into `<body>` above the host page, whose chrome it hides and makes inert
29
- * and whose scrolling it stops. Layout only: features plug into the regions. The rail sits at the
30
- * inline start, opposite the toasts (`theme.tsx`), and comes first after the banner in focus order.
26
+ * The editor's window: a full-viewport grid of regions (top bar, rail, panel, main, overlays),
27
+ * portalled into `<body>` above the host page, whose chrome it hides and makes inert and whose
28
+ * scrolling it stops. Layout only: features plug into the regions. The rail sits at the inline
29
+ * start, opposite the toasts (`theme.tsx`), and comes first after the top bar in focus order.
31
30
  */
32
- export declare function EditorShell({ topBar, banner, rail, panel, main, overlays }: EditorShellProps): import("react/jsx-runtime").JSX.Element;
31
+ export declare function EditorShell({ topBar, rail, panel, main, overlays }: EditorShellProps): import("react/jsx-runtime").JSX.Element;
@@ -4,7 +4,7 @@ import { useRef } from 'react';
4
4
  import { Portal, getDefaultZIndex } from '@mantine/core';
5
5
  import { RemoveScroll } from 'react-remove-scroll';
6
6
  import { useHideHostChrome } from './host-chrome.js';
7
- /** The line between two shell regions, as under the top bar and a shown banner. */
7
+ /** The line between two shell regions, as under the top bar. */
8
8
  export const SHELL_REGION_BORDER = '1px solid var(--canopy-input-border)';
9
9
  /**
10
10
  * Every resizable divider in the shell, the panel's and the panes', shows this many pixels wide.
@@ -16,10 +16,6 @@ export const DIVIDER_WIDTH_PX = 4;
16
16
  export const DIVIDER_TARGET_PX = { fine: 12, coarse: 16 };
17
17
  /** A divider's colour at rest; held, hovered, dragged or focused it takes `--canopy-focus-ring`. */
18
18
  export const DIVIDER_COLOR = 'var(--canopy-input-border)';
19
- // A banner component may render nothing, so the region's line follows its content, not the prop.
20
- const SHELL_STYLES = `
21
- [data-canopy-region='banner']:not(:empty) { border-block-end: ${SHELL_REGION_BORDER}; }
22
- `;
23
19
  const gridStyle = {
24
20
  position: 'fixed',
25
21
  inset: 0,
@@ -27,21 +23,21 @@ const gridStyle = {
27
23
  // under Mantine's drawers and modals, leaving the layer between for MDXEditor's popups.
28
24
  zIndex: getDefaultZIndex('modal') - 2,
29
25
  display: 'grid',
30
- gridTemplateAreas: '"topbar topbar topbar" "banner banner banner" "rail panel main"',
31
- gridTemplateRows: 'auto auto minmax(0, 1fr)',
26
+ gridTemplateAreas: '"topbar topbar topbar" "rail panel main"',
27
+ gridTemplateRows: 'auto minmax(0, 1fr)',
32
28
  gridTemplateColumns: 'auto auto minmax(0, 1fr)',
33
29
  background: 'var(--canopy-surface-subtle)',
34
30
  color: 'var(--mantine-color-text)',
35
31
  };
36
32
  const region = (area) => ({ gridArea: area, minWidth: 0, minHeight: 0 });
37
33
  /**
38
- * The editor's window: a full-viewport grid of regions (top bar, banner, rail, panel, main,
39
- * overlays), portalled into `<body>` above the host page, whose chrome it hides and makes inert
40
- * and whose scrolling it stops. Layout only: features plug into the regions. The rail sits at the
41
- * inline start, opposite the toasts (`theme.tsx`), and comes first after the banner in focus order.
34
+ * The editor's window: a full-viewport grid of regions (top bar, rail, panel, main, overlays),
35
+ * portalled into `<body>` above the host page, whose chrome it hides and makes inert and whose
36
+ * scrolling it stops. Layout only: features plug into the regions. The rail sits at the inline
37
+ * start, opposite the toasts (`theme.tsx`), and comes first after the top bar in focus order.
42
38
  */
43
- export function EditorShell({ topBar, banner, rail, panel, main, overlays }) {
39
+ export function EditorShell({ topBar, rail, panel, main, overlays }) {
44
40
  const anchorRef = useRef(null);
45
41
  useHideHostChrome(anchorRef);
46
- return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true, "data-canopy-shell-anchor": true }), _jsx(Portal, { children: _jsxs(RemoveScroll, { noIsolation: true, removeScrollBar: true, style: gridStyle, "data-canopy-shell": "", children: [_jsx("style", { children: SHELL_STYLES }), topBar && (_jsx("header", { style: region('topbar'), "data-canopy-region": "topbar", children: topBar })), _jsx("div", { style: region('banner'), "data-canopy-region": "banner", children: banner }), rail && (_jsx("aside", { style: region('rail'), "data-canopy-region": "rail", "aria-label": "Editor tools", children: rail })), _jsx("div", { style: region('panel'), "data-canopy-region": "panel", children: panel }), _jsx("main", { style: region('main'), "data-canopy-region": "main", children: main }), _jsx("div", { style: { position: 'absolute' }, "data-canopy-region": "overlays", children: overlays })] }) })] }));
42
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true, "data-canopy-shell-anchor": true }), _jsx(Portal, { children: _jsxs(RemoveScroll, { noIsolation: true, removeScrollBar: true, style: gridStyle, "data-canopy-shell": "", children: [topBar && (_jsx("header", { style: region('topbar'), "data-canopy-region": "topbar", children: topBar })), rail && (_jsx("aside", { style: region('rail'), "data-canopy-region": "rail", "aria-label": "Editor tools", children: rail })), _jsx("div", { style: region('panel'), "data-canopy-region": "panel", children: panel }), _jsx("main", { style: region('main'), "data-canopy-region": "main", children: main }), _jsx("div", { style: { position: 'absolute' }, "data-canopy-region": "overlays", children: overlays })] }) })] }));
47
43
  }
@@ -5,6 +5,7 @@ export declare const SPLIT_PERCENT_MIN = 15;
5
5
  export declare const SPLIT_PERCENT_MAX = 85;
6
6
  export declare const DEFAULT_SIDE_SPLIT_PERCENT = 52;
7
7
  export declare const DEFAULT_STACKED_SPLIT_PERCENT = 58;
8
+ export declare const FORM_PANE_ID = "canopy-form-pane";
8
9
  export interface PaneLayoutProps {
9
10
  arrangement: PaneArrangement;
10
11
  /** The preview's share (percent) beside the form; follows this prop when it changes. */
@@ -14,11 +14,14 @@ const FORM = 'canopy-form';
14
14
  // the form also keeps a usable minimum in a narrow window.
15
15
  const PREVIEW_LIMITS = { minSize: `${SPLIT_PERCENT_MIN}%`, maxSize: `${SPLIT_PERCENT_MAX}%` };
16
16
  const FORM_MIN_PX = 160;
17
+ export const FORM_PANE_ID = 'canopy-form-pane';
17
18
  const ASIDE_TOP = '--canopy-form-aside-top';
18
19
  const ASIDE_WIDTH = '--canopy-form-aside-width';
19
20
  // The pointer target is the group's `resizeTargetMinimumSize`, wider than the bar it draws. Lifted
20
- // over the panes' positioned contents, which would otherwise paint over its focus outline.
21
- const SEPARATOR_STYLES = `
21
+ // over the panes' positioned contents, which would otherwise paint over its focus outline. The form
22
+ // pane's ring is inset, since the window's edges clip it outside, and repeated on its non-scrolling
23
+ // panel above the pane, whose sticky header would cover it.
24
+ const PANE_STYLES = `
22
25
  .canopy-pane-separator {
23
26
  position: relative;
24
27
  z-index: 1;
@@ -32,6 +35,19 @@ const SEPARATOR_STYLES = `
32
35
  .canopy-pane-separator:focus-visible {
33
36
  outline: 2px solid var(--canopy-focus-ring);
34
37
  }
38
+ #${FORM_PANE_ID}:focus-visible {
39
+ outline: 2px solid var(--canopy-focus-ring);
40
+ outline-offset: -2px;
41
+ }
42
+ .canopy-form-panel:has(> #${FORM_PANE_ID}:focus-visible)::after {
43
+ outline: 2px solid var(--canopy-focus-ring);
44
+ outline-offset: -2px;
45
+ content: '';
46
+ position: absolute;
47
+ inset: 0;
48
+ z-index: 1;
49
+ pointer-events: none;
50
+ }
35
51
  `;
36
52
  const splitLayout = (percent) => ({ [PREVIEW]: percent, [FORM]: 100 - percent });
37
53
  /** The resizable preview/form split that fills the shell's main region. */
@@ -126,7 +142,7 @@ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_
126
142
  const maximizedStyle = aside
127
143
  ? { insetBlock: 0, insetInlineEnd: 0, insetInlineStart: `var(${ASIDE_WIDTH}, 0px)` }
128
144
  : { inset: 0 };
129
- return (_jsxs("div", { ref: layoutRef, style: { position: 'relative', height: '100%', width: '100%' }, children: [_jsx("style", { children: SEPARATOR_STYLES }), aside && (_jsxs("div", { "data-testid": "form-aside", "data-lifted": previewMaximized || undefined, style: {
145
+ return (_jsxs("div", { ref: layoutRef, style: { position: 'relative', height: '100%', width: '100%' }, children: [_jsx("style", { children: PANE_STYLES }), aside && (_jsxs("div", { "data-testid": "form-aside", "data-lifted": previewMaximized || undefined, style: {
130
146
  position: 'absolute',
131
147
  insetInlineStart: 0,
132
148
  bottom: 0,
@@ -154,12 +170,14 @@ export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_
154
170
  style: previewMaximized
155
171
  ? // Over the separator, which sits over the panes.
156
172
  { overflow: 'auto', position: 'absolute', zIndex: 2, ...maximizedStyle }
157
- : { overflow: 'auto', width: '100%', height: '100%' }, children: preview }) }), _jsx(Separator, { elementRef: separatorRef, "aria-label": "Resize preview and form", className: "canopy-pane-separator" }), _jsx(Panel, { id: FORM, minSize: FORM_MIN_PX, children: _jsxs(Box, { ref: formPanelRef, pos: "relative", h: "100%",
173
+ : { overflow: 'auto', width: '100%', height: '100%' }, children: preview }) }), _jsx(Separator, { elementRef: separatorRef, "aria-label": "Resize preview and form", className: "canopy-pane-separator" }), _jsx(Panel, { id: FORM, minSize: FORM_MIN_PX, children: _jsxs(Box, { ref: formPanelRef, className: "canopy-form-panel", pos: "relative", h: "100%",
158
174
  // `formOverlay` stacks within this pane, so a maximized preview always covers it.
159
175
  style: {
160
176
  isolation: 'isolate',
161
177
  marginInlineStart: aside ? `var(${ASIDE_WIDTH}, 0px)` : undefined,
162
- }, children: [_jsx(Box, { ref: formRef, "data-testid": "form-pane", h: "100%", w: "100%", style: {
178
+ }, children: [_jsx(Box, { ref: formRef, id: FORM_PANE_ID,
179
+ // Takes focus when an opened entry's form has no field to focus.
180
+ tabIndex: -1, role: "region", "aria-label": "Form", "data-testid": "form-pane", h: "100%", w: "100%", style: {
163
181
  // FormHeader reads this to span the padding.
164
182
  '--canopy-form-pane-padding': '20px',
165
183
  padding: 'var(--canopy-form-pane-padding)',
@@ -17,8 +17,15 @@ export interface SidePanelHostProps {
17
17
  * focus in. Opening alone never does, as a panel also opens when stored state loads.
18
18
  */
19
19
  focusRequest?: number;
20
+ /**
21
+ * Escape from inside a docked panel closes it, as an overlay's always does. It also marks the
22
+ * panel, so a maximized preview's Escape leaves the key to it.
23
+ */
24
+ closeOnEscape?: boolean;
20
25
  /** Where focus goes when the panel closes with focus inside it. */
21
26
  returnFocusTo?: () => HTMLElement | null;
27
+ /** Focus landed on `target`, outside the panel and what it portals; dropping to nothing isn't. */
28
+ onFocusLeave?: (target: Element) => void;
22
29
  /** Controls beside the title, before the close button. */
23
30
  actions?: ReactNode;
24
31
  children: ReactNode;
@@ -29,9 +36,9 @@ export interface SidePanelHostProps {
29
36
  * A titled side panel, resizable from its inline-end edge. Docked, it pushes its neighbours aside
30
37
  * and opens and closes at once: a sliding width would reflow the preview's page on every frame.
31
38
  * As an overlay it covers the form pane only, so the preview stays in view, slides in and out as
32
- * the drawers do unless the user prefers reduced motion, and Escape closes it. A `focusRequest`
33
- * moves focus to its `data-autofocus` element, else to the panel; closing with focus inside
34
- * returns focus to `returnFocusTo`.
39
+ * the drawers do unless the user prefers reduced motion, and Escape closes it, as it does a docked
40
+ * panel given `closeOnEscape`. A `focusRequest` moves focus to its `data-autofocus` element, else
41
+ * to the panel; closing with focus inside returns focus to `returnFocusTo`.
35
42
  */
36
- export declare function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest, returnFocusTo, actions, children, id, 'data-testid': testId, }: SidePanelHostProps): import("react/jsx-runtime").JSX.Element;
43
+ export declare function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest, closeOnEscape, returnFocusTo, onFocusLeave, actions, children, id, 'data-testid': testId, }: SidePanelHostProps): import("react/jsx-runtime").JSX.Element;
37
44
  export {};
@@ -49,17 +49,101 @@ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
49
49
  * A titled side panel, resizable from its inline-end edge. Docked, it pushes its neighbours aside
50
50
  * and opens and closes at once: a sliding width would reflow the preview's page on every frame.
51
51
  * As an overlay it covers the form pane only, so the preview stays in view, slides in and out as
52
- * the drawers do unless the user prefers reduced motion, and Escape closes it. A `focusRequest`
53
- * moves focus to its `data-autofocus` element, else to the panel; closing with focus inside
54
- * returns focus to `returnFocusTo`.
52
+ * the drawers do unless the user prefers reduced motion, and Escape closes it, as it does a docked
53
+ * panel given `closeOnEscape`. A `focusRequest` moves focus to its `data-autofocus` element, else
54
+ * to the panel; closing with focus inside returns focus to `returnFocusTo`.
55
55
  */
56
- export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest = 0, returnFocusTo, actions, children, id, 'data-testid': testId, }) {
56
+ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth, onWidthChange, onClose, focusRequest = 0, closeOnEscape = false, returnFocusTo, onFocusLeave, actions, children, id, 'data-testid': testId, }) {
57
57
  const fallbackId = useId();
58
58
  const panelId = id ?? fallbackId;
59
59
  const titleId = `${panelId}-title`;
60
60
  // State, not a ref: an overlay's section mounts a frame after `opened`, as its slide starts.
61
61
  const [panel, setPanel] = useState(null);
62
62
  const focusInside = useRef(false);
63
+ // Drops a leave the watcher below has not reported yet; the panel's own `onFocus` runs it.
64
+ const cancelFocusLeave = useRef(undefined);
65
+ const onFocusLeaveRef = useRef(onFocusLeave);
66
+ useEffect(() => {
67
+ onFocusLeaveRef.current = onFocusLeave;
68
+ });
69
+ const watchFocus = opened && !!onFocusLeave;
70
+ // Watched on the document, not the panel's blur: focus can drop to nothing first (a click on
71
+ // text, or on a button in Safari) and then land elsewhere with no blur left to report it.
72
+ useEffect(() => {
73
+ if (!watchFocus || !panel)
74
+ return;
75
+ let timer;
76
+ let pressing = false;
77
+ let pending = null;
78
+ let frame = null;
79
+ const report = (target) => {
80
+ clearTimeout(timer);
81
+ timer = setTimeout(() => onFocusLeaveRef.current?.(target), 0);
82
+ };
83
+ // A caller that closes the panel reflows what the press landed on, losing its click, so a
84
+ // press waits for its release. The 0ms timer then runs after the click.
85
+ const leave = (target) => {
86
+ if (pressing)
87
+ pending = target;
88
+ else
89
+ report(target);
90
+ };
91
+ const release = () => {
92
+ pressing = false;
93
+ if (pending)
94
+ report(pending);
95
+ pending = null;
96
+ };
97
+ const onPointerDown = (event) => {
98
+ if (event.button === 0)
99
+ pressing = true;
100
+ };
101
+ // A press inside a frame never reaches this document, so a frame's leave waits for the pointer
102
+ // to come back anywhere but the panel, which it may be reaching for; a release outside the
103
+ // window shows here as a move with no button held.
104
+ const onPointerMove = (event) => {
105
+ if (pressing && event.buttons === 0)
106
+ release();
107
+ if (!frame || (event.target instanceof Node && panel.contains(event.target)))
108
+ return;
109
+ leave(frame);
110
+ frame = null;
111
+ };
112
+ // Captured, so it runs before React's bubbling `onFocus`, which cancels it for a portaled descendant.
113
+ const onFocusIn = (event) => {
114
+ if (event.target instanceof Element && !panel.contains(event.target))
115
+ leave(event.target);
116
+ };
117
+ // Focus moving into a frame, such as the preview, reaches this document only as a blur.
118
+ const onWindowBlur = () => setTimeout(() => {
119
+ const active = document.activeElement;
120
+ if (active instanceof HTMLIFrameElement && !panel.contains(active))
121
+ frame = active;
122
+ }, 0);
123
+ cancelFocusLeave.current = () => {
124
+ clearTimeout(timer);
125
+ pending = null;
126
+ frame = null;
127
+ };
128
+ const listeners = [
129
+ [document, 'pointerdown', onPointerDown],
130
+ [document, 'pointerup', release],
131
+ [document, 'pointercancel', release],
132
+ [document, 'pointermove', onPointerMove],
133
+ [document, 'focusin', onFocusIn],
134
+ [window, 'blur', onWindowBlur],
135
+ ];
136
+ for (const [target, type, listener] of listeners) {
137
+ target.addEventListener(type, listener, true);
138
+ }
139
+ return () => {
140
+ for (const [target, type, listener] of listeners) {
141
+ target.removeEventListener(type, listener, true);
142
+ }
143
+ clearTimeout(timer);
144
+ cancelFocusLeave.current = undefined;
145
+ };
146
+ }, [watchFocus, panel]);
63
147
  const closingWithFocus = useRef(false);
64
148
  const wasOpened = useRef(opened);
65
149
  const servedFocusRequest = useRef(focusRequest);
@@ -126,11 +210,14 @@ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth,
126
210
  wasOpened.current = opened;
127
211
  }, [opened, returnFocusTo]);
128
212
  // On the window, not the panel: a dialog portalled from the panel would bubble its Escape here
129
- // through React, so only a key from inside the panel's own DOM closes it.
213
+ // through React, so only a key from inside the panel's own DOM closes it. An Escape that ends
214
+ // an IME composition only cancels the composition; Safari reports it as keyCode 229.
130
215
  useWindowEvent('keydown', (event) => {
131
216
  if (opened &&
132
- mode === 'overlay' &&
217
+ (mode === 'overlay' || closeOnEscape) &&
133
218
  event.key === 'Escape' &&
219
+ !event.isComposing &&
220
+ event.keyCode !== 229 &&
134
221
  !event.defaultPrevented &&
135
222
  event.target instanceof Node &&
136
223
  panel?.contains(event.target)) {
@@ -209,8 +296,9 @@ export function SidePanelHost({ title, mode, opened, width, minWidth, maxWidth,
209
296
  background: 'var(--mantine-color-body)',
210
297
  paddingInlineEnd: DIVIDER_WIDTH_PX,
211
298
  };
212
- return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true }), _jsx(Transition, { mounted: opened, transition: rtl ? 'slide-left' : 'slide-right', duration: overlay && !reduceMotion ? SLIDE_MS : 0, children: (transitionStyles) => (_jsxs("section", { ref: setPanel, id: panelId, "aria-labelledby": titleId, tabIndex: -1, "data-testid": testId, "data-panel-mode": mode, [FOCUS_RETURN_ATTR]: opened ? undefined : '', style: { ...style, ...transitionStyles }, onFocus: () => {
299
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: anchorRef, hidden: true }), _jsx(Transition, { mounted: opened, transition: rtl ? 'slide-left' : 'slide-right', duration: overlay && !reduceMotion ? SLIDE_MS : 0, children: (transitionStyles) => (_jsxs("section", { ref: setPanel, id: panelId, "aria-labelledby": titleId, tabIndex: -1, "data-testid": testId, "data-panel-mode": mode, "data-mantine-stop-propagation": closeOnEscape ? 'true' : undefined, [FOCUS_RETURN_ATTR]: opened ? undefined : '', style: { ...style, ...transitionStyles }, onFocus: () => {
213
300
  focusInside.current = true;
301
+ cancelFocusLeave.current?.();
214
302
  }, onBlur: (event) => {
215
303
  if (!event.currentTarget.contains(event.relatedTarget))
216
304
  focusInside.current = false;
@@ -1,12 +1,12 @@
1
1
  import { type ReactNode } from 'react';
2
2
  export interface TopBarProps {
3
- /** Where you are: the breadcrumb and anything that describes the open entry. */
3
+ /** The branch, at the bar's inline start. */
4
4
  leading: ReactNode;
5
- /** What you can do: the branch and the actions on it. */
5
+ /** The actions on the branch, at its inline end. */
6
6
  trailing: ReactNode;
7
7
  }
8
8
  /**
9
- * The shell's top bar: one 48px row from `md` up. Narrower, `leading` takes a row of its own
10
- * above `trailing`, which wraps rather than clip a control. Layout only.
9
+ * The shell's top bar, the branch bar: one 48px row while both sides fit. Narrower, `trailing`
10
+ * controls that don't fit beside the branch wrap to a second row at the inline end. Layout only.
11
11
  */
12
12
  export declare function TopBar({ leading, trailing }: TopBarProps): import("react/jsx-runtime").JSX.Element;
@@ -4,12 +4,15 @@ import { Box, Group } from '@mantine/core';
4
4
  import { SHELL_REGION_BORDER } from './EditorShell.js';
5
5
  const TOP_BAR_HEIGHT = 48;
6
6
  /**
7
- * The shell's top bar: one 48px row from `md` up. Narrower, `leading` takes a row of its own
8
- * above `trailing`, which wraps rather than clip a control. Layout only.
7
+ * The shell's top bar, the branch bar: one 48px row while both sides fit. Narrower, `trailing`
8
+ * controls that don't fit beside the branch wrap to a second row at the inline end. Layout only.
9
9
  */
10
10
  export function TopBar({ leading, trailing }) {
11
11
  return (_jsxs(Group, { gap: "xs", wrap: "wrap", align: "center", px: "sm", py: 6, mih: TOP_BAR_HEIGHT, style: {
12
12
  borderBottom: SHELL_REGION_BORDER,
13
13
  background: 'var(--mantine-color-body)',
14
- }, children: [_jsx(Box, { flex: { base: '1 0 100%', md: '1 1 0%' }, miw: 0, children: leading }), _jsx(Group, { gap: 6, wrap: "wrap", align: "center", justify: "flex-end", flex: { base: '1 0 100%', md: '0 1 auto' }, miw: 0, children: trailing })] }));
14
+ }, children: [_jsx(Box, { flex: "0 1 auto", miw: 0, children: leading }), _jsx(Group, { gap: 6, wrap: "wrap", align: "center", justify: "flex-end",
15
+ // It shares the pill's row while its widest control fits there, and then only the controls
16
+ // that don't fit wrap, still at the end; narrower, it takes a row of its own.
17
+ flex: "1 1 0%", miw: "min-content", children: trailing })] }));
15
18
  }
@@ -6,9 +6,11 @@ import { type MouseEvent, type RefObject } from 'react';
6
6
  */
7
7
  export declare const FOCUS_RETURN_ATTR = "data-focus-return";
8
8
  /**
9
- * Focuses `target` unless the user has moved focus on: nothing is focused, or focus is inside an
10
- * overlay `FOCUS_RETURN_ATTR` marks, as a closing one is until after its exit callback.
9
+ * The user has not moved focus on: nothing is focused, or focus is inside an overlay
10
+ * `FOCUS_RETURN_ATTR` marks, as a closing one is until after its exit callback.
11
11
  */
12
+ export declare function focusIsIdle(): boolean;
13
+ /** Focuses `target` while `focusIsIdle`. */
12
14
  export declare function focusIfIdle(target: HTMLElement | null): void;
13
15
  /**
14
16
  * Run by `useMenuItemFocusReturn` when its menu has closed after an item acted: focuses the menu's
@@ -54,3 +56,9 @@ export declare function useFocusAfterRemove<L extends HTMLElement = HTMLDivEleme
54
56
  listRef: RefObject<L>;
55
57
  focusAfterRemove: (index: number, fallback: RefObject<HTMLElement | null>) => void;
56
58
  };
59
+ /**
60
+ * Refs for `slots` controls that come and go by turns. Focus dropped by one leaving the page, or by
61
+ * its menu, moves to its slot's newcomer, else the first slot filled (`target`, which a dialog may
62
+ * return focus to too); a StrictMode re-attach stays.
63
+ */
64
+ export declare function useFocusHandOff(slots: number): [Array<(el: HTMLButtonElement | null) => void>, (slot: number) => HTMLButtonElement | null];
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
2
2
  /**
3
3
  * Mantine's own `returnFocus` records the focused element at open, and a menu item has unmounted
4
4
  * with its menu by close, so an overlay opened from one returns focus here instead.
@@ -6,14 +6,17 @@ import { useEffect, useRef, useState } from 'react';
6
6
  */
7
7
  export const FOCUS_RETURN_ATTR = 'data-focus-return';
8
8
  /**
9
- * Focuses `target` unless the user has moved focus on: nothing is focused, or focus is inside an
10
- * overlay `FOCUS_RETURN_ATTR` marks, as a closing one is until after its exit callback.
9
+ * The user has not moved focus on: nothing is focused, or focus is inside an overlay
10
+ * `FOCUS_RETURN_ATTR` marks, as a closing one is until after its exit callback.
11
11
  */
12
- export function focusIfIdle(target) {
12
+ export function focusIsIdle() {
13
13
  const active = document.activeElement;
14
- if (!active || active === document.body || active.closest(`[${FOCUS_RETURN_ATTR}]`)) {
14
+ return !active || active === document.body || !!active.closest(`[${FOCUS_RETURN_ATTR}]`);
15
+ }
16
+ /** Focuses `target` while `focusIsIdle`. */
17
+ export function focusIfIdle(target) {
18
+ if (focusIsIdle())
15
19
  target?.focus({ preventScroll: true });
16
- }
17
20
  }
18
21
  /**
19
22
  * Run by `useMenuItemFocusReturn` when its menu has closed after an item acted: focuses the menu's
@@ -104,3 +107,34 @@ export function useFocusAfterRemove() {
104
107
  focusAfterRemove: (index, fallback) => setPending({ index, fallback }),
105
108
  };
106
109
  }
110
+ /** `el` has focus, or the open menu it controls (`aria-controls`) does. */
111
+ function holdsFocus(el) {
112
+ const active = document.activeElement;
113
+ const menuId = el.getAttribute('aria-controls');
114
+ return active === el || (!!menuId && !!document.getElementById(menuId)?.contains(active));
115
+ }
116
+ /**
117
+ * Refs for `slots` controls that come and go by turns. Focus dropped by one leaving the page, or by
118
+ * its menu, moves to its slot's newcomer, else the first slot filled (`target`, which a dialog may
119
+ * return focus to too); a StrictMode re-attach stays.
120
+ */
121
+ export function useFocusHandOff(slots) {
122
+ const controls = useRef([]);
123
+ const outgoing = useRef(undefined);
124
+ const target = useCallback((slot) => controls.current[slot] ?? controls.current.find(Boolean) ?? null, []);
125
+ const refs = useMemo(() => Array.from({ length: slots }, (_, slot) => (el) => {
126
+ const current = controls.current[slot];
127
+ if (!el && current && holdsFocus(current))
128
+ outgoing.current = { el: current, slot };
129
+ controls.current[slot] = el;
130
+ }), [slots]);
131
+ useLayoutEffect(() => {
132
+ const left = outgoing.current;
133
+ outgoing.current = undefined;
134
+ const active = document.activeElement;
135
+ if (!left || left.el.isConnected || (active && active !== document.body))
136
+ return;
137
+ target(left.slot)?.focus({ preventScroll: true });
138
+ });
139
+ return [refs, target];
140
+ }
@@ -7,7 +7,6 @@ import type { OperatingMode, ClientSafeStrategy } from './types.js';
7
7
  export declare class ProdClientSafeStrategy implements ClientSafeStrategy {
8
8
  readonly mode: OperatingMode;
9
9
  supportsBranching(): boolean;
10
- supportsStatusBadge(): boolean;
11
10
  supportsComments(): boolean;
12
11
  supportsPullRequests(): boolean;
13
12
  getPermissionsFileName(): string;
@@ -18,7 +17,6 @@ export declare class ProdClientSafeStrategy implements ClientSafeStrategy {
18
17
  export declare class DevClientSafeStrategy implements ClientSafeStrategy {
19
18
  readonly mode: OperatingMode;
20
19
  supportsBranching(): boolean;
21
- supportsStatusBadge(): boolean;
22
20
  supportsComments(): boolean;
23
21
  supportsPullRequests(): boolean;
24
22
  getPermissionsFileName(): string;
@@ -31,8 +29,5 @@ export declare class DevClientSafeStrategy implements ClientSafeStrategy {
31
29
  * call inline — `clientOperatingStrategy(mode).supportsBranching()`.
32
30
  */
33
31
  export declare function clientOperatingStrategy(mode: OperatingMode): ClientSafeStrategy;
34
- /**
35
- * Mainly for testing.
36
- * @internal Exported for tests.
37
- */
32
+ /** @internal Exported for tests. */
38
33
  export declare function clearClientStrategyCache(): void;
@@ -10,9 +10,6 @@ export class ProdClientSafeStrategy {
10
10
  supportsBranching() {
11
11
  return true;
12
12
  }
13
- supportsStatusBadge() {
14
- return true;
15
- }
16
13
  supportsComments() {
17
14
  return true;
18
15
  }
@@ -39,9 +36,6 @@ export class DevClientSafeStrategy {
39
36
  supportsBranching() {
40
37
  return true;
41
38
  }
42
- supportsStatusBadge() {
43
- return true;
44
- }
45
39
  supportsComments() {
46
40
  return true;
47
41
  }
@@ -87,10 +81,7 @@ export function clientOperatingStrategy(mode) {
87
81
  clientStrategyCache.set(mode, strategy);
88
82
  return strategy;
89
83
  }
90
- /**
91
- * Mainly for testing.
92
- * @internal Exported for tests.
93
- */
84
+ /** @internal Exported for tests. */
94
85
  export function clearClientStrategyCache() {
95
86
  clientStrategyCache.clear();
96
87
  }
@@ -31,16 +31,13 @@ export interface ClientSafeStrategy {
31
31
  readonly mode: OperatingMode;
32
32
  /** Whether this mode supports multiple branch workspaces */
33
33
  supportsBranching(): boolean;
34
- supportsStatusBadge(): boolean;
35
34
  supportsComments(): boolean;
36
35
  supportsPullRequests(): boolean;
37
36
  /** Get the permissions file name (e.g., 'permissions.json' or 'permissions.local.json') */
38
37
  getPermissionsFileName(): string;
39
38
  /** Get the groups file name (e.g., 'groups.json' or 'groups.local.json') */
40
39
  getGroupsFileName(): string;
41
- /** Whether git commits should be made in this mode */
42
40
  shouldCommit(): boolean;
43
- /** Whether git pushes should be made in this mode */
44
41
  shouldPush(): boolean;
45
42
  }
46
43
  /** The full strategy, including Node.js APIs. Server-side imports only. */
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const CANOPYCMS_VERSION = "0.2.0";
1
+ export declare const CANOPYCMS_VERSION = "0.3.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Generated by scripts/bump-version.mjs, its only writer. The package builds with plain
2
2
  // tsc (rootDir: src), so src/ cannot import ../package.json; version.test.ts keeps the two equal.
3
- export const CANOPYCMS_VERSION = '0.2.0';
3
+ export const CANOPYCMS_VERSION = '0.3.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
3
3
  "name": "canopycms",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -1,23 +0,0 @@
1
- import type { BranchStatus } from '../../types.js';
2
- export interface BranchLockInput {
3
- branchStatus: BranchStatus | undefined;
4
- /** The protected base branch, read-only in prod. */
5
- branchReadOnly?: boolean;
6
- branchWriteBlocked?: boolean;
7
- }
8
- /**
9
- * Whether the banner renders; the top bar reads the same answer, so the two cannot drift.
10
- * @internal Exported for tests.
11
- */
12
- export declare function branchLockBannerShown(lock: BranchLockInput): boolean;
13
- /**
14
- * Why the branch takes no writes, with the way forward; undefined when it does take them. The
15
- * top bar's Save and the navigator's row locks say this; the banner describes the same lock.
16
- */
17
- export declare function describeBranchLock(lock: BranchLockInput): string | undefined;
18
- export interface BranchLockBannerProps extends BranchLockInput {
19
- branchName: string;
20
- onBranchManagerOpen: () => void;
21
- }
22
- /** The banner region's explanation of a read-only branch; not dismissible while it applies. */
23
- export declare function BranchLockBanner({ branchName, onBranchManagerOpen, ...lock }: BranchLockBannerProps): import("react/jsx-runtime").JSX.Element | null;