canopycms 0.2.0-int.121 → 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 +241 -78
- 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/dev-content-watcher.js +8 -1
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +3 -3
- package/dist/editor/Editor.js +276 -130
- package/dist/editor/EntryNavigator.d.ts +44 -9
- package/dist/editor/EntryNavigator.js +278 -104
- 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/BranchRowLock.d.ts +3 -1
- package/dist/editor/components/BranchRowLock.js +4 -2
- 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/editor-history.d.ts +7 -0
- package/dist/editor/hooks/editor-history.js +80 -0
- package/dist/editor/hooks/useBranchActions.js +3 -1
- package/dist/editor/hooks/useBranchManager.d.ts +7 -3
- package/dist/editor/hooks/useBranchManager.js +18 -11
- package/dist/editor/hooks/useEntryManager.d.ts +12 -2
- package/dist/editor/hooks/useEntryManager.js +141 -20
- 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/theme.js +22 -1
- 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
|
@@ -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
|
}
|
package/dist/editor/theme.js
CHANGED
|
@@ -320,7 +320,11 @@ const canopyCssVariablesResolver = (theme) => ({
|
|
|
320
320
|
* Switch thumbs, white text on the primary colour (moved to the fill), and dimmed text Mantine
|
|
321
321
|
* draws itself (a blocked menu item, a combobox's empty message, outside days). Outside days lose
|
|
322
322
|
* Mantine's half opacity, under 4.5:1. The crop box and its handles sit on an arbitrary image, where
|
|
323
|
-
* no single colour holds 3:1, so they take a white and black pair.
|
|
323
|
+
* no single colour holds 3:1, so they take a white and black pair. A navigator row's lock is
|
|
324
|
+
* drawn only while its row is hovered, focused (Mantine's Tree focuses the `li` around the row, on click and arrow keys), has focus
|
|
325
|
+
* within, or is selected. It hides by opacity alone, so it keeps its place and stays in the tab order
|
|
326
|
+
* and the accessibility tree; while hidden it takes no pointer events, so a tap on its slot reaches
|
|
327
|
+
* the row.
|
|
324
328
|
*/
|
|
325
329
|
const CANOPY_STYLES = `
|
|
326
330
|
.mantine-Checkbox-input:not(:checked, :disabled, [data-indeterminate], [data-error]),
|
|
@@ -353,6 +357,23 @@ const CANOPY_STYLES = `
|
|
|
353
357
|
outline: 2px solid var(--mantine-color-white);
|
|
354
358
|
box-shadow: 0 0 0 4px var(--mantine-color-black);
|
|
355
359
|
}
|
|
360
|
+
[data-nav-row] [data-row-lock] {
|
|
361
|
+
opacity: 0;
|
|
362
|
+
pointer-events: none;
|
|
363
|
+
transition: opacity 120ms ease;
|
|
364
|
+
}
|
|
365
|
+
[data-nav-row]:hover [data-row-lock],
|
|
366
|
+
[data-nav-row]:focus-within [data-row-lock],
|
|
367
|
+
[role='treeitem']:focus > [data-nav-row] [data-row-lock],
|
|
368
|
+
[data-nav-row][data-nav-selected] [data-row-lock] {
|
|
369
|
+
opacity: 1;
|
|
370
|
+
pointer-events: auto;
|
|
371
|
+
}
|
|
372
|
+
@media (prefers-reduced-motion: reduce) {
|
|
373
|
+
[data-nav-row] [data-row-lock] {
|
|
374
|
+
transition: none;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
356
377
|
[data-combobox-selected] {
|
|
357
378
|
--mantine-primary-color-filled: var(--canopy-primary-fill);
|
|
358
379
|
}
|
|
@@ -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;
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Alert, Box, Button, Group, Text } from '@mantine/core';
|
|
3
|
-
import { IconLock } from '@tabler/icons-react';
|
|
4
|
-
import { EDITOR_ACTIONS } from '../copy.js';
|
|
5
|
-
/**
|
|
6
|
-
* Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
|
|
7
|
-
* nothing offers a write the API would reject; `branchReadOnly` only picks the message, and
|
|
8
|
-
* base-branch read-only takes precedence over a workflow status lock.
|
|
9
|
-
*/
|
|
10
|
-
function branchLockState({ branchStatus, branchReadOnly = false, branchWriteBlocked = false, }) {
|
|
11
|
-
const statusLocked = branchWriteBlocked && !branchReadOnly;
|
|
12
|
-
// `branchStatus` can't describe the lock while branch data loads (undefined) or under version
|
|
13
|
-
// skew (an old server omits `branchWriteBlocked` but reports 'editing'), so both read as
|
|
14
|
-
// "details unavailable" rather than naming a status the lock contradicts.
|
|
15
|
-
const branchDataUnavailable = statusLocked && (branchStatus === undefined || branchStatus === 'editing');
|
|
16
|
-
return { statusLocked, branchDataUnavailable };
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Whether the banner renders; the top bar reads the same answer, so the two cannot drift.
|
|
20
|
-
* @internal Exported for tests.
|
|
21
|
-
*/
|
|
22
|
-
export function branchLockBannerShown(lock) {
|
|
23
|
-
return !!lock.branchReadOnly || branchLockState(lock).statusLocked;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Why the branch takes no writes, with the way forward; undefined when it does take them. The
|
|
27
|
-
* top bar's Save and the navigator's row locks say this; the banner describes the same lock.
|
|
28
|
-
*/
|
|
29
|
-
export function describeBranchLock(lock) {
|
|
30
|
-
if (!branchLockBannerShown(lock))
|
|
31
|
-
return undefined;
|
|
32
|
-
const { statusLocked, branchDataUnavailable } = branchLockState(lock);
|
|
33
|
-
if (lock.branchReadOnly)
|
|
34
|
-
return 'The base branch is read-only. Create a branch to make changes.';
|
|
35
|
-
if (branchDataUnavailable)
|
|
36
|
-
return 'Branch details could not be loaded. Use "Switch branch…" to retry.';
|
|
37
|
-
if (!statusLocked)
|
|
38
|
-
return undefined;
|
|
39
|
-
if (lock.branchStatus === 'submitted')
|
|
40
|
-
return 'This branch is in review. Withdraw it to make changes.';
|
|
41
|
-
if (lock.branchStatus === 'approved')
|
|
42
|
-
return 'This branch is approved. Withdraw it to make changes.';
|
|
43
|
-
return `This branch is ${lock.branchStatus}. Its content is read-only.`;
|
|
44
|
-
}
|
|
45
|
-
/** The banner region's explanation of a read-only branch; not dismissible while it applies. */
|
|
46
|
-
export function BranchLockBanner({ branchName, onBranchManagerOpen, ...lock }) {
|
|
47
|
-
const { branchDataUnavailable } = branchLockState(lock);
|
|
48
|
-
if (!branchLockBannerShown(lock))
|
|
49
|
-
return null;
|
|
50
|
-
const [testId, message, action] = lock.branchReadOnly
|
|
51
|
-
? [
|
|
52
|
-
'protected-branch-banner',
|
|
53
|
-
`You are viewing the protected base branch "${branchName}". Content is read-only — create a branch to make changes.`,
|
|
54
|
-
`${EDITOR_ACTIONS.newBranch}…`,
|
|
55
|
-
]
|
|
56
|
-
: [
|
|
57
|
-
'status-locked-banner',
|
|
58
|
-
branchDataUnavailable
|
|
59
|
-
? `Branch details for "${branchName}" could not be loaded, so content is locked until they arrive. Use Switch branch… to retry.`
|
|
60
|
-
: lock.branchStatus === 'submitted'
|
|
61
|
-
? `Branch "${branchName}" is submitted for review and locked for edits. Withdraw it from review to make changes.`
|
|
62
|
-
: `Branch "${branchName}" is ${lock.branchStatus} — content is read-only.`,
|
|
63
|
-
`${EDITOR_ACTIONS.switchBranch}…`,
|
|
64
|
-
];
|
|
65
|
-
return (_jsx(Box, { px: "md", py: "sm", children: _jsx(Alert, { icon: _jsx(IconLock, { size: 16 }), color: "yellow", variant: "light", "data-testid": testId, children: _jsxs(Group, { justify: "space-between", align: "center", gap: "sm", wrap: "wrap", children: [_jsx(Text, { size: "sm", children: message }), _jsx(Button, { variant: "default", onClick: onBranchManagerOpen, children: action })] }) }) }));
|
|
66
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/** @internal Exported for tests. Marks an overlay Mod+P may open over: the palette's own closing dialog. */
|
|
2
|
-
export declare const QUICK_OPEN_REPLACES_ATTR = "data-quick-open-replaces";
|
|
3
|
-
/** One option: an entry, with the type and route its second line shows. */
|
|
4
|
-
export interface QuickOpenEntry {
|
|
5
|
-
path: string;
|
|
6
|
-
label: string;
|
|
7
|
-
/** The entry's title, matched by the search where the label is a singleton's type label. */
|
|
8
|
-
title?: string;
|
|
9
|
-
slug?: string;
|
|
10
|
-
collectionPath?: string;
|
|
11
|
-
collectionName?: string;
|
|
12
|
-
/** The entry's type (`entryTypeLine`); absent for a singleton, whose label already names it. */
|
|
13
|
-
typeLabel?: string;
|
|
14
|
-
/** The entry's route on the site (`buildPreviewRoute`); absent when it has no page. */
|
|
15
|
-
route?: string;
|
|
16
|
-
}
|
|
17
|
-
export interface QuickOpenPaletteProps {
|
|
18
|
-
opened: boolean;
|
|
19
|
-
onOpen: () => void;
|
|
20
|
-
onClose: () => void;
|
|
21
|
-
entries: ReadonlyArray<QuickOpenEntry>;
|
|
22
|
-
/** Display name for a collection heading; the collection's own name when absent, none when ''. */
|
|
23
|
-
collectionLabel?: (collectionPath: string) => string | undefined;
|
|
24
|
-
onSelect: (path: string) => void;
|
|
25
|
-
/** Names the element that takes focus after the palette closes, for a caller that unmounted the opener. */
|
|
26
|
-
returnFocusTo?: () => HTMLElement | null;
|
|
27
|
-
}
|
|
28
|
-
export declare function QuickOpenPalette({ opened, onOpen, onClose, entries, collectionLabel, onSelect, returnFocusTo, }: QuickOpenPaletteProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
/** Mod+P palette: type part of an entry's title or slug, pick it with the keyboard or mouse. */
|
|
4
|
-
import { useEffect, useMemo, useState } from 'react';
|
|
5
|
-
import { Combobox, Highlight, Modal, ScrollArea, Text, TextInput, useCombobox } from '@mantine/core';
|
|
6
|
-
import { useHotkeys } from '@mantine/hooks';
|
|
7
|
-
import { IconSearch } from '@tabler/icons-react';
|
|
8
|
-
import { filterEntries, groupEntriesByCollection } from '../entry-search.js';
|
|
9
|
-
import { modKeyLabel } from '../platform.js';
|
|
10
|
-
import { EDITOR_ACTIONS } from '../copy.js';
|
|
11
|
-
import { FOCUS_RETURN_ATTR, focusIfIdle } from '../utils/focus-return.js';
|
|
12
|
-
/** @internal Exported for tests. Marks an overlay Mod+P may open over: the palette's own closing dialog. */
|
|
13
|
-
export const QUICK_OPEN_REPLACES_ATTR = 'data-quick-open-replaces';
|
|
14
|
-
/**
|
|
15
|
-
* True while a modal or drawer (not a popover) is open that `QUICK_OPEN_REPLACES_ATTR` doesn't mark.
|
|
16
|
-
* Overlays stack in DOM order and the palette's portal precedes the editor's other dialogs, so it
|
|
17
|
-
* would open hidden under them and take the keyboard from the one in use.
|
|
18
|
-
*/
|
|
19
|
-
const otherOverlayOpen = () => Array.from(document.querySelectorAll('[role="dialog"][aria-modal="true"]')).some((dialog) => !dialog.closest(`[${QUICK_OPEN_REPLACES_ATTR}]`));
|
|
20
|
-
/** Options rendered at once; the rest are reached by typing more. */
|
|
21
|
-
const MAX_OPTIONS = 50;
|
|
22
|
-
/** An option's second line: its type and its route, whichever it has. */
|
|
23
|
-
const detailLine = (entry) => [entry.typeLabel, entry.route].filter(Boolean).join(' · ');
|
|
24
|
-
export function QuickOpenPalette({ opened, onOpen, onClose, entries, collectionLabel, onSelect, returnFocusTo, }) {
|
|
25
|
-
// `[]` ignores no tags and `true` fires from contenteditable, so the shortcut works from inside
|
|
26
|
-
// a text field or the MDX editor.
|
|
27
|
-
useHotkeys([
|
|
28
|
-
[
|
|
29
|
-
'mod+P',
|
|
30
|
-
(event) => {
|
|
31
|
-
// Mantine's `mod` is Ctrl or ⌘ everywhere; on macOS Ctrl+P moves the caret up a line.
|
|
32
|
-
if (modKeyLabel() === '⌘' && !event.metaKey)
|
|
33
|
-
return;
|
|
34
|
-
// Keeps the browser's print dialog shut, even when the palette can't open.
|
|
35
|
-
event.preventDefault();
|
|
36
|
-
if (!otherOverlayOpen())
|
|
37
|
-
onOpen();
|
|
38
|
-
},
|
|
39
|
-
{ preventDefault: false },
|
|
40
|
-
],
|
|
41
|
-
], [], true);
|
|
42
|
-
const [query, setQuery] = useState('');
|
|
43
|
-
const [wasOpened, setWasOpened] = useState(opened);
|
|
44
|
-
if (opened !== wasOpened) {
|
|
45
|
-
setWasOpened(opened);
|
|
46
|
-
if (!opened)
|
|
47
|
-
setQuery('');
|
|
48
|
-
}
|
|
49
|
-
// Always open: the options are the palette's body, not a dropdown below a field.
|
|
50
|
-
const combobox = useCombobox({ opened: true });
|
|
51
|
-
const { selectFirstOption } = combobox;
|
|
52
|
-
const matches = useMemo(() => filterEntries(entries, query), [entries, query]);
|
|
53
|
-
const shown = useMemo(() => matches.slice(0, MAX_OPTIONS), [matches]);
|
|
54
|
-
const groups = useMemo(() => groupEntriesByCollection(shown, collectionLabel), [shown, collectionLabel]);
|
|
55
|
-
// Enter right after typing opens the best match, so the first option is always armed. Deferred
|
|
56
|
-
// a tick: on opening, the modal's portal and the options list mount after this effect.
|
|
57
|
-
useEffect(() => {
|
|
58
|
-
if (!opened)
|
|
59
|
-
return;
|
|
60
|
-
const timer = setTimeout(selectFirstOption, 0);
|
|
61
|
-
return () => clearTimeout(timer);
|
|
62
|
-
}, [opened, query, selectFirstOption]);
|
|
63
|
-
const handleSubmit = (path) => {
|
|
64
|
-
onSelect(path);
|
|
65
|
-
onClose();
|
|
66
|
-
};
|
|
67
|
-
return (_jsx(Modal, { opened: opened, onClose: onClose, title: EDITOR_ACTIONS.openEntry, size: "lg", yOffset: "10vh", returnFocus: !returnFocusTo, onExitTransitionEnd: () => returnFocusTo && focusIfIdle(returnFocusTo()), [QUICK_OPEN_REPLACES_ATTR]: true, [FOCUS_RETURN_ATTR]: true, children: _jsxs(Combobox, { store: combobox, onOptionSubmit: handleSubmit, children: [_jsx(Combobox.EventsTarget, { withExpandedAttribute: true, "data-mantine-stop-propagation": false, children: _jsx(TextInput, { role: "combobox", "aria-label": "Search entries", "aria-autocomplete": "list", placeholder: "Type an entry title or slug", leftSection: _jsx(IconSearch, { size: 14, "aria-hidden": true }), value: query, onChange: (event) => setQuery(event.currentTarget.value), "data-autofocus": true, "data-testid": "quick-open-input" }) }), _jsx(Combobox.Options, { mt: "sm", "aria-label": "Entries", children: _jsx(ScrollArea.Autosize, { mah: 360, type: "scroll", scrollbars: "y", children: matches.length === 0 ? (_jsxs(Text, { size: "sm", c: "dimmed", p: "xs", "data-testid": "quick-open-empty", children: ["No entries match \u201C", query, "\u201D."] })) : (groups.map((group, index) => (_jsx(Combobox.Group, { label: group.heading || undefined, children: group.entries.map((entry) => {
|
|
68
|
-
const detail = detailLine(entry);
|
|
69
|
-
return (_jsxs(Combobox.Option, { value: entry.path, "data-testid": "quick-open-option", children: [_jsx(Highlight, { highlight: query.trim(), size: "sm", children: entry.label }), detail && (_jsx(Text, { size: "xs", "data-testid": "quick-open-option-detail", children: detail }))] }, entry.path));
|
|
70
|
-
}) }, index)))) }) }), matches.length > MAX_OPTIONS && (_jsxs(Text, { size: "xs", c: "dimmed", mt: "xs", children: ["Showing ", MAX_OPTIONS, " of ", matches.length, ". Keep typing to narrow the list."] }))] }) }));
|
|
71
|
-
}
|