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.
- package/CHANGELOG.md +199 -41
- package/dist/api/branch.d.ts +2 -2
- package/dist/cli/cli.js +0 -18
- package/dist/cli/generate-ai-content.js +0 -6
- package/dist/cli/init.js +0 -6
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +3 -3
- package/dist/editor/Editor.js +253 -128
- package/dist/editor/EntryNavigator.d.ts +44 -9
- package/dist/editor/EntryNavigator.js +275 -103
- package/dist/editor/branch-lock.d.ts +42 -0
- package/dist/editor/branch-lock.js +79 -0
- package/dist/editor/branch-status.d.ts +0 -6
- package/dist/editor/branch-status.js +0 -10
- package/dist/editor/components/BranchNotAvailableNotice.d.ts +11 -0
- package/dist/editor/components/BranchNotAvailableNotice.js +10 -0
- package/dist/editor/components/BranchPill.d.ts +3 -18
- package/dist/editor/components/BranchPill.js +7 -10
- package/dist/editor/components/EditorHeader.d.ts +24 -52
- package/dist/editor/components/EditorHeader.js +151 -71
- package/dist/editor/components/EditorRail.d.ts +8 -8
- package/dist/editor/components/EditorRail.js +7 -5
- package/dist/editor/components/EntryBreadcrumb.d.ts +5 -1
- package/dist/editor/components/EntryBreadcrumb.js +3 -3
- package/dist/editor/components/EntryHeader.d.ts +60 -0
- package/dist/editor/components/EntryHeader.js +45 -0
- package/dist/editor/components/EntrySaveButton.d.ts +24 -0
- package/dist/editor/components/EntrySaveButton.js +101 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +1 -1
- package/dist/editor/components/NoEditPermissionNotice.js +1 -1
- package/dist/editor/components/RetryToastMessage.d.ts +3 -1
- package/dist/editor/components/RetryToastMessage.js +8 -4
- package/dist/editor/components/SaveAllButton.d.ts +25 -0
- package/dist/editor/components/SaveAllButton.js +81 -0
- package/dist/editor/components/UnsavedMark.d.ts +1 -1
- package/dist/editor/components/UnsavedMark.js +1 -1
- package/dist/editor/components/index.d.ts +1 -1
- package/dist/editor/components/index.js +1 -1
- package/dist/editor/copy.d.ts +0 -1
- package/dist/editor/copy.js +0 -1
- package/dist/editor/editor-utils.d.ts +2 -2
- package/dist/editor/editor-utils.js +2 -2
- package/dist/editor/entry-search.d.ts +10 -16
- package/dist/editor/entry-search.js +4 -24
- package/dist/editor/hooks/useBranchManager.d.ts +7 -3
- package/dist/editor/hooks/useBranchManager.js +13 -9
- package/dist/editor/hooks/useEntryManager.d.ts +3 -0
- package/dist/editor/hooks/useEntryManager.js +10 -3
- package/dist/editor/shell/ActivityRail.d.ts +4 -0
- package/dist/editor/shell/ActivityRail.js +13 -4
- package/dist/editor/shell/EditorShell.d.ts +6 -7
- package/dist/editor/shell/EditorShell.js +9 -13
- package/dist/editor/shell/PaneLayout.d.ts +1 -0
- package/dist/editor/shell/PaneLayout.js +23 -5
- package/dist/editor/shell/SidePanelHost.d.ts +11 -4
- package/dist/editor/shell/SidePanelHost.js +95 -7
- package/dist/editor/shell/TopBar.d.ts +4 -4
- package/dist/editor/shell/TopBar.js +6 -3
- package/dist/editor/utils/focus-return.d.ts +10 -2
- package/dist/editor/utils/focus-return.js +40 -6
- package/dist/operating-mode/client-safe-strategy.d.ts +1 -6
- package/dist/operating-mode/client-safe-strategy.js +1 -10
- package/dist/operating-mode/types.d.ts +0 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/editor/components/BranchLockBanner.d.ts +0 -23
- package/dist/editor/components/BranchLockBanner.js +0 -66
- package/dist/editor/components/QuickOpenPalette.d.ts +0 -28
- package/dist/editor/components/QuickOpenPalette.js +0 -71
- package/dist/editor/components/SaveSplitButton.d.ts +0 -31
- 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:
|
|
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
|
|
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,
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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,
|
|
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
|
|
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" "
|
|
31
|
-
gridTemplateRows: 'auto
|
|
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,
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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,
|
|
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: [
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
|
33
|
-
* moves focus to its `data-autofocus` element, else
|
|
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
|
|
53
|
-
* moves focus to its `data-autofocus` element, else
|
|
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
|
-
/**
|
|
3
|
+
/** The branch, at the bar's inline start. */
|
|
4
4
|
leading: ReactNode;
|
|
5
|
-
/**
|
|
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
|
|
10
|
-
*
|
|
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
|
|
8
|
-
*
|
|
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:
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
|
12
|
+
export function focusIsIdle() {
|
|
13
13
|
const active = document.activeElement;
|
|
14
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|