canopycms 0.1.0-int.115 → 0.1.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 +267 -231
- package/README.md +0 -1
- package/dist/ai/references.js +3 -1
- package/dist/api/assets.js +2 -2
- package/dist/api/branch-merge.d.ts +1 -1
- package/dist/api/branch.js +1 -1
- package/dist/api/content.js +1 -1
- package/dist/api/entries-constants.d.ts +1 -1
- package/dist/api/entries-constants.js +1 -1
- package/dist/api/entries.d.ts +2 -2
- package/dist/api/permissions.d.ts +2 -2
- package/dist/api/types.d.ts +3 -2
- package/dist/cli/cli.js +16 -14
- package/dist/cli/generate-ai-content.js +7 -5
- package/dist/cli/init.js +4 -4
- package/dist/client.d.ts +0 -1
- package/dist/client.js +0 -1
- package/dist/config/schemas/config.d.ts +0 -5
- package/dist/config/schemas/config.js +0 -1
- package/dist/config/types.d.ts +0 -1
- package/dist/content-index-generation.d.ts +1 -1
- package/dist/content-index-generation.js +1 -1
- package/dist/content-listing.js +1 -1
- package/dist/content-reader.js +1 -1
- package/dist/content-store.d.ts +1 -1
- package/dist/content-store.js +1 -1
- package/dist/dev-content-watcher.d.ts +1 -1
- package/dist/dev-content-watcher.js +1 -1
- package/dist/editor/BranchManager.d.ts +2 -0
- package/dist/editor/BranchManager.js +5 -4
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +1 -2
- package/dist/editor/CommentsPanel.js +3 -6
- package/dist/editor/Editor.d.ts +0 -2
- package/dist/editor/Editor.js +329 -181
- package/dist/editor/EditorAuthGate.js +8 -3
- package/dist/editor/EntryNavigator.d.ts +10 -0
- package/dist/editor/EntryNavigator.js +51 -2
- package/dist/editor/FormHeader.js +1 -1
- package/dist/editor/PreviewFrame.d.ts +5 -1
- package/dist/editor/PreviewFrame.js +3 -2
- package/dist/editor/PreviewPane.d.ts +47 -0
- package/dist/editor/PreviewPane.js +147 -0
- package/dist/editor/PreviewToolbarTools.d.ts +22 -0
- package/dist/editor/PreviewToolbarTools.js +73 -0
- package/dist/editor/comments/form-comments.js +2 -1
- package/dist/editor/components/BranchLockBanner.d.ts +22 -0
- package/dist/editor/components/BranchLockBanner.js +39 -0
- package/dist/editor/components/BranchPill.d.ts +32 -0
- package/dist/editor/components/BranchPill.js +33 -0
- package/dist/editor/components/ConfirmDeleteModal.d.ts +3 -1
- package/dist/editor/components/ConfirmDeleteModal.js +3 -2
- package/dist/editor/components/EditorHeader.d.ts +50 -108
- package/dist/editor/components/EditorHeader.js +117 -138
- package/dist/editor/components/EditorRail.d.ts +49 -0
- package/dist/editor/components/EditorRail.js +65 -0
- package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
- package/dist/editor/components/EntryBreadcrumb.js +56 -0
- package/dist/editor/components/QuickOpenPalette.d.ts +1 -1
- package/dist/editor/components/QuickOpenPalette.js +3 -2
- package/dist/editor/components/RenameEntryModal.d.ts +3 -1
- package/dist/editor/components/RenameEntryModal.js +3 -2
- package/dist/editor/components/SaveAllResult.d.ts +25 -0
- package/dist/editor/components/SaveAllResult.js +86 -0
- package/dist/editor/components/SaveSplitButton.d.ts +30 -0
- package/dist/editor/components/SaveSplitButton.js +108 -0
- package/dist/editor/components/index.d.ts +2 -2
- package/dist/editor/components/index.js +2 -2
- package/dist/editor/context/EditorStateContext.d.ts +4 -4
- package/dist/editor/context/EditorStateContext.js +4 -4
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/editor-utils.d.ts +11 -6
- package/dist/editor/editor-utils.js +8 -17
- package/dist/editor/fields/MarkdownField.js +3 -0
- package/dist/editor/hooks/save-all.d.ts +68 -0
- package/dist/editor/hooks/save-all.js +61 -0
- package/dist/editor/hooks/useCommentSystem.d.ts +2 -0
- package/dist/editor/hooks/useCommentSystem.js +3 -0
- package/dist/editor/hooks/useDraftManager.d.ts +24 -1
- package/dist/editor/hooks/useDraftManager.js +297 -16
- package/dist/editor/hooks/useEditorLayout.d.ts +15 -11
- package/dist/editor/hooks/useEditorLayout.js +13 -25
- package/dist/editor/hooks/useEntryManager.d.ts +4 -0
- package/dist/editor/hooks/useEntryManager.js +30 -7
- package/dist/editor/media/MediaLibrary.d.ts +1 -1
- package/dist/editor/preview-path.d.ts +12 -0
- package/dist/editor/preview-path.js +45 -1
- package/dist/editor/preview-scale.d.ts +27 -0
- package/dist/editor/preview-scale.js +34 -0
- package/dist/editor/shell/ActivityRail.d.ts +37 -0
- package/dist/editor/shell/ActivityRail.js +91 -0
- package/dist/editor/shell/EditorShell.d.ts +17 -0
- package/dist/editor/shell/EditorShell.js +31 -0
- package/dist/editor/shell/PaneLayout.d.ts +28 -0
- package/dist/editor/shell/PaneLayout.js +116 -0
- package/dist/editor/shell/SidePanelHost.d.ts +35 -0
- package/dist/editor/shell/SidePanelHost.js +184 -0
- package/dist/editor/shell/TopBar.d.ts +12 -0
- package/dist/editor/shell/TopBar.js +14 -0
- package/dist/editor/shell/host-chrome.d.ts +16 -0
- package/dist/editor/shell/host-chrome.js +69 -0
- package/dist/editor/theme.js +18 -3
- package/dist/git-manager.d.ts +1 -1
- package/dist/git-manager.js +2 -2
- package/dist/operating-mode/types.d.ts +1 -1
- package/dist/resolve-canopy-user.d.ts +1 -1
- package/dist/resolve-canopy-user.js +1 -1
- package/dist/schema/resolver.d.ts +1 -1
- package/dist/schema/resolver.js +1 -1
- package/dist/schema/schema-store.d.ts +1 -1
- package/dist/schema/schema-store.js +2 -2
- package/dist/url-collision.js +3 -3
- package/dist/url-exclusivity-fixtures.d.ts +1 -1
- package/dist/url-exclusivity-fixtures.js +1 -1
- package/dist/utils/content-write-lock.d.ts +1 -1
- package/dist/utils/content-write-lock.js +1 -1
- package/dist/utils/debug.d.ts +1 -1
- package/dist/utils/debug.js +5 -3
- package/dist/utils/git.d.ts +4 -0
- package/dist/utils/git.js +27 -5
- package/dist/validation/block-structural-keys.d.ts +1 -1
- package/dist/validation/block-structural-keys.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -1
- package/dist/worker/cms-worker.js +2 -2
- package/dist/worker/github-auth.d.ts +1 -1
- package/dist/worker/github-auth.js +1 -1
- package/dist/worker/github-gateway-wire.d.ts +2 -1
- package/dist/worker/github-gateway-wire.js +7 -3
- package/dist/worker/github-gateway.js +1 -1
- package/dist/worker/github-mirror.d.ts +2 -2
- package/dist/worker/github-mirror.js +2 -2
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/self-checks.d.ts +1 -1
- package/dist/worker/shared-repo-git.js +1 -1
- package/package.json +5 -4
- package/dist/editor/EditorPanes.d.ts +0 -20
- package/dist/editor/EditorPanes.js +0 -124
- package/dist/editor/components/EditorFooter.d.ts +0 -11
- package/dist/editor/components/EditorFooter.js +0 -9
- package/dist/editor/components/EditorSidebar.d.ts +0 -70
- package/dist/editor/components/EditorSidebar.js +0 -50
|
@@ -102,10 +102,6 @@ export function useEntryManager(options) {
|
|
|
102
102
|
selectedPathRef.current = selectedPath;
|
|
103
103
|
// Counts selections through the returned setter, not this hook's own fallbacks.
|
|
104
104
|
const selectionsByCallerRef = useRef(0);
|
|
105
|
-
const selectEntryPath = useCallback((path) => {
|
|
106
|
-
selectionsByCallerRef.current += 1;
|
|
107
|
-
setSelectedPath(path);
|
|
108
|
-
}, []);
|
|
109
105
|
const [navigatorOpen, setNavigatorOpen] = useState(() => readUrlEntry() === '');
|
|
110
106
|
const isInitialMount = useRef(true);
|
|
111
107
|
const hasSyncedFromUrl = useRef(false);
|
|
@@ -184,8 +180,18 @@ export function useEntryManager(options) {
|
|
|
184
180
|
if (initialUrlEntry.current === undefined) {
|
|
185
181
|
initialUrlEntry.current = readUrlEntry();
|
|
186
182
|
}
|
|
187
|
-
//
|
|
183
|
+
// Set by the URL's empty `entry` or by `closeEntry`; honoured until an entry is selected or the
|
|
184
|
+
// branch changes, after which a lost selection opens the first again.
|
|
188
185
|
const noEntryRequested = useRef(initialUrlEntry.current === '');
|
|
186
|
+
const selectEntryPath = useCallback((path) => {
|
|
187
|
+
selectionsByCallerRef.current += 1;
|
|
188
|
+
setSelectedPath(path);
|
|
189
|
+
}, []);
|
|
190
|
+
// No entry open, as the URL's empty `entry` asks, where a lost selection opens the first.
|
|
191
|
+
const closeEntry = useCallback(() => {
|
|
192
|
+
noEntryRequested.current = true;
|
|
193
|
+
selectEntryPath('');
|
|
194
|
+
}, [selectEntryPath]);
|
|
189
195
|
const collectionByPath = useMemo(() => {
|
|
190
196
|
const map = new Map();
|
|
191
197
|
const walk = (collections) => {
|
|
@@ -492,10 +498,24 @@ export function useEntryManager(options) {
|
|
|
492
498
|
const renaming = entriesState.find((e) => e.path === path);
|
|
493
499
|
const contentId = renaming?.contentId;
|
|
494
500
|
const label = renaming?.label ?? path;
|
|
501
|
+
// As `refreshAndOpen`: the new path opens only if the user is still on the entry and branch.
|
|
502
|
+
const branch = options.branchName;
|
|
503
|
+
const stillOnIt = selectedPathRef.current === path;
|
|
504
|
+
const selections = selectionsByCallerRef.current;
|
|
505
|
+
// The rename landed whatever the refresh does. The new path is selected only once the list
|
|
506
|
+
// holds it: before that, the selection check would find it missing and open the first entry.
|
|
495
507
|
const landed = async (newPath) => {
|
|
496
|
-
|
|
508
|
+
const listed = await refreshEntries().catch((err) => {
|
|
509
|
+
console.error(err);
|
|
510
|
+
return null;
|
|
511
|
+
});
|
|
512
|
+
if (newPath &&
|
|
513
|
+
listed?.some((e) => e.path === newPath) &&
|
|
514
|
+
stillOnIt &&
|
|
515
|
+
selections === selectionsByCallerRef.current &&
|
|
516
|
+
branch === currentBranchRef.current) {
|
|
497
517
|
setSelectedPath(newPath);
|
|
498
|
-
|
|
518
|
+
}
|
|
499
519
|
notifications.show({ message: 'Entry renamed', color: 'green' });
|
|
500
520
|
};
|
|
501
521
|
options.setBusy(true);
|
|
@@ -543,6 +563,7 @@ export function useEntryManager(options) {
|
|
|
543
563
|
isInitialMount.current = false;
|
|
544
564
|
}
|
|
545
565
|
else {
|
|
566
|
+
noEntryRequested.current = false;
|
|
546
567
|
setSelectedPath('');
|
|
547
568
|
// Bound version-map growth across switches. Correctness no longer
|
|
548
569
|
// depends on this clear: tokens are keyed by `${branch}:${contentId}`
|
|
@@ -710,12 +731,14 @@ export function useEntryManager(options) {
|
|
|
710
731
|
return {
|
|
711
732
|
selectedPath,
|
|
712
733
|
setSelectedPath: selectEntryPath,
|
|
734
|
+
closeEntry,
|
|
713
735
|
entries: entriesState,
|
|
714
736
|
setEntries,
|
|
715
737
|
collections: collectionsState,
|
|
716
738
|
currentEntry,
|
|
717
739
|
availableSchemas,
|
|
718
740
|
entriesInitializing,
|
|
741
|
+
entriesLoadFailed: Boolean(options.branchName) && Boolean(entriesError),
|
|
719
742
|
navigatorOpen,
|
|
720
743
|
setNavigatorOpen,
|
|
721
744
|
refreshEntries,
|
|
@@ -4,7 +4,7 @@ export interface MediaLibraryProps {
|
|
|
4
4
|
opened: boolean;
|
|
5
5
|
onClose: () => void;
|
|
6
6
|
/**
|
|
7
|
-
* 'manage': right-side Drawer opened from the
|
|
7
|
+
* 'manage': right-side Drawer opened from the rail's Media button.
|
|
8
8
|
* 'picker': Modal used by ImageField ("Browse library"/"Replace") and the
|
|
9
9
|
* MDX image dialog's "From library" tab.
|
|
10
10
|
*/
|
|
@@ -8,3 +8,15 @@
|
|
|
8
8
|
export declare const normalizePreviewPath: (url: string) => string;
|
|
9
9
|
/** Whether two preview URLs name the same page; see `normalizePreviewPath`. */
|
|
10
10
|
export declare const isSamePreviewPath: (a: string, b: string) => boolean;
|
|
11
|
+
/** Where `buildPreviewSrc` mounts the preview routes, and the `basePath` the site's pages keep. */
|
|
12
|
+
export interface PreviewMount {
|
|
13
|
+
basePath?: string;
|
|
14
|
+
previewPrefix?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The previewed page's path on the site, as the preview toolbar shows and copies it: without the
|
|
18
|
+
* `previewPrefix` mount, the `branch` query the top bar already names, or a fragment. A relative
|
|
19
|
+
* URL stays relative. An absolute one keeps its origin, since it may be another site, unless an
|
|
20
|
+
* absolute `previewPrefix` mount is what was taken out.
|
|
21
|
+
*/
|
|
22
|
+
export declare const displayPreviewPath: (src: string, mount?: PreviewMount) => string;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { stripTrailingSlashes } from '../utils/url-prefix.js';
|
|
1
|
+
import { isAbsoluteUrl, joinUrlPrefix, stripTrailingSlashes } from '../utils/url-prefix.js';
|
|
2
2
|
/**
|
|
3
3
|
* Reduces a preview URL to what identifies the page, its path and query, with no origin,
|
|
4
4
|
* fragment or trailing slash. The editor names a page by the `src` it built and the framed page
|
|
@@ -18,3 +18,47 @@ export const normalizePreviewPath = (url) => {
|
|
|
18
18
|
};
|
|
19
19
|
/** Whether two preview URLs name the same page; see `normalizePreviewPath`. */
|
|
20
20
|
export const isSamePreviewPath = (a, b) => normalizePreviewPath(a) === normalizePreviewPath(b);
|
|
21
|
+
/** `url` with the `previewPrefix` mount taken out, so `/base/preview/posts/x` reads `/base/posts/x`. */
|
|
22
|
+
const stripPreviewPrefix = (url, { basePath, previewPrefix }) => {
|
|
23
|
+
if (!previewPrefix || !stripTrailingSlashes(previewPrefix))
|
|
24
|
+
return url;
|
|
25
|
+
const mount = stripTrailingSlashes(joinUrlPrefix(basePath, joinUrlPrefix(previewPrefix, '/')));
|
|
26
|
+
if (!url.startsWith(mount))
|
|
27
|
+
return url;
|
|
28
|
+
const rest = url.slice(mount.length);
|
|
29
|
+
if (rest !== '' && !/^[/?#]/.test(rest))
|
|
30
|
+
return url;
|
|
31
|
+
// An absolute prefix skips `basePath`, so the page under it has none either.
|
|
32
|
+
const base = isAbsoluteUrl(mount) ? '' : stripTrailingSlashes(joinUrlPrefix(basePath, '/'));
|
|
33
|
+
return rest.startsWith('/') ? `${base}${rest}` : `${base || '/'}${rest}`;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The previewed page's path on the site, as the preview toolbar shows and copies it: without the
|
|
37
|
+
* `previewPrefix` mount, the `branch` query the top bar already names, or a fragment. A relative
|
|
38
|
+
* URL stays relative. An absolute one keeps its origin, since it may be another site, unless an
|
|
39
|
+
* absolute `previewPrefix` mount is what was taken out.
|
|
40
|
+
*/
|
|
41
|
+
export const displayPreviewPath = (src, mount = {}) => {
|
|
42
|
+
const url = stripPreviewPrefix(src, mount);
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = new URL(url, 'http://preview.invalid');
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return url;
|
|
49
|
+
}
|
|
50
|
+
// Filtered as text: a URLSearchParams round trip would form-encode the query it keeps (`flag`
|
|
51
|
+
// becomes `flag=`, `%20` becomes `+`).
|
|
52
|
+
const query = parsed.search
|
|
53
|
+
.slice(1)
|
|
54
|
+
.split('&')
|
|
55
|
+
.filter((part) => part !== '' && part.split('=', 1)[0] !== 'branch')
|
|
56
|
+
.join('&');
|
|
57
|
+
const path = `${parsed.pathname}${query ? `?${query}` : ''}`;
|
|
58
|
+
if (!isAbsoluteUrl(url))
|
|
59
|
+
return path;
|
|
60
|
+
// `origin` is 'null' for a scheme the URL spec does not treat as special.
|
|
61
|
+
return url.startsWith('//')
|
|
62
|
+
? `//${parsed.host}${path}`
|
|
63
|
+
: `${parsed.protocol}//${parsed.host}${path}`;
|
|
64
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PreviewWidth } from './hooks/useEditorLayout.js';
|
|
2
|
+
/** The CSS width each fixed preview width renders the site at. */
|
|
3
|
+
export declare const PREVIEW_DEVICE_WIDTHS: {
|
|
4
|
+
readonly desktop: 1280;
|
|
5
|
+
readonly tablet: 768;
|
|
6
|
+
readonly mobile: 390;
|
|
7
|
+
};
|
|
8
|
+
/** Where and how large the preview iframe renders inside a pane of a given size. */
|
|
9
|
+
export interface PreviewViewport {
|
|
10
|
+
/** The iframe's CSS width: the width the site's breakpoints see. */
|
|
11
|
+
frameWidth: number;
|
|
12
|
+
/** The iframe's CSS height, before scaling; scaled, it fills the pane's height. */
|
|
13
|
+
frameHeight: number;
|
|
14
|
+
/** The factor the iframe is scaled by, at most 1. */
|
|
15
|
+
scale: number;
|
|
16
|
+
/** The left offset that centres the scaled iframe in the pane. */
|
|
17
|
+
offsetX: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Fits the preview into a pane. "fit" renders at the pane's width. A fixed width renders at
|
|
21
|
+
* exactly that CSS width and, when the pane is narrower, scales down so the whole width shows,
|
|
22
|
+
* stretching the CSS height by the same factor so the scaled frame still fills the pane's height.
|
|
23
|
+
* An unmeasured pane (zero or non-finite) renders unscaled.
|
|
24
|
+
*/
|
|
25
|
+
export declare function computePreviewViewport(previewWidth: PreviewWidth, paneWidth: number, paneHeight: number): PreviewViewport;
|
|
26
|
+
/** The readout beside the width selector: the CSS width, plus the scale when it rounds below 100%. */
|
|
27
|
+
export declare function formatPreviewWidthReadout({ frameWidth, scale }: PreviewViewport): string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The CSS width each fixed preview width renders the site at. */
|
|
2
|
+
export const PREVIEW_DEVICE_WIDTHS = {
|
|
3
|
+
desktop: 1280,
|
|
4
|
+
tablet: 768,
|
|
5
|
+
mobile: 390,
|
|
6
|
+
};
|
|
7
|
+
const positiveOr = (value, fallback) => Number.isFinite(value) && value > 0 ? value : fallback;
|
|
8
|
+
/**
|
|
9
|
+
* Fits the preview into a pane. "fit" renders at the pane's width. A fixed width renders at
|
|
10
|
+
* exactly that CSS width and, when the pane is narrower, scales down so the whole width shows,
|
|
11
|
+
* stretching the CSS height by the same factor so the scaled frame still fills the pane's height.
|
|
12
|
+
* An unmeasured pane (zero or non-finite) renders unscaled.
|
|
13
|
+
*/
|
|
14
|
+
export function computePreviewViewport(previewWidth, paneWidth, paneHeight) {
|
|
15
|
+
const width = positiveOr(paneWidth, 0);
|
|
16
|
+
const height = positiveOr(paneHeight, 0);
|
|
17
|
+
if (previewWidth === 'fit') {
|
|
18
|
+
return { frameWidth: width, frameHeight: height, scale: 1, offsetX: 0 };
|
|
19
|
+
}
|
|
20
|
+
const frameWidth = PREVIEW_DEVICE_WIDTHS[previewWidth];
|
|
21
|
+
const scale = width > 0 ? Math.min(1, width / frameWidth) : 1;
|
|
22
|
+
return {
|
|
23
|
+
frameWidth,
|
|
24
|
+
frameHeight: height / scale,
|
|
25
|
+
scale,
|
|
26
|
+
offsetX: width > 0 ? Math.max(0, (width - frameWidth * scale) / 2) : 0,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** The readout beside the width selector: the CSS width, plus the scale when it rounds below 100%. */
|
|
30
|
+
export function formatPreviewWidthReadout({ frameWidth, scale }) {
|
|
31
|
+
const px = `${Math.round(frameWidth)} px`;
|
|
32
|
+
const percent = Math.round(scale * 100);
|
|
33
|
+
return percent < 100 ? `${px} at ${percent}%` : px;
|
|
34
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React, { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
|
2
|
+
export interface ActivityRailProps {
|
|
3
|
+
/** Rail buttons from the top: the monogram, then the activities. */
|
|
4
|
+
top: ReactNode;
|
|
5
|
+
/** Rail buttons pinned to the bottom, such as Settings. */
|
|
6
|
+
bottom?: ReactNode;
|
|
7
|
+
/** The account control, a tab stop of its own: an adopter's component manages its own focus. */
|
|
8
|
+
account?: ReactNode;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The left activity rail: one vertical toolbar whose `RailButton`s share a single Tab stop and move
|
|
12
|
+
* with the arrow keys, Home and End, then the account control. Layout only; the buttons come in as
|
|
13
|
+
* children.
|
|
14
|
+
*/
|
|
15
|
+
export declare function ActivityRail({ top, bottom, account }: ActivityRailProps): import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export interface RailButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'children'> {
|
|
17
|
+
/** Unique within the rail; the roving Tab stop is tracked by it. Not `id`: Mantine's
|
|
18
|
+
* `Menu.Target` sets that on its child. */
|
|
19
|
+
railId: string;
|
|
20
|
+
/** The accessible name and the tooltip's text. */
|
|
21
|
+
label: string;
|
|
22
|
+
icon: ReactNode;
|
|
23
|
+
/** Highlights the button while its panel or drawer is open. */
|
|
24
|
+
active?: boolean;
|
|
25
|
+
/** Tinted at rest, as the site monogram is. */
|
|
26
|
+
tinted?: boolean;
|
|
27
|
+
/** Shown after the label in the tooltip, as the platform writes it ("⌘P"). */
|
|
28
|
+
shortcut?: string;
|
|
29
|
+
/** A count badge, hidden at zero; the label should say it too. */
|
|
30
|
+
count?: number;
|
|
31
|
+
countTestId?: string;
|
|
32
|
+
tooltipDisabled?: boolean;
|
|
33
|
+
/** Disables the button, which stays focusable, and its tooltip gives this reason instead. */
|
|
34
|
+
disabledReason?: string;
|
|
35
|
+
}
|
|
36
|
+
/** A rail button: icon only, named by `label`, which its tooltip repeats on hover and focus. */
|
|
37
|
+
export declare const RailButton: React.ForwardRefExoticComponent<RailButtonProps & React.RefAttributes<HTMLButtonElement>>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, useId, useLayoutEffect, useRef, useState, } from 'react';
|
|
4
|
+
import { ActionIcon, Indicator, Stack, Tooltip, VisuallyHidden } from '@mantine/core';
|
|
5
|
+
import { useElementSize, useMergedRef } from '@mantine/hooks';
|
|
6
|
+
const ACTIVITY_RAIL_WIDTH = 44;
|
|
7
|
+
const RAIL_ITEM_ATTR = 'data-rail-item';
|
|
8
|
+
/**
|
|
9
|
+
* The left activity rail: one vertical toolbar whose `RailButton`s share a single Tab stop and move
|
|
10
|
+
* with the arrow keys, Home and End, then the account control. Layout only; the buttons come in as
|
|
11
|
+
* children.
|
|
12
|
+
*/
|
|
13
|
+
export function ActivityRail({ top, bottom, account }) {
|
|
14
|
+
const toolbarRef = useRef(null);
|
|
15
|
+
const currentRef = useRef(null);
|
|
16
|
+
// Re-renders on a resize, so the check below runs again.
|
|
17
|
+
const { ref: sizeRef } = useElementSize();
|
|
18
|
+
const mergedToolbarRef = useMergedRef(toolbarRef, sizeRef);
|
|
19
|
+
const [overflowing, setOverflowing] = useState(false);
|
|
20
|
+
const items = () => Array.from(toolbarRef.current?.querySelectorAll(`[${RAIL_ITEM_ATTR}]`) ?? []);
|
|
21
|
+
// Set on the DOM after every render, so a button that mounts or goes takes or hands on the Tab
|
|
22
|
+
// stop in the same commit; it falls back to the first button.
|
|
23
|
+
const placeTabStop = () => {
|
|
24
|
+
const all = items();
|
|
25
|
+
const stop = all.find((item) => item.getAttribute(RAIL_ITEM_ATTR) === currentRef.current);
|
|
26
|
+
for (const item of all)
|
|
27
|
+
item.tabIndex = item === (stop ?? all[0]) ? 0 : -1;
|
|
28
|
+
};
|
|
29
|
+
useLayoutEffect(placeTabStop);
|
|
30
|
+
// Only a rail too short for its buttons scrolls: a scroll container clips sideways too, and at
|
|
31
|
+
// full height nothing, such as a two-digit count badge, should be cut off.
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
const toolbar = toolbarRef.current;
|
|
34
|
+
if (toolbar)
|
|
35
|
+
setOverflowing(toolbar.scrollHeight > toolbar.clientHeight);
|
|
36
|
+
});
|
|
37
|
+
const onKeyDown = (event) => {
|
|
38
|
+
const all = items();
|
|
39
|
+
// A menu portalled from a button still bubbles here through React; only the buttons rove.
|
|
40
|
+
const index = all.indexOf(event.target);
|
|
41
|
+
if (index < 0)
|
|
42
|
+
return;
|
|
43
|
+
const next = event.key === 'ArrowDown'
|
|
44
|
+
? all[(index + 1) % all.length]
|
|
45
|
+
: event.key === 'ArrowUp'
|
|
46
|
+
? all[(index - 1 + all.length) % all.length]
|
|
47
|
+
: event.key === 'Home'
|
|
48
|
+
? all[0]
|
|
49
|
+
: event.key === 'End'
|
|
50
|
+
? all[all.length - 1]
|
|
51
|
+
: undefined;
|
|
52
|
+
if (!next)
|
|
53
|
+
return;
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
next.focus();
|
|
56
|
+
};
|
|
57
|
+
return (_jsxs(Stack, { gap: 0, h: "100%", w: ACTIVITY_RAIL_WIDTH, align: "center", style: {
|
|
58
|
+
borderInlineEnd: '1px solid var(--canopy-input-border)',
|
|
59
|
+
background: 'var(--mantine-color-body)',
|
|
60
|
+
}, children: [_jsxs(Stack, { ref: mergedToolbarRef, role: "toolbar", "aria-orientation": "vertical", "aria-label": "Activities", gap: 6, py: 8, align: "center", style: {
|
|
61
|
+
flex: 1,
|
|
62
|
+
minHeight: 0,
|
|
63
|
+
...(overflowing && {
|
|
64
|
+
alignSelf: 'stretch',
|
|
65
|
+
overflowY: 'auto',
|
|
66
|
+
overflowX: 'hidden',
|
|
67
|
+
scrollbarWidth: 'none',
|
|
68
|
+
}),
|
|
69
|
+
}, onKeyDown: onKeyDown, onFocus: (event) => {
|
|
70
|
+
const id = event.target.getAttribute(RAIL_ITEM_ATTR);
|
|
71
|
+
if (id) {
|
|
72
|
+
currentRef.current = id;
|
|
73
|
+
placeTabStop();
|
|
74
|
+
}
|
|
75
|
+
}, children: [top, _jsx("div", { style: { flex: 1 } }), bottom] }), account && _jsx(Stack, { pb: 8, children: account })] }));
|
|
76
|
+
}
|
|
77
|
+
/** 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, tinted = false, shortcut, count = 0, countTestId, tooltipDisabled, disabledReason, onClick, ...rest }, ref) {
|
|
79
|
+
const reasonId = useId();
|
|
80
|
+
const button = (_jsxs(ActionIcon, { ref: ref,
|
|
81
|
+
// Filled, not tinted, when active: a tint alone would show the state by colour, under 3:1.
|
|
82
|
+
variant: active ? 'filled' : tinted ? 'light' : 'subtle', color: active || tinted ? 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: {
|
|
84
|
+
indicator: {
|
|
85
|
+
pointerEvents: 'none',
|
|
86
|
+
// Mantine paints the pill again in a ::before, from this variable.
|
|
87
|
+
'--indicator-color': 'var(--canopy-count-bg)',
|
|
88
|
+
'--indicator-text-color': 'var(--mantine-color-text)',
|
|
89
|
+
},
|
|
90
|
+
}, children: button }) }));
|
|
91
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export interface EditorShellProps {
|
|
3
|
+
topBar?: ReactNode;
|
|
4
|
+
banner?: ReactNode;
|
|
5
|
+
rail?: ReactNode;
|
|
6
|
+
panel?: ReactNode;
|
|
7
|
+
main: ReactNode;
|
|
8
|
+
/** Drawers, modals and palettes, each portalling itself into `<body>`. */
|
|
9
|
+
overlays?: ReactNode;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The editor's window: a full-viewport grid of regions (top bar, banner, rail, panel, main,
|
|
13
|
+
* overlays), portalled into `<body>` above the host page, whose chrome it hides and makes inert
|
|
14
|
+
* and whose scrolling it stops. Layout only: features plug into the regions. The rail sits at the
|
|
15
|
+
* inline start, opposite the toasts (`theme.tsx`), and comes first after the banner in focus order.
|
|
16
|
+
*/
|
|
17
|
+
export declare function EditorShell({ topBar, banner, rail, panel, main, overlays }: EditorShellProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
import { Portal, getDefaultZIndex } from '@mantine/core';
|
|
5
|
+
import { RemoveScroll } from 'react-remove-scroll';
|
|
6
|
+
import { useHideHostChrome } from './host-chrome.js';
|
|
7
|
+
const gridStyle = {
|
|
8
|
+
position: 'fixed',
|
|
9
|
+
inset: 0,
|
|
10
|
+
// Widgets a host adds outside its React tree bring their own z-index, so the shell sits just
|
|
11
|
+
// under Mantine's drawers and modals, leaving the layer between for MDXEditor's popups.
|
|
12
|
+
zIndex: getDefaultZIndex('modal') - 2,
|
|
13
|
+
display: 'grid',
|
|
14
|
+
gridTemplateAreas: '"topbar topbar topbar" "banner banner banner" "rail panel main"',
|
|
15
|
+
gridTemplateRows: 'auto auto minmax(0, 1fr)',
|
|
16
|
+
gridTemplateColumns: 'auto auto minmax(0, 1fr)',
|
|
17
|
+
background: 'var(--canopy-surface-subtle)',
|
|
18
|
+
color: 'var(--mantine-color-text)',
|
|
19
|
+
};
|
|
20
|
+
const region = (area) => ({ gridArea: area, minWidth: 0, minHeight: 0 });
|
|
21
|
+
/**
|
|
22
|
+
* The editor's window: a full-viewport grid of regions (top bar, banner, rail, panel, main,
|
|
23
|
+
* overlays), portalled into `<body>` above the host page, whose chrome it hides and makes inert
|
|
24
|
+
* and whose scrolling it stops. Layout only: features plug into the regions. The rail sits at the
|
|
25
|
+
* inline start, opposite the toasts (`theme.tsx`), and comes first after the banner in focus order.
|
|
26
|
+
*/
|
|
27
|
+
export function EditorShell({ topBar, banner, rail, panel, main, overlays }) {
|
|
28
|
+
const anchorRef = useRef(null);
|
|
29
|
+
useHideHostChrome(anchorRef);
|
|
30
|
+
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 })), _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 })] }) })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/** Preview beside the form, or above it. */
|
|
3
|
+
export type PaneArrangement = 'side' | 'stacked';
|
|
4
|
+
export declare const SPLIT_PERCENT_MIN = 15;
|
|
5
|
+
export declare const SPLIT_PERCENT_MAX = 85;
|
|
6
|
+
export declare const DEFAULT_SIDE_SPLIT_PERCENT = 52;
|
|
7
|
+
export declare const DEFAULT_STACKED_SPLIT_PERCENT = 58;
|
|
8
|
+
export interface PaneLayoutProps {
|
|
9
|
+
arrangement: PaneArrangement;
|
|
10
|
+
/** The preview's share (percent) beside the form; follows this prop when it changes. */
|
|
11
|
+
sideSplitPercent?: number;
|
|
12
|
+
/** The preview's share (percent) above the form; follows this prop when it changes. */
|
|
13
|
+
stackedSplitPercent?: number;
|
|
14
|
+
/** Called when a drag or a keyboard resize changes the split, with the preview's percent. */
|
|
15
|
+
onSplitPercentChange?: (arrangement: PaneArrangement, percent: number) => void;
|
|
16
|
+
preview?: React.ReactNode;
|
|
17
|
+
form?: React.ReactNode;
|
|
18
|
+
/** Laid over the form pane, outside its scrolling: the side panel in overlay mode. */
|
|
19
|
+
formOverlay?: React.ReactNode;
|
|
20
|
+
/** Takes the form out of focus and the pointer's reach while an open `formOverlay` hides it. */
|
|
21
|
+
formInert?: boolean;
|
|
22
|
+
/** Lays the preview over the whole layout, form and separator inert, without remounting it. */
|
|
23
|
+
previewMaximized?: boolean;
|
|
24
|
+
/** Called as a held separator first moves the split, and as it is let go after that. */
|
|
25
|
+
onSeparatorDrag?: (dragging: boolean) => void;
|
|
26
|
+
}
|
|
27
|
+
/** The resizable preview/form split that fills the shell's main region. */
|
|
28
|
+
export declare function PaneLayout({ arrangement, sideSplitPercent, stackedSplitPercent, onSplitPercentChange, preview, form, formOverlay, formInert, previewMaximized, onSeparatorDrag, }: PaneLayoutProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
4
|
+
import { Box } from '@mantine/core';
|
|
5
|
+
import { Group, Panel, Separator, useGroupRef } from 'react-resizable-panels';
|
|
6
|
+
export const SPLIT_PERCENT_MIN = 15;
|
|
7
|
+
export const SPLIT_PERCENT_MAX = 85;
|
|
8
|
+
export const DEFAULT_SIDE_SPLIT_PERCENT = 52;
|
|
9
|
+
export const DEFAULT_STACKED_SPLIT_PERCENT = 58;
|
|
10
|
+
const PREVIEW = 'canopy-preview';
|
|
11
|
+
const FORM = 'canopy-form';
|
|
12
|
+
// The preview's limits are the stored split's, so a drag never ends somewhere storage would move;
|
|
13
|
+
// the form also keeps a usable minimum in a narrow window.
|
|
14
|
+
const PREVIEW_LIMITS = { minSize: `${SPLIT_PERCENT_MIN}%`, maxSize: `${SPLIT_PERCENT_MAX}%` };
|
|
15
|
+
const FORM_MIN_PX = 160;
|
|
16
|
+
const SEPARATOR_STYLES = `
|
|
17
|
+
.canopy-pane-separator {
|
|
18
|
+
min-width: 8px;
|
|
19
|
+
min-height: 8px;
|
|
20
|
+
background: var(--canopy-surface-muted);
|
|
21
|
+
box-shadow: inset 0 0 0 1px var(--canopy-input-border);
|
|
22
|
+
}
|
|
23
|
+
.canopy-pane-separator:is([data-separator='hover'], [data-separator='active']) {
|
|
24
|
+
background: var(--canopy-focus-ring);
|
|
25
|
+
}
|
|
26
|
+
.canopy-pane-separator:focus-visible {
|
|
27
|
+
outline: 2px solid var(--canopy-focus-ring);
|
|
28
|
+
outline-offset: -2px;
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
const splitLayout = (percent) => ({ [PREVIEW]: percent, [FORM]: 100 - percent });
|
|
32
|
+
/** The resizable preview/form split that fills the shell's main region. */
|
|
33
|
+
export function PaneLayout({ arrangement, sideSplitPercent = DEFAULT_SIDE_SPLIT_PERCENT, stackedSplitPercent = DEFAULT_STACKED_SPLIT_PERCENT, onSplitPercentChange, preview, form, formOverlay, formInert = false, previewMaximized = false, onSeparatorDrag, }) {
|
|
34
|
+
const groupRef = useGroupRef();
|
|
35
|
+
const separatorRef = useRef(null);
|
|
36
|
+
const previewRef = useRef(null);
|
|
37
|
+
const formRef = useRef(null);
|
|
38
|
+
const formPanelRef = useRef(null);
|
|
39
|
+
const percent = arrangement === 'side' ? sideSplitPercent : stackedSplitPercent;
|
|
40
|
+
// Stored sizes load after mount, and the group must never remount: that would reload the preview.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const group = groupRef.current;
|
|
43
|
+
if (!group)
|
|
44
|
+
return;
|
|
45
|
+
if (Math.abs((group.getLayout()[PREVIEW] ?? percent) - percent) > 0.1) {
|
|
46
|
+
group.setLayout(splitLayout(percent));
|
|
47
|
+
}
|
|
48
|
+
}, [groupRef, percent, arrangement]);
|
|
49
|
+
// React 18 has no `inert` prop, so it is set on the DOM. A layout effect, so it changes before
|
|
50
|
+
// any passive effect of the same commit tries to focus into the form or out of the overlay.
|
|
51
|
+
useLayoutEffect(() => {
|
|
52
|
+
formRef.current?.toggleAttribute('inert', formInert);
|
|
53
|
+
formPanelRef.current?.toggleAttribute('inert', previewMaximized);
|
|
54
|
+
separatorRef.current?.toggleAttribute('inert', previewMaximized);
|
|
55
|
+
}, [formInert, previewMaximized]);
|
|
56
|
+
const onSeparatorDragRef = useRef(onSeparatorDrag);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
onSeparatorDragRef.current = onSeparatorDrag;
|
|
59
|
+
}, [onSeparatorDrag]);
|
|
60
|
+
// A press alone is no drag: one that only focuses the separator must not report one.
|
|
61
|
+
const hold = useRef(null);
|
|
62
|
+
// An iframe takes the pointer moves a drag needs, so the panes ignore the pointer while the
|
|
63
|
+
// separator is held. Set on the DOM as the attribute flips, before the first move arrives.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const separator = separatorRef.current;
|
|
66
|
+
if (!separator)
|
|
67
|
+
return;
|
|
68
|
+
const observer = new MutationObserver(() => {
|
|
69
|
+
const held = separator.getAttribute('data-separator') === 'active';
|
|
70
|
+
for (const pane of [previewRef.current, formRef.current]) {
|
|
71
|
+
if (pane)
|
|
72
|
+
pane.style.pointerEvents = held ? 'none' : '';
|
|
73
|
+
}
|
|
74
|
+
if (held && !hold.current) {
|
|
75
|
+
hold.current = { start: groupRef.current?.getLayout()[PREVIEW] ?? 0, moved: false };
|
|
76
|
+
}
|
|
77
|
+
else if (!held && hold.current) {
|
|
78
|
+
if (hold.current.moved)
|
|
79
|
+
onSeparatorDragRef.current?.(false);
|
|
80
|
+
hold.current = null;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
observer.observe(separator, { attributes: true, attributeFilter: ['data-separator'] });
|
|
84
|
+
return () => observer.disconnect();
|
|
85
|
+
}, [groupRef]);
|
|
86
|
+
const side = arrangement === 'side';
|
|
87
|
+
return (_jsxs(_Fragment, { children: [_jsx("style", { children: SEPARATOR_STYLES }), _jsxs(Group, { groupRef: groupRef, orientation: side ? 'horizontal' : 'vertical', defaultLayout: splitLayout(percent),
|
|
88
|
+
// The hidden separator would otherwise still take drags over a maximized preview: the
|
|
89
|
+
// library hit-tests separators by their rectangles, which `inert` leaves in place.
|
|
90
|
+
disabled: previewMaximized, onLayoutChange: (layout) => {
|
|
91
|
+
const next = layout[PREVIEW];
|
|
92
|
+
const held = hold.current;
|
|
93
|
+
if (held && !held.moved && next !== undefined && Math.abs(next - held.start) > 0.01) {
|
|
94
|
+
held.moved = true;
|
|
95
|
+
onSeparatorDragRef.current?.(true);
|
|
96
|
+
}
|
|
97
|
+
}, onLayoutChanged: (layout, meta) => {
|
|
98
|
+
const next = layout[PREVIEW];
|
|
99
|
+
if (meta.isUserInteraction && next !== undefined && Math.abs(next - percent) > 0.01)
|
|
100
|
+
onSplitPercentChange?.(arrangement, next);
|
|
101
|
+
},
|
|
102
|
+
// The maximized preview's containing block: the panels themselves are unpositioned.
|
|
103
|
+
style: { height: '100%', width: '100%', position: 'relative' }, children: [_jsx(Panel, { id: PREVIEW, ...PREVIEW_LIMITS, children: _jsx(Box, { ref: previewRef, "data-testid": "preview-pane", "data-maximized": previewMaximized || undefined, h: "100%", w: "100%", style: previewMaximized
|
|
104
|
+
? { overflow: 'auto', position: 'absolute', inset: 0, zIndex: 1 }
|
|
105
|
+
: { overflow: 'auto' }, 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%", w: "100%",
|
|
106
|
+
// `formOverlay` stacks within this pane, so a maximized preview always covers it.
|
|
107
|
+
style: { isolation: 'isolate' }, children: [_jsx(Box, { ref: formRef, "data-testid": "form-pane", h: "100%", w: "100%", style: {
|
|
108
|
+
// FormHeader reads this to span the padding.
|
|
109
|
+
'--canopy-form-pane-padding': '20px',
|
|
110
|
+
padding: 'var(--canopy-form-pane-padding)',
|
|
111
|
+
overflow: 'auto',
|
|
112
|
+
backgroundColor: 'var(--canopy-surface-muted)',
|
|
113
|
+
// Keeps the form's sticky header under `formOverlay`.
|
|
114
|
+
isolation: 'isolate',
|
|
115
|
+
}, children: form }), formOverlay] }) })] })] }));
|
|
116
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** Docked beside the main region, or laid over the form pane only. */
|
|
3
|
+
type SidePanelMode = 'docked' | 'overlay';
|
|
4
|
+
export interface SidePanelHostProps {
|
|
5
|
+
title: string;
|
|
6
|
+
mode: SidePanelMode;
|
|
7
|
+
opened: boolean;
|
|
8
|
+
/** The stored width; a drag shows its own until it ends. */
|
|
9
|
+
width: number;
|
|
10
|
+
minWidth: number;
|
|
11
|
+
maxWidth: number;
|
|
12
|
+
/** Called when a drag ends and on each resize key. */
|
|
13
|
+
onWidthChange: (width: number) => void;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
/**
|
|
16
|
+
* Bumped by the caller each time the user asks for the panel, open or not: each new value moves
|
|
17
|
+
* focus in. Opening alone never does, as a panel also opens when stored state loads.
|
|
18
|
+
*/
|
|
19
|
+
focusRequest?: number;
|
|
20
|
+
/** Where focus goes when the panel closes with focus inside it. */
|
|
21
|
+
returnFocusTo?: () => HTMLElement | null;
|
|
22
|
+
/** Controls beside the title, before the close button. */
|
|
23
|
+
actions?: ReactNode;
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
id?: string;
|
|
26
|
+
'data-testid'?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A titled side panel, resizable from its inline-end edge. Docked, it is a column of the shell;
|
|
30
|
+
* as an overlay it covers the form pane only, so the preview stays in view, and Escape closes it.
|
|
31
|
+
* A `focusRequest` moves focus to its `data-autofocus` element, else to the panel; closing with
|
|
32
|
+
* focus inside returns focus to `returnFocusTo`.
|
|
33
|
+
*/
|
|
34
|
+
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 | null;
|
|
35
|
+
export {};
|