@gogitcms/design-system 0.16.0-next.12 → 0.16.0-next.14
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/README.md +5 -2
- package/css/tokens.css +1 -0
- package/package.json +1 -1
- package/src/__tests__/BranchMenu.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +23 -14
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +10 -6
- package/src/__tests__/ContentBrowser.expand.test.tsx +170 -0
- package/src/__tests__/ContentBrowser.forms.test.tsx +25 -20
- package/src/__tests__/ContentBrowser.listrow.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.master.test.tsx +147 -0
- package/src/__tests__/ContentBrowser.media.test.tsx +2 -1
- package/src/__tests__/ContentBrowser.references.test.tsx +3 -3
- package/src/__tests__/ContentBrowser.reorder.test.tsx +2 -3
- package/src/__tests__/ContentBrowser.window.test.tsx +203 -0
- package/src/__tests__/MediaBrowser.test.tsx +15 -9
- package/src/__tests__/ProtectedBranchModal.test.tsx +14 -0
- package/src/__tests__/cssTokens.test.ts +1 -0
- package/src/__tests__/documentDrafts.test.ts +59 -0
- package/src/components/BranchMenu.tsx +14 -4
- package/src/components/ContentBrowser.tsx +584 -460
- package/src/components/Input.tsx +37 -5
- package/src/components/MediaField.tsx +2 -2
- package/src/components/Notifications.tsx +10 -1
- package/src/components/ProjectMenu.tsx +100 -26
- package/src/components/ProtectedBranchModal.tsx +19 -7
- package/src/components/Skeleton.tsx +12 -19
- package/src/components/WorkspaceMenu.tsx +163 -0
- package/src/components/documentDrafts.ts +19 -4
- package/src/components/layout.tsx +33 -1
- package/src/icons.ts +5 -0
- package/src/index.ts +4 -1
- package/src/references.ts +1 -1
- package/src/theme.ts +3 -1
|
@@ -8,11 +8,12 @@ import { Button, IconButton } from "./Button";
|
|
|
8
8
|
import { Input } from "./Input";
|
|
9
9
|
import { Avatar, Badge, CheckBox, DiffStat, Divider, SectionLabel } from "./primitives";
|
|
10
10
|
import { NavRow } from "./NavRow";
|
|
11
|
-
import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle } from "./layout";
|
|
11
|
+
import { AppShell, MobileScreen, Pane, TopBar, ThemeToggle, type WindowChrome } from "./layout";
|
|
12
12
|
import { Segment, type SegmentItem } from "./Segment";
|
|
13
13
|
import { readDraft, writeDraft, clearDraft, subscribeDrafts, hasDraft, draftCount } from "./documentDrafts";
|
|
14
14
|
import { BranchMenu, type BranchRef } from "./BranchMenu";
|
|
15
|
-
import { ProjectMenu, type ProjectRef } from "./ProjectMenu";
|
|
15
|
+
import { ProjectMenu, type ProjectMenuAction, type ProjectRef } from "./ProjectMenu";
|
|
16
|
+
import { WorkspaceMenu, type WorkspaceMenuProps } from "./WorkspaceMenu";
|
|
16
17
|
import { ChangeDetail, type DocumentChange, type FieldConflict, type ConflictChoice } from "./ChangeDetail";
|
|
17
18
|
import { ApplyChangesModal, type MergeProgress } from "./ApplyChangesModal";
|
|
18
19
|
import { ProtectedBranchModal } from "./ProtectedBranchModal";
|
|
@@ -39,7 +40,7 @@ import { referenceValue } from "../references";
|
|
|
39
40
|
import { MediaBrowser } from "./MediaBrowser";
|
|
40
41
|
import { FormsBrowser } from "./FormsBrowser";
|
|
41
42
|
import { FORMS_NAV_KEY, formsNavKey, isFormsNavKey, parseFormsNavKey, type FormsApi } from "../forms";
|
|
42
|
-
import { type HistoryApi } from "../history";
|
|
43
|
+
import { relativeTime, type HistoryApi } from "../history";
|
|
43
44
|
import { DocumentHistory } from "./DocumentHistory";
|
|
44
45
|
import {
|
|
45
46
|
MEDIA_NAV_KEY,
|
|
@@ -53,7 +54,19 @@ import {
|
|
|
53
54
|
} from "../media";
|
|
54
55
|
import type { IconName } from "../icons";
|
|
55
56
|
|
|
56
|
-
export type CmsNavItem = {
|
|
57
|
+
export type CmsNavItem = {
|
|
58
|
+
key: string;
|
|
59
|
+
label: string;
|
|
60
|
+
icon: IconName;
|
|
61
|
+
count?: number;
|
|
62
|
+
conflicts?: number;
|
|
63
|
+
// The item has no list of its own: selecting it opens the thing itself in the
|
|
64
|
+
// detail region — a singleton document, a plugin screen — rather than pushing
|
|
65
|
+
// a list into the master pane. The sections stay on screen with its row lit,
|
|
66
|
+
// and the pushed list's title menu leaves it out, since there is no list to
|
|
67
|
+
// switch to. Omitted for a collection, Media and Forms, which all list.
|
|
68
|
+
direct?: boolean;
|
|
69
|
+
};
|
|
57
70
|
// An entry in the user (avatar) menu: host settings screens and
|
|
58
71
|
// plugin-contributed links. When any are present the avatar becomes a dropdown.
|
|
59
72
|
export type UserMenuItem = { label: string; icon?: IconName; onSelect: () => void; testID?: string };
|
|
@@ -74,21 +87,26 @@ export type EntryField = {
|
|
|
74
87
|
source?: string;
|
|
75
88
|
format?: string;
|
|
76
89
|
flavor?: string;
|
|
77
|
-
// For a media picker (`component: "media"
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
// field
|
|
90
|
+
// For a media picker (`component: "media"`, or an array whose items are —
|
|
91
|
+
// `itemComponent: "media"`): which configured media set to browse, and —
|
|
92
|
+
// for a string value — which form of the path to write. Both come from the
|
|
93
|
+
// field's `media:` block; on an array they describe its *items*.
|
|
81
94
|
media?: string;
|
|
82
95
|
storeAs?: string;
|
|
83
96
|
// For a document picker (`component: "reference"`, or an array whose items
|
|
84
|
-
// are
|
|
85
|
-
// reference
|
|
86
|
-
// embedded copies —
|
|
87
|
-
//
|
|
97
|
+
// are — `itemComponent: "reference"`): what the field points at and stores.
|
|
98
|
+
// An object-shaped reference declares its `fields` — the key child and the
|
|
99
|
+
// embedded copies — and the copies are the server's: recomputed on every
|
|
100
|
+
// save and shown read-only here (docs/references.md).
|
|
88
101
|
reference?: ReferenceDef;
|
|
89
102
|
value: unknown;
|
|
90
103
|
// Item type for an array field (string | number | boolean | object).
|
|
91
104
|
of?: string;
|
|
105
|
+
// The control each item of an array renders, from display.item.component.
|
|
106
|
+
// `list` describes the array; what an item is — a text input, a media
|
|
107
|
+
// picker, a document picker — is declared here, never inferred from the
|
|
108
|
+
// array's media or reference block. Absent means the default for `of`.
|
|
109
|
+
itemComponent?: string;
|
|
92
110
|
// Child fields for an object field (or an array whose `of` is object).
|
|
93
111
|
fields?: EntryField[];
|
|
94
112
|
// Object shapes a mixed-list array (component: "mixedList") can hold.
|
|
@@ -206,6 +224,12 @@ export type CmsEntry = {
|
|
|
206
224
|
conflicted?: boolean;
|
|
207
225
|
title: string;
|
|
208
226
|
body: string;
|
|
227
|
+
// When this document last changed, as an ISO 8601 string. The list row shows
|
|
228
|
+
// its age in place of the file path — a document is its title and when it was
|
|
229
|
+
// last touched, and the path is one click away in the editor's Details. Hosts
|
|
230
|
+
// that have no such timestamp (the changes surface, whose rows are diffs of
|
|
231
|
+
// files) leave it off and keep the path.
|
|
232
|
+
updatedAt?: string;
|
|
209
233
|
// When present, the detail pane renders a field editor instead of title/body.
|
|
210
234
|
fields?: EntryField[];
|
|
211
235
|
// A list entry whose full document (fields/body) hasn't loaded yet — the detail
|
|
@@ -302,14 +326,15 @@ export type ContentBrowserProps = {
|
|
|
302
326
|
// Items shown in the avatar dropdown above Sign out. Absent/empty keeps the
|
|
303
327
|
// pre-menu topbar (sign-out icon + static avatar) byte-identical.
|
|
304
328
|
userMenuItems?: UserMenuItem[];
|
|
305
|
-
// When set, the
|
|
306
|
-
// desktop
|
|
307
|
-
//
|
|
329
|
+
// When set, the detail region is replaced by this node (plugin screens): on
|
|
330
|
+
// desktop it becomes one flexing pane beside the master pane, which stays on
|
|
331
|
+
// the sections with the plugin's row lit; on mobile it renders as its own
|
|
332
|
+
// screen titled `contentTitle`. The topbar stays.
|
|
308
333
|
contentSlot?: React.ReactNode;
|
|
309
334
|
contentTitle?: string;
|
|
310
335
|
// Providing entriesEmpty switches on "collection browsing" mode: nothing is
|
|
311
|
-
// auto-selected,
|
|
312
|
-
// unselected document shows "Select {collection}".
|
|
336
|
+
// auto-selected, the detail region shows this message while no collection
|
|
337
|
+
// is selected, and an unselected document shows "Select {collection}".
|
|
313
338
|
entriesEmpty?: string;
|
|
314
339
|
|
|
315
340
|
// Controlled document selection. When onSelectEntry is provided the component
|
|
@@ -496,9 +521,33 @@ export type ContentBrowserProps = {
|
|
|
496
521
|
onSelectProject?: (project: ProjectRef) => void;
|
|
497
522
|
/** cms.config.js pins this deployment to one project: shown, but inert. */
|
|
498
523
|
projectLocked?: boolean;
|
|
524
|
+
|
|
525
|
+
// ---- an app window (all optional; the web and mobile apps pass none) -----
|
|
526
|
+
// The desktop app is a window per project, moving between workspaces rather
|
|
527
|
+
// than being addressed to one by a URL. These are what that needs of the
|
|
528
|
+
// desktop layout's top bar; the mobile layout ignores them.
|
|
529
|
+
/**
|
|
530
|
+
* The workspace switcher, in place of the static workspace tag: every
|
|
531
|
+
* workspace the user belongs to, and what choosing one does.
|
|
532
|
+
*/
|
|
533
|
+
workspaceMenu?: WorkspaceMenuProps;
|
|
534
|
+
/** Offers each project in the project menu in a window of its own. */
|
|
535
|
+
onOpenProjectInNewWindow?: (project: ProjectRef) => void;
|
|
536
|
+
/** Rows under the project menu's list. */
|
|
537
|
+
projectMenuActions?: ProjectMenuAction[];
|
|
538
|
+
/**
|
|
539
|
+
* Room for the platform's window controls, and the top bar marked as the
|
|
540
|
+
* window's drag region, for a host drawing its own window chrome (the
|
|
541
|
+
* desktop app's chromeless macOS window).
|
|
542
|
+
*/
|
|
543
|
+
windowChrome?: WindowChrome;
|
|
499
544
|
// Omitted → no "New branch" row. Rejecting a name should throw so the inline
|
|
500
545
|
// form can show why.
|
|
501
546
|
onCreateBranch?: (name: string) => Promise<void> | void;
|
|
547
|
+
// The repository's branch prefix ("cms/"). Every branch the editor creates —
|
|
548
|
+
// from the branch menu or the protected-branch prompt — is named under it, so
|
|
549
|
+
// both forms show it read-only ahead of the name and hand back the full name.
|
|
550
|
+
branchPrefix?: string;
|
|
502
551
|
|
|
503
552
|
// The comparison target on the changes surface. When set, the top bar shows a
|
|
504
553
|
// `source → target` pair instead of a single branch menu — the source is the
|
|
@@ -634,6 +683,11 @@ function EntryListItem({ entry }: { entry: CmsEntry }) {
|
|
|
634
683
|
// what git thinks of the file: a document can be a local draft, or committed
|
|
635
684
|
// and modified on the branch, or both, and they are not the same state.
|
|
636
685
|
const draft = useHasDraft(entry.id);
|
|
686
|
+
// "Updated 3d ago": the age alone reads as a bare number against the title,
|
|
687
|
+
// with nothing saying what happened then. An unparseable timestamp renders as
|
|
688
|
+
// "" — fall back to the path rather than leave the row's second line blank.
|
|
689
|
+
const age = entry.updatedAt ? relativeTime(entry.updatedAt) : "";
|
|
690
|
+
const updated = age ? `Updated ${age}` : "";
|
|
637
691
|
return (
|
|
638
692
|
<ListRow conflicted={entry.conflicted}>
|
|
639
693
|
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
|
|
@@ -652,8 +706,8 @@ function EntryListItem({ entry }: { entry: CmsEntry }) {
|
|
|
652
706
|
{entry.status}
|
|
653
707
|
</Text>
|
|
654
708
|
) : null}
|
|
655
|
-
<Text variant="monoSm" color="tertiary" numberOfLines={1}>
|
|
656
|
-
{entry.path}
|
|
709
|
+
<Text variant="monoSm" color="tertiary" numberOfLines={1} testID="entry-subtitle">
|
|
710
|
+
{updated || entry.path}
|
|
657
711
|
</Text>
|
|
658
712
|
</View>
|
|
659
713
|
</ListRow>
|
|
@@ -1715,8 +1769,10 @@ function ListControl({
|
|
|
1715
1769
|
// what was picked: the only thing such an item can hold is a picked
|
|
1716
1770
|
// document, so an empty slot to fill in afterwards is a step nobody wants.
|
|
1717
1771
|
// Without a host seam there is nothing to pick from, and the button is
|
|
1718
|
-
// disabled as the field's own Choose button is.
|
|
1719
|
-
|
|
1772
|
+
// disabled as the field's own Choose button is. "Reference list" is the
|
|
1773
|
+
// item's declared control, not the presence of a block: an array whose
|
|
1774
|
+
// items are not declared pickers is a plain list whatever else it carries.
|
|
1775
|
+
const reference = field.itemComponent === "reference" ? field.reference : undefined;
|
|
1720
1776
|
const referenceApi = useReferenceApi();
|
|
1721
1777
|
const [pickingNew, setPickingNew] = React.useState(false);
|
|
1722
1778
|
const addItem = () => {
|
|
@@ -1732,20 +1788,21 @@ function ListControl({
|
|
|
1732
1788
|
onChange([...items, referenceValue(target, isObject ? "object" : "string", reference, undefined)]);
|
|
1733
1789
|
};
|
|
1734
1790
|
|
|
1735
|
-
// Items
|
|
1736
|
-
// component — `list` describes the array, and is not a
|
|
1737
|
-
//
|
|
1738
|
-
//
|
|
1791
|
+
// Items render the array's *declared* item control (display.item.component),
|
|
1792
|
+
// not the array's own component — `list` describes the array, and is not a
|
|
1793
|
+
// control an item could render. The media and reference blocks describe the
|
|
1794
|
+
// items too, so they travel with the item; they never pick its control. An
|
|
1795
|
+
// array that declares nothing falls back to the default control for `of`.
|
|
1739
1796
|
const itemField: EntryField = {
|
|
1740
1797
|
name: field.name,
|
|
1741
1798
|
type: itemType,
|
|
1742
1799
|
of: undefined,
|
|
1743
1800
|
fields: field.fields,
|
|
1744
1801
|
value: null,
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1802
|
+
component: field.itemComponent,
|
|
1803
|
+
media: field.media,
|
|
1804
|
+
storeAs: field.storeAs,
|
|
1805
|
+
reference: field.reference,
|
|
1749
1806
|
};
|
|
1750
1807
|
|
|
1751
1808
|
// The list's own path, from the FieldControl wrapping this control; an item
|
|
@@ -2776,7 +2833,8 @@ function EntryDetail({
|
|
|
2776
2833
|
history,
|
|
2777
2834
|
active,
|
|
2778
2835
|
onActivate,
|
|
2779
|
-
|
|
2836
|
+
expanded,
|
|
2837
|
+
onToggleExpand,
|
|
2780
2838
|
onClose,
|
|
2781
2839
|
dragHandle,
|
|
2782
2840
|
}: {
|
|
@@ -2805,11 +2863,13 @@ function EntryDetail({
|
|
|
2805
2863
|
// the header renders exactly as it did before history existed.
|
|
2806
2864
|
history?: HistoryApi;
|
|
2807
2865
|
// Desktop columns: `active` marks the focused column (accent + header press
|
|
2808
|
-
// activates it via onActivate);
|
|
2809
|
-
// controls
|
|
2866
|
+
// activates it via onActivate); onToggleExpand/onClose add the header
|
|
2867
|
+
// expand/close controls, and `expanded` says which way the expand control
|
|
2868
|
+
// points. All omitted on mobile → unchanged single-detail behavior.
|
|
2810
2869
|
active?: boolean;
|
|
2811
2870
|
onActivate?: () => void;
|
|
2812
|
-
|
|
2871
|
+
expanded?: boolean;
|
|
2872
|
+
onToggleExpand?: () => void;
|
|
2813
2873
|
onClose?: () => void;
|
|
2814
2874
|
// A drag grip rendered at the head of the breadcrumb row to reorder columns.
|
|
2815
2875
|
dragHandle?: React.ReactNode;
|
|
@@ -3299,8 +3359,15 @@ function EntryDetail({
|
|
|
3299
3359
|
onMove={canMove ? () => setPrompt("move") : undefined}
|
|
3300
3360
|
onDelete={canDelete && deleteEntry ? deleteEntry : undefined}
|
|
3301
3361
|
/>
|
|
3302
|
-
{
|
|
3303
|
-
<IconButton
|
|
3362
|
+
{onToggleExpand ? (
|
|
3363
|
+
<IconButton
|
|
3364
|
+
name={expanded ? "minimize" : "maximize"}
|
|
3365
|
+
size="sm"
|
|
3366
|
+
active={expanded}
|
|
3367
|
+
label={expanded ? "Collapse column" : "Expand column"}
|
|
3368
|
+
onPress={onToggleExpand}
|
|
3369
|
+
testID="column-expand"
|
|
3370
|
+
/>
|
|
3304
3371
|
) : null}
|
|
3305
3372
|
{onClose ? (
|
|
3306
3373
|
<IconButton name="x" size="sm" label="Close column" onPress={onClose} testID="column-close" />
|
|
@@ -3496,6 +3563,12 @@ const MIN_COL = 480;
|
|
|
3496
3563
|
// a burst of keystrokes is one update rather than twenty.
|
|
3497
3564
|
const DRAFT_DEBOUNCE_MS = 150;
|
|
3498
3565
|
|
|
3566
|
+
// Which pane, if any, has taken over the desktop layout: the master pane (the
|
|
3567
|
+
// sections or the list pushed into it), or a single document column. Expanding
|
|
3568
|
+
// is exclusive — one pane at a time, and everything else stops rendering — so
|
|
3569
|
+
// this is a single value rather than a flag per pane.
|
|
3570
|
+
type ExpandedPane = { kind: "master" } | { kind: "column"; id: string } | null;
|
|
3571
|
+
|
|
3499
3572
|
// A normalized desktop column, unifying the host-controlled (openTabs) and
|
|
3500
3573
|
// self-owned (openIds) sources so the render + sizing paths stay single. `entry`
|
|
3501
3574
|
// is undefined while a controlled tab's document loads.
|
|
@@ -4000,6 +4073,7 @@ function ModalOverlays(props: ContentBrowserProps) {
|
|
|
4000
4073
|
error={props.protectedBranchError}
|
|
4001
4074
|
onCreate={props.onCreateBranchAndSave ?? (() => {})}
|
|
4002
4075
|
onCancel={props.onCancelProtectedSave ?? (() => {})}
|
|
4076
|
+
prefix={props.branchPrefix}
|
|
4003
4077
|
/>
|
|
4004
4078
|
) : null}
|
|
4005
4079
|
</>
|
|
@@ -4066,20 +4140,40 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4066
4140
|
|
|
4067
4141
|
// ---- open document columns (ephemeral desktop state) --------------------
|
|
4068
4142
|
// openIds are the documents shown as side-by-side columns, in display order.
|
|
4069
|
-
//
|
|
4070
|
-
//
|
|
4143
|
+
// They reset when the collection changes (open columns belong to the active
|
|
4144
|
+
// collection).
|
|
4071
4145
|
const [openIds, setOpenIds] = useState<string[]>([]);
|
|
4072
|
-
|
|
4073
|
-
|
|
4146
|
+
// The one pane that has taken over the screen, if any: the master pane, or a
|
|
4147
|
+
// single document column. While it holds, nothing else renders, and the
|
|
4148
|
+
// control that expanded it puts the layout back.
|
|
4149
|
+
const [expanded, setExpanded] = useState<ExpandedPane>(null);
|
|
4074
4150
|
const [region, setRegion] = useState({ w: 0, h: 0 });
|
|
4075
|
-
//
|
|
4076
|
-
//
|
|
4077
|
-
const [
|
|
4078
|
-
|
|
4151
|
+
// The master pane's width. Seeded from the layout default; dragging the seam
|
|
4152
|
+
// beside it updates it live (clamped). Local, ephemeral view state.
|
|
4153
|
+
const [masterW, setMasterW] = useState(t.layout.master);
|
|
4154
|
+
// ---- master / detail ----------------------------------------------------
|
|
4155
|
+
// One pane plays the sidebar and the content list in turn. At rest it lists
|
|
4156
|
+
// the sections; selecting an item that HAS a list pushes that list in, headed
|
|
4157
|
+
// by a Back control and a title that doubles as a switcher. A direct item — a
|
|
4158
|
+
// singleton, a plugin screen — has no list: it opens in the detail region and
|
|
4159
|
+
// the sections stay put with its row lit.
|
|
4160
|
+
const activeItem = navItemFor(sections, activeNavKey);
|
|
4161
|
+
// Media and Forms are named even when the host supplied no row for them
|
|
4162
|
+
// (a media key with no sets yet, a forms key on a host that lists forms
|
|
4163
|
+
// some other way): the pushed list still needs a title.
|
|
4164
|
+
const activeLabel = activeItem?.label ?? (mediaShowing ? "Media" : formsShowing ? "Forms" : "Content");
|
|
4165
|
+
const pushed = !!activeNavKey && !activeItem?.direct && props.contentSlot == null;
|
|
4079
4166
|
// The self-owned column set (openIds) is only used in non-tabbed mode; in tabbed
|
|
4080
|
-
// mode the host owns it, so
|
|
4081
|
-
//
|
|
4082
|
-
useEffect(() => { if (!tabbed)
|
|
4167
|
+
// mode the host owns it, so this maintenance effect no-ops. `expanded` stays
|
|
4168
|
+
// local (pure view state) in both modes.
|
|
4169
|
+
useEffect(() => { if (!tabbed) setOpenIds([]); }, [activeNavKey, tabbed]);
|
|
4170
|
+
// Anything that opens in the detail region puts a full-screen master back:
|
|
4171
|
+
// the thing just opened would otherwise be invisible. Called from every
|
|
4172
|
+
// control in the master that does so (a list row, a media set, a form), and
|
|
4173
|
+
// from the effect below for a direct item, which the host resolves.
|
|
4174
|
+
const revealDetail = () => setExpanded((x) => (x?.kind === "master" ? null : x));
|
|
4175
|
+
const directActive = !!activeNavKey && !pushed;
|
|
4176
|
+
useEffect(() => { if (directActive) revealDetail(); }, [directActive, activeNavKey]);
|
|
4083
4177
|
// Clear the selection when the collection changes so the detail shows its empty
|
|
4084
4178
|
// state. In controlled mode the host owns this (the URL changes with the nav).
|
|
4085
4179
|
useEffect(() => {
|
|
@@ -4109,8 +4203,9 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4109
4203
|
// RNW surfaces the click modifiers on nativeEvent (typed without them here).
|
|
4110
4204
|
const mods = ev?.nativeEvent as unknown as { shiftKey?: boolean; metaKey?: boolean } | undefined;
|
|
4111
4205
|
const newColumn = !!(mods?.shiftKey || mods?.metaKey);
|
|
4112
|
-
//
|
|
4113
|
-
|
|
4206
|
+
// Opening a document from a full-screen list restores the layout: the column
|
|
4207
|
+
// it opens in lives in the region that list is covering.
|
|
4208
|
+
revealDetail();
|
|
4114
4209
|
// Tabbed mode: hand the intent to the host (it owns the open-tab set).
|
|
4115
4210
|
if (tabbed) { props.onOpenEntry?.(e, { newColumn }); return; }
|
|
4116
4211
|
// Plain click on a not-yet-open document: swap it into the active column's
|
|
@@ -4129,12 +4224,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4129
4224
|
};
|
|
4130
4225
|
// Closing a column drops it; if it was active, activate a neighbor (or clear).
|
|
4131
4226
|
const closeColumn = (id: string) => {
|
|
4132
|
-
|
|
4133
|
-
if (!(id in c)) return c;
|
|
4134
|
-
const cp = { ...c };
|
|
4135
|
-
delete cp[id];
|
|
4136
|
-
return cp;
|
|
4137
|
-
});
|
|
4227
|
+
setExpanded((x) => (x?.kind === "column" && x.id === id ? null : x));
|
|
4138
4228
|
if (tabbed) { props.onCloseTab?.(id); return; }
|
|
4139
4229
|
const idx = openIds.indexOf(id);
|
|
4140
4230
|
const next = openIds.filter((x) => x !== id);
|
|
@@ -4149,8 +4239,11 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4149
4239
|
const { checked, toggle, clear, checkedIds, checkedFolders, toggleFolder, checkedFolderKeys } = useMultiSelect(activeNavKey);
|
|
4150
4240
|
const [movePrompt, setMovePrompt] = useState(false);
|
|
4151
4241
|
const selected = entries.find((e) => e.id === selectedId) ?? (controlled || browsing ? null : entries[0] ?? null);
|
|
4152
|
-
const activeLabel = sections.flatMap((s) => s.items).find((i) => i.key === activeNavKey)?.label ?? "Content";
|
|
4153
4242
|
const showCreate = !!(creating && createFields && onSubmitCreate);
|
|
4243
|
+
// The create form opens as a column in the detail region, which an expanded
|
|
4244
|
+
// pane either hides (the list) or crowds out (a column). Starting a create
|
|
4245
|
+
// puts the layout back rather than opening a form nobody can see.
|
|
4246
|
+
useEffect(() => { if (showCreate) setExpanded(null); }, [showCreate]);
|
|
4154
4247
|
|
|
4155
4248
|
// Folder drill-down for hierarchical collections: navigate the folder tree and
|
|
4156
4249
|
// show only the folders + documents at the current level.
|
|
@@ -4212,16 +4305,22 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4212
4305
|
canMove: !!onMove,
|
|
4213
4306
|
collectionPath,
|
|
4214
4307
|
}));
|
|
4215
|
-
//
|
|
4216
|
-
//
|
|
4217
|
-
//
|
|
4218
|
-
const
|
|
4219
|
-
const
|
|
4220
|
-
|
|
4308
|
+
// Which pane is expanded, resolved against what is actually open: a column
|
|
4309
|
+
// that has since closed (or that belonged to another collection) falls back to
|
|
4310
|
+
// the normal layout rather than blanking the screen.
|
|
4311
|
+
const expandedCol = expanded?.kind === "column" ? columns.find((c) => c.id === expanded.id) ?? null : null;
|
|
4312
|
+
const masterExpanded = expanded?.kind === "master";
|
|
4313
|
+
// An expanded column is the only one on screen. The others stay open (they
|
|
4314
|
+
// keep their place in openIds/openTabs, and their unsaved edits) — they are
|
|
4315
|
+
// just not rendered until it collapses.
|
|
4316
|
+
const shownColumns = expandedCol ? [expandedCol] : columns;
|
|
4317
|
+
// Size the columns: a lone column fills the region; several share it down to
|
|
4318
|
+
// MIN_COL, past which the region scrolls horizontally.
|
|
4319
|
+
const colCount = shownColumns.length + (showCreate ? 1 : 0);
|
|
4221
4320
|
let colW = MIN_COL;
|
|
4222
|
-
if (region.w > 0 &&
|
|
4223
|
-
const per =
|
|
4224
|
-
colW = per >= MIN_COL ? Math.floor(per) :
|
|
4321
|
+
if (region.w > 0 && colCount > 0) {
|
|
4322
|
+
const per = region.w / colCount;
|
|
4323
|
+
colW = per >= MIN_COL ? Math.floor(per) : colCount <= 1 ? Math.max(1, Math.floor(region.w)) : MIN_COL;
|
|
4225
4324
|
}
|
|
4226
4325
|
const hasColumns = columns.length > 0 || showCreate;
|
|
4227
4326
|
|
|
@@ -4231,8 +4330,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4231
4330
|
// origin plus the scroll offset), then `slotAtX` picks the gap it's nearest.
|
|
4232
4331
|
// Reorder applies to the host (onReorderTabs) in tabbed mode or the local
|
|
4233
4332
|
// openIds otherwise. Web-only affordance; the native shells never mount it.
|
|
4234
|
-
|
|
4235
|
-
|
|
4333
|
+
// Reorder operates on the columns on screen, so an expanded column — the only
|
|
4334
|
+
// one rendered — has nothing to trade places with and grows no grip.
|
|
4335
|
+
const colIds = shownColumns.map((c) => c.id);
|
|
4336
|
+
const colWidths = shownColumns.map(() => colW);
|
|
4236
4337
|
const idsRef = useRef(colIds);
|
|
4237
4338
|
idsRef.current = colIds;
|
|
4238
4339
|
const widthsRef = useRef(colWidths);
|
|
@@ -4303,34 +4404,64 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4303
4404
|
<View key={key} style={{ width: 2, alignSelf: "stretch", backgroundColor: t.color.borderActive }} />
|
|
4304
4405
|
);
|
|
4305
4406
|
|
|
4306
|
-
const listLabel = browsing && !activeNavKey ? "Content" : activeLabel;
|
|
4307
4407
|
const createBtn = canCreate && onStartCreate && activeNavKey ? (
|
|
4308
4408
|
<IconButton name="plus" size="sm" label="Create" onPress={onStartCreate} testID="create-start" />
|
|
4309
4409
|
) : null;
|
|
4310
4410
|
|
|
4311
4411
|
// Collection search + faceted filters. `search` is enabled for a real (non
|
|
4312
|
-
// singleton) active collection; the icons/inputs render into the
|
|
4412
|
+
// singleton) active collection; the icons/inputs render into the master header.
|
|
4313
4413
|
const search = useCollectionSearch(props, activeNavKey);
|
|
4314
4414
|
const searchEnabled = !!props.searchable && !!activeNavKey;
|
|
4315
4415
|
const facetFields = props.facetFields ?? [];
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
)
|
|
4416
|
+
|
|
4417
|
+
// The master pane's header. At rest it names the surface; with a list pushed
|
|
4418
|
+
// in it carries Back, the list's title (a switcher when there is more than one
|
|
4419
|
+
// list), and whatever controls that list has. The expand control is there in
|
|
4420
|
+
// both states, exactly as a column's is.
|
|
4421
|
+
const expandBtn = (
|
|
4422
|
+
<IconButton
|
|
4423
|
+
name={masterExpanded ? "minimize" : "maximize"}
|
|
4424
|
+
size="sm"
|
|
4425
|
+
active={masterExpanded}
|
|
4426
|
+
label={masterExpanded ? "Collapse pane" : "Expand pane"}
|
|
4427
|
+
onPress={() => setExpanded(masterExpanded ? null : { kind: "master" })}
|
|
4428
|
+
testID="master-expand"
|
|
4429
|
+
/>
|
|
4430
|
+
);
|
|
4431
|
+
const masterHeader = (extras?: React.ReactNode) => (
|
|
4320
4432
|
<>
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
<>
|
|
4324
|
-
<IconButton name="search" size="sm" label="Search" onPress={search.openSearch} testID="search-open" />
|
|
4325
|
-
{facetFields.length > 0 ? (
|
|
4326
|
-
<IconButton name="filter" size="sm" label="Filter" onPress={search.openAdvanced} testID="filter-open" />
|
|
4327
|
-
) : null}
|
|
4328
|
-
</>
|
|
4433
|
+
{pushed ? (
|
|
4434
|
+
<IconButton name="chevronLeft" size="sm" label="Back to sections" onPress={() => onSelectNav("")} testID="master-back" />
|
|
4329
4435
|
) : null}
|
|
4330
|
-
{
|
|
4331
|
-
|
|
4436
|
+
{pushed ? (
|
|
4437
|
+
<MasterTitle label={activeLabel} sections={sections} activeNavKey={activeNavKey} onSelect={onSelectNav} />
|
|
4438
|
+
) : (
|
|
4439
|
+
<Text variant="h3" weight="semibold" style={{ flex: 1 }} testID="master-title">
|
|
4440
|
+
{(props.surface ?? "edit") === "changes" ? "Changes" : "Content"}
|
|
4441
|
+
</Text>
|
|
4442
|
+
)}
|
|
4443
|
+
{extras}
|
|
4444
|
+
{expandBtn}
|
|
4332
4445
|
</>
|
|
4333
4446
|
);
|
|
4447
|
+
// A collection's header swaps to a search input while a search is open.
|
|
4448
|
+
const collectionHeader = search.open ? (
|
|
4449
|
+
<SearchHeaderBar search={search} showFilter={facetFields.length > 0} />
|
|
4450
|
+
) : (
|
|
4451
|
+
masterHeader(
|
|
4452
|
+
<>
|
|
4453
|
+
{searchEnabled ? (
|
|
4454
|
+
<>
|
|
4455
|
+
<IconButton name="search" size="sm" label="Search" onPress={search.openSearch} testID="search-open" />
|
|
4456
|
+
{facetFields.length > 0 ? (
|
|
4457
|
+
<IconButton name="filter" size="sm" label="Filter" onPress={search.openAdvanced} testID="filter-open" />
|
|
4458
|
+
) : null}
|
|
4459
|
+
</>
|
|
4460
|
+
) : null}
|
|
4461
|
+
{createBtn}
|
|
4462
|
+
</>,
|
|
4463
|
+
)
|
|
4464
|
+
);
|
|
4334
4465
|
|
|
4335
4466
|
// Activate a column: hand off to the host in tabbed mode (it switches the active
|
|
4336
4467
|
// collection + URL to the tab); otherwise move the local selection.
|
|
@@ -4365,7 +4496,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4365
4496
|
title={col.title || "Untitled"}
|
|
4366
4497
|
active={!showCreate && col.active}
|
|
4367
4498
|
onActivate={() => activateColumn(col)}
|
|
4368
|
-
|
|
4499
|
+
expanded={!!expandedCol}
|
|
4500
|
+
onToggleExpand={() => setExpanded(expandedCol ? null : { kind: "column", id: col.id })}
|
|
4369
4501
|
onClose={() => closeColumn(col.id)}
|
|
4370
4502
|
dragHandle={columnGrip(col.id)}
|
|
4371
4503
|
>
|
|
@@ -4392,7 +4524,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4392
4524
|
folderOptions={moveFolders}
|
|
4393
4525
|
active={!showCreate && col.active}
|
|
4394
4526
|
onActivate={() => activateColumn(col)}
|
|
4395
|
-
|
|
4527
|
+
expanded={!!expandedCol}
|
|
4528
|
+
onToggleExpand={() => setExpanded(expandedCol ? null : { kind: "column", id: col.id })}
|
|
4396
4529
|
onClose={() => closeColumn(col.id)}
|
|
4397
4530
|
dragHandle={columnGrip(col.id)}
|
|
4398
4531
|
/>
|
|
@@ -4406,9 +4539,10 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4406
4539
|
|
|
4407
4540
|
const topBar = (
|
|
4408
4541
|
<TopBar
|
|
4542
|
+
windowChrome={props.windowChrome}
|
|
4409
4543
|
left={
|
|
4410
4544
|
<>
|
|
4411
|
-
<WorkspaceTag workspace={workspace} />
|
|
4545
|
+
{props.workspaceMenu ? <WorkspaceMenu {...props.workspaceMenu} /> : <WorkspaceTag workspace={workspace} />}
|
|
4412
4546
|
{/* Project before branch: the coarser choice reads first, matching the
|
|
4413
4547
|
URL's own order (projects/{name}/branches/{id}). */}
|
|
4414
4548
|
<ProjectMenu
|
|
@@ -4416,6 +4550,8 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4416
4550
|
projects={props.projects}
|
|
4417
4551
|
onSelect={props.onSelectProject}
|
|
4418
4552
|
locked={props.projectLocked}
|
|
4553
|
+
onOpenInNewWindow={props.onOpenProjectInNewWindow}
|
|
4554
|
+
actions={props.projectMenuActions}
|
|
4419
4555
|
/>
|
|
4420
4556
|
<BranchMenu
|
|
4421
4557
|
current={workspace.branch}
|
|
@@ -4423,6 +4559,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4423
4559
|
defaultBranch={props.defaultBranch}
|
|
4424
4560
|
onSelect={props.onSelectBranch}
|
|
4425
4561
|
onCreate={props.onCreateBranch}
|
|
4562
|
+
prefix={props.onCreateBranch ? props.branchPrefix : undefined}
|
|
4426
4563
|
/>
|
|
4427
4564
|
{/* On the changes surface the top bar reads `source → target`: the
|
|
4428
4565
|
branch above is the source under review, this is what it's compared
|
|
@@ -4480,8 +4617,11 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4480
4617
|
/>
|
|
4481
4618
|
);
|
|
4482
4619
|
|
|
4483
|
-
|
|
4484
|
-
|
|
4620
|
+
// The sections list — the master pane at rest. Plain rows; a row with a list
|
|
4621
|
+
// pushes it in (through the host's nav key), a direct row opens its thing in
|
|
4622
|
+
// the detail region and stays lit here.
|
|
4623
|
+
const sectionsList = (
|
|
4624
|
+
<ScrollView testID="nav-list" contentContainerStyle={{ paddingVertical: t.space(2) }}>
|
|
4485
4625
|
{sections.map((section, i) => (
|
|
4486
4626
|
<View key={section.title || `section-${i}`}>
|
|
4487
4627
|
{/* An untitled section is a standalone button (Media), not a group. */}
|
|
@@ -4501,7 +4641,7 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4501
4641
|
))}
|
|
4502
4642
|
</View>
|
|
4503
4643
|
))}
|
|
4504
|
-
</
|
|
4644
|
+
</ScrollView>
|
|
4505
4645
|
);
|
|
4506
4646
|
|
|
4507
4647
|
// A full-width read-only notice (branch frozen in a change request) below the
|
|
@@ -4511,274 +4651,206 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4511
4651
|
);
|
|
4512
4652
|
const modalOverlays = <ModalOverlays {...props} />;
|
|
4513
4653
|
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
const changeCount = entries.length;
|
|
4521
|
-
return (
|
|
4522
|
-
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
4523
|
-
{navPane}
|
|
4524
|
-
<ResizeHandle width={navW} min={180} max={420} onChange={setNavW} testID="resize-nav" />
|
|
4525
|
-
|
|
4526
|
-
<Pane
|
|
4527
|
-
width={listW}
|
|
4528
|
-
testID="pane-changes"
|
|
4529
|
-
scroll={false}
|
|
4530
|
-
header={<PaneTitle title={activeNavKey ? activeLabel : "Changes"} />}
|
|
4531
|
-
>
|
|
4532
|
-
{!activeNavKey ? (
|
|
4533
|
-
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4534
|
-
<Text variant="body" color="tertiary" testID="changes-empty">
|
|
4535
|
-
{entriesEmpty ?? "Select a collection"}
|
|
4536
|
-
</Text>
|
|
4537
|
-
</View>
|
|
4538
|
-
) : (
|
|
4539
|
-
<EntriesList
|
|
4540
|
-
variant="desktop"
|
|
4541
|
-
entries={entries}
|
|
4542
|
-
selectedId={selectedId}
|
|
4543
|
-
onOpenEntry={(entry) => activate(entry)}
|
|
4544
|
-
header={
|
|
4545
|
-
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }}>
|
|
4546
|
-
<Text variant="label" color="tertiary">
|
|
4547
|
-
{`${changeCount} changed`}
|
|
4548
|
-
</Text>
|
|
4549
|
-
</View>
|
|
4550
|
-
}
|
|
4551
|
-
folders={[]}
|
|
4552
|
-
onFolderPress={() => {}}
|
|
4553
|
-
/>
|
|
4554
|
-
)}
|
|
4555
|
-
</Pane>
|
|
4556
|
-
<ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-list" />
|
|
4654
|
+
const changes = (props.surface ?? "edit") === "changes";
|
|
4655
|
+
const emptyDetail = (message: string, testID: string) => (
|
|
4656
|
+
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4657
|
+
<Text variant="body" color="tertiary" testID={testID}>{message}</Text>
|
|
4658
|
+
</View>
|
|
4659
|
+
);
|
|
4557
4660
|
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4566
|
-
|
|
4661
|
+
// What the master pane holds and how it is headed, by what is pushed into it.
|
|
4662
|
+
// Nothing pushed → the sections. The changes surface, Media and Forms each
|
|
4663
|
+
// push a list of their own kind; a collection pushes its entries. The header
|
|
4664
|
+
// is the same shape throughout, so switching between them moves nothing.
|
|
4665
|
+
let masterHeaderNode: React.ReactNode;
|
|
4666
|
+
let masterBody: React.ReactNode;
|
|
4667
|
+
if (!pushed) {
|
|
4668
|
+
masterHeaderNode = masterHeader();
|
|
4669
|
+
masterBody = sectionsList;
|
|
4670
|
+
} else if (changes) {
|
|
4671
|
+
// The changes list: the same rows as the editor's, carrying a status and a
|
|
4672
|
+
// diff stat instead of a draft badge. Everything that mutates a document
|
|
4673
|
+
// (create, bulk select, search, folder drill-down) is deliberately absent:
|
|
4674
|
+
// this describes a comparison between two branches, not something to edit.
|
|
4675
|
+
masterHeaderNode = masterHeader();
|
|
4676
|
+
masterBody = (
|
|
4677
|
+
<EntriesList
|
|
4678
|
+
variant="desktop"
|
|
4679
|
+
entries={entries}
|
|
4680
|
+
selectedId={selectedId}
|
|
4681
|
+
onOpenEntry={(entry) => { revealDetail(); activate(entry); }}
|
|
4682
|
+
header={
|
|
4683
|
+
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }}>
|
|
4684
|
+
<Text variant="label" color="tertiary">
|
|
4685
|
+
{`${entries.length} changed`}
|
|
4686
|
+
</Text>
|
|
4687
|
+
</View>
|
|
4688
|
+
}
|
|
4689
|
+
folders={[]}
|
|
4690
|
+
onFolderPress={() => {}}
|
|
4691
|
+
/>
|
|
4567
4692
|
);
|
|
4568
|
-
}
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
<
|
|
4575
|
-
{
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
{modalOverlays}
|
|
4581
|
-
</AppShell>
|
|
4693
|
+
} else if (formsShowing && props.forms) {
|
|
4694
|
+
// The forms list sits where a collection's documents would. Opening a form
|
|
4695
|
+
// hands the detail region to FormsBrowser, which owns the two levels beneath
|
|
4696
|
+
// (submissions → one submission); the list stays here so you can switch.
|
|
4697
|
+
masterHeaderNode = masterHeader();
|
|
4698
|
+
masterBody = (
|
|
4699
|
+
<FormsBrowser
|
|
4700
|
+
api={props.forms}
|
|
4701
|
+
form={null}
|
|
4702
|
+
onSelectForm={(name) => { revealDetail(); onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY); }}
|
|
4703
|
+
variant="desktop"
|
|
4704
|
+
/>
|
|
4582
4705
|
);
|
|
4583
|
-
}
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4706
|
+
} else if (mediaShowing) {
|
|
4707
|
+
// Media lists its sets here and browses the chosen one in the detail region.
|
|
4708
|
+
// Selecting a set goes through onSelectNav, so it lands in the URL like any
|
|
4709
|
+
// other selection.
|
|
4710
|
+
masterHeaderNode = masterHeader();
|
|
4711
|
+
masterBody = (
|
|
4712
|
+
<MediaSetList
|
|
4713
|
+
sets={mediaSets}
|
|
4714
|
+
selected={mediaSet}
|
|
4715
|
+
onSelect={(name) => { revealDetail(); onSelectNav(mediaNavKey(name)); }}
|
|
4716
|
+
/>
|
|
4717
|
+
);
|
|
4718
|
+
} else {
|
|
4719
|
+
masterHeaderNode = collectionHeader;
|
|
4720
|
+
masterBody = search.advanced ? (
|
|
4721
|
+
<AdvancedSearch
|
|
4722
|
+
fields={facetFields}
|
|
4723
|
+
initial={search.draft}
|
|
4724
|
+
onChange={search.setDraft}
|
|
4725
|
+
onApply={search.applyDraft}
|
|
4726
|
+
matchCount={search.matchCount}
|
|
4727
|
+
counting={search.counting}
|
|
4728
|
+
/>
|
|
4729
|
+
) : (
|
|
4730
|
+
<EntriesList
|
|
4731
|
+
variant="desktop"
|
|
4732
|
+
entries={visibleEntries}
|
|
4733
|
+
selectedId={selectedId}
|
|
4734
|
+
onOpenEntry={openEntry}
|
|
4735
|
+
header={
|
|
4736
|
+
showChecks && selectedCount > 0 && (onBulkDelete || canBulkMove) ? (
|
|
4737
|
+
<SelectionBar
|
|
4738
|
+
count={selectedCount}
|
|
4739
|
+
onClear={clear}
|
|
4740
|
+
onMove={canBulkMove ? () => setMovePrompt(true) : undefined}
|
|
4741
|
+
onDelete={onBulkDelete ? async () => { await onBulkDelete(moveIds); clear(); } : undefined}
|
|
4613
4742
|
/>
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4743
|
+
) : (
|
|
4744
|
+
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2), flexDirection: "row", alignItems: "center", gap: t.space(1.5) }}>
|
|
4745
|
+
<Text variant="label" color="tertiary">
|
|
4746
|
+
{`${totalEntries ?? entries.length} entries`}
|
|
4747
|
+
</Text>
|
|
4748
|
+
{/* The count is of documents with unsaved edits in this
|
|
4749
|
+
browser — the same state the rows below badge as Draft.
|
|
4750
|
+
It replaced workspace.changed here, which every host
|
|
4751
|
+
passed as a constant and which therefore told nobody
|
|
4752
|
+
anything. Rendered only when there is something to
|
|
4753
|
+
report: a permanent "0 uncommitted" is furniture. */}
|
|
4754
|
+
{draftsOpen > 0 ? (
|
|
4755
|
+
<>
|
|
4756
|
+
<Text variant="label" color="tertiary">·</Text>
|
|
4757
|
+
<Text variant="label" color="primary" testID="uncommitted-count">
|
|
4758
|
+
{`${draftsOpen} uncommitted`}
|
|
4759
|
+
</Text>
|
|
4760
|
+
</>
|
|
4761
|
+
) : null}
|
|
4762
|
+
</View>
|
|
4763
|
+
)
|
|
4764
|
+
}
|
|
4765
|
+
upRow={
|
|
4766
|
+
hierarchical && folder.length > 0 ? (
|
|
4767
|
+
<PressableRow onPress={() => setFolder(folder.slice(0, -1))}>
|
|
4768
|
+
<UpListItem parentLabel={parentLabel(folder, activeLabel)} currentPath={folder.join("/")} />
|
|
4769
|
+
</PressableRow>
|
|
4770
|
+
) : null
|
|
4771
|
+
}
|
|
4772
|
+
folders={
|
|
4773
|
+
hierarchical
|
|
4774
|
+
? folderNames.map((name) => ({
|
|
4775
|
+
name,
|
|
4776
|
+
count: countItems(node!.folders.get(name)!),
|
|
4777
|
+
key: [...folder, name].join("/"),
|
|
4778
|
+
}))
|
|
4779
|
+
: []
|
|
4780
|
+
}
|
|
4781
|
+
onFolderPress={(name) => setFolder([...folder, name])}
|
|
4782
|
+
showChecks={showChecks}
|
|
4783
|
+
checked={checked}
|
|
4784
|
+
onToggleEntry={toggle}
|
|
4785
|
+
showFolderChecks={canBulkMove}
|
|
4786
|
+
checkedFolders={checkedFolders}
|
|
4787
|
+
onToggleFolder={toggleFolder}
|
|
4788
|
+
// Pagination is driven by the flat list; the folder drill-down loads
|
|
4789
|
+
// the whole set up front, so hasMore is false there.
|
|
4790
|
+
hasMore={!hierarchical && hasMoreEntries}
|
|
4791
|
+
loadingMore={loadingMoreEntries}
|
|
4792
|
+
onLoadMore={onLoadMoreEntries}
|
|
4793
|
+
/>
|
|
4640
4794
|
);
|
|
4641
4795
|
}
|
|
4642
4796
|
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
sets={mediaSets}
|
|
4652
|
-
selected={mediaSet}
|
|
4653
|
-
onSelect={(name) => onSelectNav(mediaNavKey(name))}
|
|
4654
|
-
/>
|
|
4655
|
-
</Pane>
|
|
4656
|
-
<ResizeHandle width={listW} min={240} max={520} onChange={setListW} testID="resize-media-sets" />
|
|
4797
|
+
// The master pane, expandable to the whole screen, and gone while a document
|
|
4798
|
+
// column has it. Its body owns its own scrolling (a FlatList, or the sections
|
|
4799
|
+
// ScrollView), so the pane itself does not.
|
|
4800
|
+
const masterPane = (
|
|
4801
|
+
<Pane width={masterExpanded ? undefined : masterW} testID="pane-master" scroll={false} header={masterHeaderNode}>
|
|
4802
|
+
{masterBody}
|
|
4803
|
+
</Pane>
|
|
4804
|
+
);
|
|
4657
4805
|
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
{
|
|
4666
|
-
|
|
4806
|
+
// The detail region, by what the master has pushed. A plugin screen and an
|
|
4807
|
+
// open form take it whole; Media browses the chosen set; the changes surface
|
|
4808
|
+
// shows the selected diff; the editor shows its document columns.
|
|
4809
|
+
let detail: React.ReactNode;
|
|
4810
|
+
if (changes) {
|
|
4811
|
+
detail = (
|
|
4812
|
+
<ChangeDetail
|
|
4813
|
+
change={props.selectedChange}
|
|
4814
|
+
loading={props.loadingChange}
|
|
4815
|
+
emptyMessage={activeNavKey ? "Select a change" : (entriesEmpty ?? "No changes on this branch")}
|
|
4816
|
+
conflicts={props.selectedConflicts}
|
|
4817
|
+
onResolveConflict={props.onResolveConflict}
|
|
4818
|
+
/>
|
|
4667
4819
|
);
|
|
4668
|
-
}
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
<
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
trailing={createBtn}
|
|
4820
|
+
} else if (props.contentSlot != null) {
|
|
4821
|
+
detail = (
|
|
4822
|
+
<Pane flex={1} testID="pane-plugin" scroll={false} borderRight={false}>
|
|
4823
|
+
{props.contentSlot}
|
|
4824
|
+
</Pane>
|
|
4825
|
+
);
|
|
4826
|
+
} else if (formsShowing && props.forms) {
|
|
4827
|
+
detail = activeForm ? (
|
|
4828
|
+
<Pane flex={1} testID="pane-forms" scroll={false} borderRight={false}>
|
|
4829
|
+
<FormsBrowser
|
|
4830
|
+
api={props.forms}
|
|
4831
|
+
form={activeForm}
|
|
4832
|
+
onSelectForm={(name) => onSelectNav(name ? formsNavKey(name) : FORMS_NAV_KEY)}
|
|
4833
|
+
selectedId={props.selectedSubmissionId ?? null}
|
|
4834
|
+
onSelectSubmission={props.onSelectSubmission}
|
|
4835
|
+
variant="desktop"
|
|
4685
4836
|
/>
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4693
|
-
|
|
4694
|
-
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
|
|
4703
|
-
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
|
|
4704
|
-
<Text variant="body" color="tertiary" testID="entries-empty">{entriesEmpty}</Text>
|
|
4705
|
-
</View>
|
|
4706
|
-
) : (
|
|
4707
|
-
<EntriesList
|
|
4708
|
-
variant="desktop"
|
|
4709
|
-
entries={visibleEntries}
|
|
4710
|
-
selectedId={selectedId}
|
|
4711
|
-
onOpenEntry={openEntry}
|
|
4712
|
-
header={
|
|
4713
|
-
showChecks && selectedCount > 0 && (onBulkDelete || canBulkMove) ? (
|
|
4714
|
-
<SelectionBar
|
|
4715
|
-
count={selectedCount}
|
|
4716
|
-
onClear={clear}
|
|
4717
|
-
onMove={canBulkMove ? () => setMovePrompt(true) : undefined}
|
|
4718
|
-
onDelete={onBulkDelete ? async () => { await onBulkDelete(moveIds); clear(); } : undefined}
|
|
4719
|
-
/>
|
|
4720
|
-
) : (
|
|
4721
|
-
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2), flexDirection: "row", alignItems: "center", gap: t.space(1.5) }}>
|
|
4722
|
-
<Text variant="label" color="tertiary">
|
|
4723
|
-
{`${totalEntries ?? entries.length} entries`}
|
|
4724
|
-
</Text>
|
|
4725
|
-
{/* The count is of documents with unsaved edits in this
|
|
4726
|
-
browser — the same state the rows below badge as Draft.
|
|
4727
|
-
It replaced workspace.changed here, which every host
|
|
4728
|
-
passed as a constant and which therefore told nobody
|
|
4729
|
-
anything. Rendered only when there is something to
|
|
4730
|
-
report: a permanent "0 uncommitted" is furniture. */}
|
|
4731
|
-
{draftsOpen > 0 ? (
|
|
4732
|
-
<>
|
|
4733
|
-
<Text variant="label" color="tertiary">·</Text>
|
|
4734
|
-
<Text variant="label" color="primary" testID="uncommitted-count">
|
|
4735
|
-
{`${draftsOpen} uncommitted`}
|
|
4736
|
-
</Text>
|
|
4737
|
-
</>
|
|
4738
|
-
) : null}
|
|
4739
|
-
</View>
|
|
4740
|
-
)
|
|
4741
|
-
}
|
|
4742
|
-
upRow={
|
|
4743
|
-
hierarchical && folder.length > 0 ? (
|
|
4744
|
-
<PressableRow onPress={() => setFolder(folder.slice(0, -1))}>
|
|
4745
|
-
<UpListItem parentLabel={parentLabel(folder, activeLabel)} currentPath={folder.join("/")} />
|
|
4746
|
-
</PressableRow>
|
|
4747
|
-
) : null
|
|
4748
|
-
}
|
|
4749
|
-
folders={
|
|
4750
|
-
hierarchical
|
|
4751
|
-
? folderNames.map((name) => ({
|
|
4752
|
-
name,
|
|
4753
|
-
count: countItems(node!.folders.get(name)!),
|
|
4754
|
-
key: [...folder, name].join("/"),
|
|
4755
|
-
}))
|
|
4756
|
-
: []
|
|
4757
|
-
}
|
|
4758
|
-
onFolderPress={(name) => setFolder([...folder, name])}
|
|
4759
|
-
showChecks={showChecks}
|
|
4760
|
-
checked={checked}
|
|
4761
|
-
onToggleEntry={toggle}
|
|
4762
|
-
showFolderChecks={canBulkMove}
|
|
4763
|
-
checkedFolders={checkedFolders}
|
|
4764
|
-
onToggleFolder={toggleFolder}
|
|
4765
|
-
// Pagination is driven by the flat list; the folder drill-down loads
|
|
4766
|
-
// the whole set up front, so hasMore is false there.
|
|
4767
|
-
hasMore={!hierarchical && hasMoreEntries}
|
|
4768
|
-
loadingMore={loadingMoreEntries}
|
|
4769
|
-
onLoadMore={onLoadMoreEntries}
|
|
4770
|
-
/>
|
|
4771
|
-
)}
|
|
4772
|
-
</Pane>
|
|
4773
|
-
)}
|
|
4774
|
-
|
|
4775
|
-
{/* draggable seam: resize the entry list (only while it's expanded) */}
|
|
4776
|
-
{listCollapsed ? null : (
|
|
4777
|
-
<ResizeHandle width={listW} min={260} max={640} onChange={setListW} testID="resize-list" />
|
|
4778
|
-
)}
|
|
4779
|
-
|
|
4780
|
-
{/* detail — a horizontally-scrolling row of side-by-side document columns.
|
|
4781
|
-
Each open column is its own editor focus-mode viewport (see `column`). */}
|
|
4837
|
+
</Pane>
|
|
4838
|
+
) : (
|
|
4839
|
+
emptyDetail("Select a form", "forms-empty")
|
|
4840
|
+
);
|
|
4841
|
+
} else if (mediaShowing) {
|
|
4842
|
+
detail = mediaSet ? (
|
|
4843
|
+
<MediaBrowser set={mediaSet} label={mediaSetLabel(mediaSets, mediaSet)} variant="desktop" />
|
|
4844
|
+
) : (
|
|
4845
|
+
emptyDetail("Select a media collection", "media-sets-empty")
|
|
4846
|
+
);
|
|
4847
|
+
} else {
|
|
4848
|
+
// A horizontally-scrolling row of side-by-side document columns. Each open
|
|
4849
|
+
// column is its own editor focus-mode viewport (see `columnView`). Unmounted
|
|
4850
|
+
// while the master has the screen; the columns keep their state through
|
|
4851
|
+
// that, since the host owns the open-tab set and the drafts live outside
|
|
4852
|
+
// this tree.
|
|
4853
|
+
detail = (
|
|
4782
4854
|
<View
|
|
4783
4855
|
testID="pane-detail"
|
|
4784
4856
|
ref={detailRef}
|
|
@@ -4801,27 +4873,13 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4801
4873
|
scrollEventThrottle={16}
|
|
4802
4874
|
onScroll={(ev) => { scrollXRef.current = ev.nativeEvent.contentOffset.x; }}
|
|
4803
4875
|
>
|
|
4804
|
-
{
|
|
4876
|
+
{shownColumns.flatMap((col, i) => {
|
|
4805
4877
|
const nodes: React.ReactNode[] = [];
|
|
4806
4878
|
if (dragId && overSlot === i) nodes.push(dropIndicator(`drop-${i}`));
|
|
4807
|
-
nodes.push(
|
|
4808
|
-
collapsedCols[col.id] ? (
|
|
4809
|
-
<CollapsedRail
|
|
4810
|
-
key={col.id}
|
|
4811
|
-
testID={`column-${col.id}`}
|
|
4812
|
-
label={col.title || col.entry?.title || "Untitled"}
|
|
4813
|
-
onExpand={() => setCollapsedCols((c) => ({ ...c, [col.id]: false }))}
|
|
4814
|
-
onClose={() => closeColumn(col.id)}
|
|
4815
|
-
dragHandle={columnGrip(col.id)}
|
|
4816
|
-
dimmed={dragId === col.id}
|
|
4817
|
-
/>
|
|
4818
|
-
) : (
|
|
4819
|
-
columnView(col)
|
|
4820
|
-
),
|
|
4821
|
-
);
|
|
4879
|
+
nodes.push(columnView(col));
|
|
4822
4880
|
return nodes;
|
|
4823
4881
|
})}
|
|
4824
|
-
{dragId && overSlot ===
|
|
4882
|
+
{dragId && overSlot === shownColumns.length ? dropIndicator("drop-end") : null}
|
|
4825
4883
|
{showCreate ? (
|
|
4826
4884
|
<View
|
|
4827
4885
|
testID="column-create"
|
|
@@ -4846,12 +4904,33 @@ function DesktopBrowser(props: ContentBrowserProps) {
|
|
|
4846
4904
|
</ScrollView>
|
|
4847
4905
|
) : (
|
|
4848
4906
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
|
4907
|
+
{/* With nothing pushed the region says what to do; a pushed list
|
|
4908
|
+
says what to pick from it; a direct item is on its way and
|
|
4909
|
+
needs no prompt. */}
|
|
4849
4910
|
<Text variant="body" color="tertiary" testID="detail-empty">
|
|
4850
|
-
{browsing
|
|
4911
|
+
{!browsing
|
|
4912
|
+
? "No entry selected."
|
|
4913
|
+
: !activeNavKey
|
|
4914
|
+
? entriesEmpty
|
|
4915
|
+
: activeItem?.direct
|
|
4916
|
+
? ""
|
|
4917
|
+
: `Select ${activeLabel}`}
|
|
4851
4918
|
</Text>
|
|
4852
4919
|
</View>
|
|
4853
4920
|
)}
|
|
4854
4921
|
</View>
|
|
4922
|
+
);
|
|
4923
|
+
}
|
|
4924
|
+
|
|
4925
|
+
return (
|
|
4926
|
+
<AppShell testID="desktop-shell" topBar={topBar} banner={readOnlyBanner}>
|
|
4927
|
+
{expandedCol ? null : masterPane}
|
|
4928
|
+
{/* draggable seam: resize the master pane — there is nothing to resize it
|
|
4929
|
+
against once a pane has taken the screen */}
|
|
4930
|
+
{masterExpanded || expandedCol ? null : (
|
|
4931
|
+
<ResizeHandle width={masterW} min={260} max={640} onChange={setMasterW} testID="resize-master" />
|
|
4932
|
+
)}
|
|
4933
|
+
{masterExpanded ? null : detail}
|
|
4855
4934
|
{movePrompt && onBulkMove ? (
|
|
4856
4935
|
<PromptDialog
|
|
4857
4936
|
title={`Move ${selectedCount} item${selectedCount === 1 ? "" : "s"}`}
|
|
@@ -5274,10 +5353,6 @@ function MobileBrowser(props: ContentBrowserProps) {
|
|
|
5274
5353
|
);
|
|
5275
5354
|
}
|
|
5276
5355
|
|
|
5277
|
-
// Width of a collapsed rail (the content list or a document column). Narrow,
|
|
5278
|
-
// full-height, with its label rotated 90° — clicking the body re-expands it.
|
|
5279
|
-
const RAIL_W = 44;
|
|
5280
|
-
|
|
5281
5356
|
// RN's typed `cursor` only allows auto/pointer; react-native-web forwards any CSS
|
|
5282
5357
|
// cursor to the DOM. Cast these desktop-only affordances through ViewStyle.
|
|
5283
5358
|
const CURSOR_COL_RESIZE = { cursor: "col-resize" } as unknown as ViewStyle;
|
|
@@ -5354,14 +5429,15 @@ function ResizeHandle({
|
|
|
5354
5429
|
|
|
5355
5430
|
// CustomColumn is a column whose body is a caller-supplied node rather than the
|
|
5356
5431
|
// document editor (OpenTab.content). It reproduces the editor column's header
|
|
5357
|
-
// chrome — grip, active accent,
|
|
5432
|
+
// chrome — grip, active accent, expand, close — so a plugin's column is a
|
|
5358
5433
|
// peer of the document columns rather than something visibly bolted on. The
|
|
5359
5434
|
// body gets no padding and no scroll container: an iframe wants the whole box.
|
|
5360
5435
|
function CustomColumn({
|
|
5361
5436
|
title,
|
|
5362
5437
|
active,
|
|
5363
5438
|
onActivate,
|
|
5364
|
-
|
|
5439
|
+
expanded,
|
|
5440
|
+
onToggleExpand,
|
|
5365
5441
|
onClose,
|
|
5366
5442
|
dragHandle,
|
|
5367
5443
|
children,
|
|
@@ -5369,7 +5445,8 @@ function CustomColumn({
|
|
|
5369
5445
|
title: string;
|
|
5370
5446
|
active?: boolean;
|
|
5371
5447
|
onActivate?: () => void;
|
|
5372
|
-
|
|
5448
|
+
expanded?: boolean;
|
|
5449
|
+
onToggleExpand?: () => void;
|
|
5373
5450
|
onClose?: () => void;
|
|
5374
5451
|
dragHandle?: React.ReactNode;
|
|
5375
5452
|
children: React.ReactNode;
|
|
@@ -5391,8 +5468,15 @@ function CustomColumn({
|
|
|
5391
5468
|
<Pressable onPress={onActivate} style={{ flex: 1 }}>
|
|
5392
5469
|
<Text variant="monoSm" numberOfLines={1}>{title}</Text>
|
|
5393
5470
|
</Pressable>
|
|
5394
|
-
{
|
|
5395
|
-
<IconButton
|
|
5471
|
+
{onToggleExpand ? (
|
|
5472
|
+
<IconButton
|
|
5473
|
+
name={expanded ? "minimize" : "maximize"}
|
|
5474
|
+
size="sm"
|
|
5475
|
+
active={expanded}
|
|
5476
|
+
label={expanded ? "Collapse column" : "Expand column"}
|
|
5477
|
+
onPress={onToggleExpand}
|
|
5478
|
+
testID="column-expand"
|
|
5479
|
+
/>
|
|
5396
5480
|
) : null}
|
|
5397
5481
|
{onClose ? (
|
|
5398
5482
|
<IconButton name="x" size="sm" label="Close column" onPress={onClose} testID="column-close" />
|
|
@@ -5403,72 +5487,6 @@ function CustomColumn({
|
|
|
5403
5487
|
);
|
|
5404
5488
|
}
|
|
5405
5489
|
|
|
5406
|
-
// CollapsedRail is the thin vertical strip a collapsed pane becomes. It mirrors
|
|
5407
|
-
// the expanded pane's header: an optional top control row (a trailing slot such
|
|
5408
|
-
// as the create "+", and/or a close button) over a pressable body whose rotated
|
|
5409
|
-
// label matches the header's title. onExpand fires on a body press.
|
|
5410
|
-
function CollapsedRail({
|
|
5411
|
-
label,
|
|
5412
|
-
onExpand,
|
|
5413
|
-
onClose,
|
|
5414
|
-
trailing,
|
|
5415
|
-
dragHandle,
|
|
5416
|
-
dimmed,
|
|
5417
|
-
testID,
|
|
5418
|
-
}: {
|
|
5419
|
-
label: string;
|
|
5420
|
-
onExpand: () => void;
|
|
5421
|
-
onClose?: () => void;
|
|
5422
|
-
trailing?: React.ReactNode;
|
|
5423
|
-
// A drag grip (column rails) rendered above the close button in the control row.
|
|
5424
|
-
dragHandle?: React.ReactNode;
|
|
5425
|
-
// Dim the rail while it's the column being dragged.
|
|
5426
|
-
dimmed?: boolean;
|
|
5427
|
-
testID?: string;
|
|
5428
|
-
}) {
|
|
5429
|
-
const t = useTheme();
|
|
5430
|
-
return (
|
|
5431
|
-
<View
|
|
5432
|
-
testID={testID}
|
|
5433
|
-
style={{
|
|
5434
|
-
width: RAIL_W, alignSelf: "stretch", minHeight: 0, overflow: "hidden",
|
|
5435
|
-
borderRightWidth: 1, borderRightColor: t.color.borderSubtle,
|
|
5436
|
-
backgroundColor: t.color.surfacePage,
|
|
5437
|
-
opacity: dimmed ? 0.5 : 1,
|
|
5438
|
-
}}
|
|
5439
|
-
>
|
|
5440
|
-
{trailing || onClose || dragHandle ? (
|
|
5441
|
-
<View
|
|
5442
|
-
style={{
|
|
5443
|
-
height: t.layout.topbar, alignItems: "center", justifyContent: "center", gap: t.space(1),
|
|
5444
|
-
borderBottomWidth: 1, borderBottomColor: t.color.borderSubtle,
|
|
5445
|
-
}}
|
|
5446
|
-
>
|
|
5447
|
-
{dragHandle}
|
|
5448
|
-
{trailing}
|
|
5449
|
-
{onClose ? <IconButton name="x" size="sm" label="Close" onPress={onClose} testID="rail-close" /> : null}
|
|
5450
|
-
</View>
|
|
5451
|
-
) : null}
|
|
5452
|
-
<Pressable
|
|
5453
|
-
onPress={onExpand}
|
|
5454
|
-
style={({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => ({
|
|
5455
|
-
flex: 1, alignItems: "center", justifyContent: "center", gap: t.space(3),
|
|
5456
|
-
backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
5457
|
-
})}
|
|
5458
|
-
>
|
|
5459
|
-
<Icon name="chevronRight" size={14} color={t.color.textTertiary} />
|
|
5460
|
-
{/* rotate doesn't change the layout box, so the pre-rotation width becomes
|
|
5461
|
-
the label's on-screen height; overflow:hidden on the rail clips it. */}
|
|
5462
|
-
<View style={{ transform: [{ rotate: "90deg" }] }}>
|
|
5463
|
-
<Text variant="monoSm" weight="medium" numberOfLines={1} style={{ width: 240, textAlign: "center" }}>
|
|
5464
|
-
{label}
|
|
5465
|
-
</Text>
|
|
5466
|
-
</View>
|
|
5467
|
-
</Pressable>
|
|
5468
|
-
</View>
|
|
5469
|
-
);
|
|
5470
|
-
}
|
|
5471
|
-
|
|
5472
5490
|
// Small Pressable wrapper so list items get press feedback without duplicating.
|
|
5473
5491
|
// Row press feedback: the selected row holds a stronger tint (surfaceActive) so
|
|
5474
5492
|
// it stays distinct from the lighter hover tint (surfaceHover) on other rows.
|
|
@@ -5530,23 +5548,6 @@ function mediaSetLabel(sets: MediaSetInfo[], name: string): string {
|
|
|
5530
5548
|
return set ? titleCaseWord(set.name) : titleCaseWord(name);
|
|
5531
5549
|
}
|
|
5532
5550
|
|
|
5533
|
-
// PaneTitle is the content pane's heading for the surfaces with no controls
|
|
5534
|
-
// beside it — Changes, Media, Forms.
|
|
5535
|
-
//
|
|
5536
|
-
// Deliberately the same Text the collection header uses (`listHeader`), and
|
|
5537
|
-
// nothing else. Pane's header row already supplies the height, the horizontal
|
|
5538
|
-
// padding and the rule beneath, so the padded wrapper this used to add sat
|
|
5539
|
-
// inside that padding and pushed the title a step right of every collection's —
|
|
5540
|
-
// at `body` rather than `h3`, so it read a size smaller too. Switching between
|
|
5541
|
-
// Posts and Media moved the heading twice over.
|
|
5542
|
-
function PaneTitle({ title }: { title: string }) {
|
|
5543
|
-
return (
|
|
5544
|
-
<Text variant="h3" weight="semibold" style={{ flex: 1 }}>
|
|
5545
|
-
{title}
|
|
5546
|
-
</Text>
|
|
5547
|
-
);
|
|
5548
|
-
}
|
|
5549
|
-
|
|
5550
5551
|
// MediaSetList is the content pane under Media: one row per media collection,
|
|
5551
5552
|
// sitting exactly where a collection's documents sit. Each row shows how many
|
|
5552
5553
|
// files the set holds and the glob it is discovered by, since that glob is the
|
|
@@ -5594,15 +5595,138 @@ function MediaSetList({
|
|
|
5594
5595
|
);
|
|
5595
5596
|
}
|
|
5596
5597
|
|
|
5597
|
-
// navRowActive reports whether a
|
|
5598
|
-
// button stays lit while a set beneath it is open ("media:uploads"),
|
|
5599
|
-
//
|
|
5600
|
-
//
|
|
5598
|
+
// navRowActive reports whether a sections row should read as current. The Media
|
|
5599
|
+
// button stays lit while a set beneath it is open ("media:uploads"), and Forms
|
|
5600
|
+
// while a form is, because neither the set nor the form is itself a row —
|
|
5601
|
+
// losing the highlight there would leave the sections showing nothing selected
|
|
5602
|
+
// while media or a form is plainly on screen.
|
|
5601
5603
|
function navRowActive(itemKey: string, activeNavKey: string): boolean {
|
|
5602
5604
|
if (itemKey === MEDIA_NAV_KEY) return isMediaNavKey(activeNavKey);
|
|
5605
|
+
if (itemKey === FORMS_NAV_KEY) return isFormsNavKey(activeNavKey);
|
|
5603
5606
|
return itemKey === activeNavKey;
|
|
5604
5607
|
}
|
|
5605
5608
|
|
|
5609
|
+
// navItemFor resolves the sections row a nav key belongs to — the row itself,
|
|
5610
|
+
// or, for a media set or a form, the Media or Forms row they sit beneath.
|
|
5611
|
+
function navItemFor(sections: CmsNavSection[], key: string): CmsNavItem | undefined {
|
|
5612
|
+
if (!key) return undefined;
|
|
5613
|
+
for (const s of sections) {
|
|
5614
|
+
for (const item of s.items) {
|
|
5615
|
+
if (navRowActive(item.key, key)) return item;
|
|
5616
|
+
}
|
|
5617
|
+
}
|
|
5618
|
+
return undefined;
|
|
5619
|
+
}
|
|
5620
|
+
|
|
5621
|
+
// MasterTitle heads the master pane while a list is pushed into it. It is the
|
|
5622
|
+
// list's name and, when there is more than one list to choose from, a menu of
|
|
5623
|
+
// the rest — so switching collections does not go back through the sections.
|
|
5624
|
+
// Direct items (singletons, plugin screens) are left out: they have no list to
|
|
5625
|
+
// switch to. With a single list to offer there is no menu at all, matching the
|
|
5626
|
+
// branch and project menus' rule against a dead affordance.
|
|
5627
|
+
//
|
|
5628
|
+
// The menu sits inside the pane's header, which stacks above the pane body and
|
|
5629
|
+
// clips to the pane — so it is positioned against the header row, spans the
|
|
5630
|
+
// pane's width, and scrolls past a height the pane could not show.
|
|
5631
|
+
function MasterTitle({
|
|
5632
|
+
label,
|
|
5633
|
+
sections,
|
|
5634
|
+
activeNavKey,
|
|
5635
|
+
onSelect,
|
|
5636
|
+
}: {
|
|
5637
|
+
label: string;
|
|
5638
|
+
sections: CmsNavSection[];
|
|
5639
|
+
activeNavKey: string;
|
|
5640
|
+
onSelect: (key: string) => void;
|
|
5641
|
+
}) {
|
|
5642
|
+
const t = useTheme();
|
|
5643
|
+
const [open, setOpen] = useState(false);
|
|
5644
|
+
const listed = sections
|
|
5645
|
+
.map((s) => ({ ...s, items: s.items.filter((i) => !i.direct) }))
|
|
5646
|
+
.filter((s) => s.items.length > 0);
|
|
5647
|
+
const count = listed.reduce((n, s) => n + s.items.length, 0);
|
|
5648
|
+
if (count < 2) {
|
|
5649
|
+
return (
|
|
5650
|
+
<Text variant="h3" weight="semibold" style={{ flex: 1 }} numberOfLines={1} testID="master-title">
|
|
5651
|
+
{label}
|
|
5652
|
+
</Text>
|
|
5653
|
+
);
|
|
5654
|
+
}
|
|
5655
|
+
const rowStyle = ({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
|
|
5656
|
+
flexDirection: "row" as const, alignItems: "center" as const, gap: t.space(2),
|
|
5657
|
+
paddingHorizontal: t.space(3), paddingVertical: t.space(2),
|
|
5658
|
+
backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
5659
|
+
});
|
|
5660
|
+
return (
|
|
5661
|
+
<>
|
|
5662
|
+
<Pressable
|
|
5663
|
+
testID="master-title"
|
|
5664
|
+
accessibilityRole="button"
|
|
5665
|
+
accessibilityLabel={`${label}. Switch collection`}
|
|
5666
|
+
aria-expanded={open}
|
|
5667
|
+
onPress={() => setOpen((v) => !v)}
|
|
5668
|
+
style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: t.space(1.5), minWidth: 0 }}
|
|
5669
|
+
>
|
|
5670
|
+
<Text variant="h3" weight="semibold" numberOfLines={1} style={{ flexShrink: 1 }}>{label}</Text>
|
|
5671
|
+
<Icon name="chevronDown" size={14} color={t.color.textTertiary} />
|
|
5672
|
+
</Pressable>
|
|
5673
|
+
{open ? (
|
|
5674
|
+
<>
|
|
5675
|
+
{/* A catcher so a click anywhere in the pane dismisses; above the
|
|
5676
|
+
header's other controls so none of them fires through it. */}
|
|
5677
|
+
<Pressable
|
|
5678
|
+
onPress={() => setOpen(false)}
|
|
5679
|
+
testID="master-menu-backdrop"
|
|
5680
|
+
style={{ position: "absolute", top: -2000, left: -2000, right: -2000, bottom: -2000, zIndex: 15 }}
|
|
5681
|
+
/>
|
|
5682
|
+
<View
|
|
5683
|
+
testID="master-menu"
|
|
5684
|
+
style={{
|
|
5685
|
+
position: "absolute", top: t.layout.topbar - t.space(1), left: t.space(2), right: t.space(2),
|
|
5686
|
+
maxHeight: 420, zIndex: 20,
|
|
5687
|
+
backgroundColor: t.color.surfaceRaised, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
5688
|
+
borderRadius: t.radius.md, paddingVertical: t.space(1),
|
|
5689
|
+
}}
|
|
5690
|
+
>
|
|
5691
|
+
<ScrollView>
|
|
5692
|
+
{listed.map((section, i) => (
|
|
5693
|
+
<View key={section.title || `section-${i}`}>
|
|
5694
|
+
{section.title ? <SectionLabel>{section.title}</SectionLabel> : null}
|
|
5695
|
+
{section.items.map((item) => {
|
|
5696
|
+
const active = navRowActive(item.key, activeNavKey);
|
|
5697
|
+
return (
|
|
5698
|
+
<Pressable
|
|
5699
|
+
key={item.key}
|
|
5700
|
+
testID={`master-option-${item.key}`}
|
|
5701
|
+
accessibilityRole="menuitem"
|
|
5702
|
+
aria-current={active ? "page" : undefined}
|
|
5703
|
+
onPress={() => { setOpen(false); if (!active) onSelect(item.key); }}
|
|
5704
|
+
style={rowStyle}
|
|
5705
|
+
>
|
|
5706
|
+
<Icon
|
|
5707
|
+
name={active ? "check" : item.icon}
|
|
5708
|
+
size={15}
|
|
5709
|
+
color={active ? t.color.textPrimary : t.color.textSecondary}
|
|
5710
|
+
/>
|
|
5711
|
+
<Text variant="sm" weight={active ? "semibold" : "regular"} numberOfLines={1} style={{ flex: 1 }}>
|
|
5712
|
+
{item.label}
|
|
5713
|
+
</Text>
|
|
5714
|
+
{item.count != null ? (
|
|
5715
|
+
<Text variant="monoSm" color="tertiary">{String(item.count)}</Text>
|
|
5716
|
+
) : null}
|
|
5717
|
+
</Pressable>
|
|
5718
|
+
);
|
|
5719
|
+
})}
|
|
5720
|
+
</View>
|
|
5721
|
+
))}
|
|
5722
|
+
</ScrollView>
|
|
5723
|
+
</View>
|
|
5724
|
+
</>
|
|
5725
|
+
) : null}
|
|
5726
|
+
</>
|
|
5727
|
+
);
|
|
5728
|
+
}
|
|
5729
|
+
|
|
5606
5730
|
// navLabelFor finds the sidebar label for a nav key, so a pane header shows the
|
|
5607
5731
|
// same words the user clicked rather than re-deriving them from the key.
|
|
5608
5732
|
function navLabelFor(sections: CmsNavSection[], key: string): string | undefined {
|