@gogitcms/design-system 0.16.0-next.13 → 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.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 +558 -442
- package/src/components/Input.tsx +37 -5
- 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/theme.ts +3 -1
package/src/components/Input.tsx
CHANGED
|
@@ -21,6 +21,11 @@ export type InputProps = {
|
|
|
21
21
|
// error highlights the field with the danger border color (e.g. a failed save
|
|
22
22
|
// or validation naming this field).
|
|
23
23
|
error?: boolean;
|
|
24
|
+
// prefix renders a fixed, read-only fragment ahead of the editable text, as
|
|
25
|
+
// one control — for a value whose start is decided elsewhere (a branch name
|
|
26
|
+
// under the repository's prefix). It is display only: `value` is the part
|
|
27
|
+
// after it, and the caller composes the whole.
|
|
28
|
+
prefix?: string;
|
|
24
29
|
style?: StyleProp<ViewStyle>;
|
|
25
30
|
testID?: string;
|
|
26
31
|
// Optional focus/blur passthroughs (used by collaborative presence tracking).
|
|
@@ -45,6 +50,7 @@ export function Input({
|
|
|
45
50
|
keyboardType,
|
|
46
51
|
editable = true,
|
|
47
52
|
error = false,
|
|
53
|
+
prefix,
|
|
48
54
|
style,
|
|
49
55
|
testID,
|
|
50
56
|
onFocus,
|
|
@@ -58,6 +64,12 @@ export function Input({
|
|
|
58
64
|
if (focusSignal) ref.current?.focus();
|
|
59
65
|
}, [focusSignal]);
|
|
60
66
|
|
|
67
|
+
const borderColor = error
|
|
68
|
+
? t.color.diffDelFg
|
|
69
|
+
: focused && editable
|
|
70
|
+
? t.color.borderStrong
|
|
71
|
+
: t.color.borderDefault;
|
|
72
|
+
|
|
61
73
|
return (
|
|
62
74
|
<View style={[{ gap: t.space(2) }, style]}>
|
|
63
75
|
{label ? (
|
|
@@ -65,6 +77,26 @@ export function Input({
|
|
|
65
77
|
{label}
|
|
66
78
|
</Text>
|
|
67
79
|
) : null}
|
|
80
|
+
<View style={{ flexDirection: "row", alignItems: "stretch" }}>
|
|
81
|
+
{prefix ? (
|
|
82
|
+
<View
|
|
83
|
+
testID={testID ? `${testID}-prefix` : undefined}
|
|
84
|
+
accessibilityLabel={`Prefix ${prefix}`}
|
|
85
|
+
style={{
|
|
86
|
+
height: t.control[size],
|
|
87
|
+
paddingHorizontal: t.space(3),
|
|
88
|
+
justifyContent: "center",
|
|
89
|
+
borderWidth: 1,
|
|
90
|
+
borderRightWidth: 0,
|
|
91
|
+
borderColor,
|
|
92
|
+
borderTopLeftRadius: t.radius.md,
|
|
93
|
+
borderBottomLeftRadius: t.radius.md,
|
|
94
|
+
backgroundColor: t.color.surfaceSunken,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<Text variant="mono" color="secondary" style={{ fontSize: t.type.mono.size }}>{prefix}</Text>
|
|
98
|
+
</View>
|
|
99
|
+
) : null}
|
|
68
100
|
<TextInput
|
|
69
101
|
ref={ref}
|
|
70
102
|
testID={testID}
|
|
@@ -85,15 +117,14 @@ export function Input({
|
|
|
85
117
|
// has no outlineStyle, and the mismatch is reported on the whole style
|
|
86
118
|
// object rather than on the property, so the suppression belongs here.
|
|
87
119
|
style={{
|
|
120
|
+
flex: 1,
|
|
88
121
|
height: t.control[size],
|
|
89
122
|
paddingHorizontal: t.space(3),
|
|
90
123
|
borderRadius: t.radius.md,
|
|
124
|
+
borderTopLeftRadius: prefix ? 0 : t.radius.md,
|
|
125
|
+
borderBottomLeftRadius: prefix ? 0 : t.radius.md,
|
|
91
126
|
borderWidth: 1,
|
|
92
|
-
borderColor
|
|
93
|
-
? t.color.diffDelFg
|
|
94
|
-
: focused && editable
|
|
95
|
-
? t.color.borderStrong
|
|
96
|
-
: t.color.borderDefault,
|
|
127
|
+
borderColor,
|
|
97
128
|
backgroundColor: editable ? t.color.surfaceRaised : t.color.surfaceSunken,
|
|
98
129
|
color: editable ? t.color.textPrimary : t.color.textSecondary,
|
|
99
130
|
fontFamily: mono ? t.font.mono : t.font.sans,
|
|
@@ -101,6 +132,7 @@ export function Input({
|
|
|
101
132
|
outlineStyle: "none",
|
|
102
133
|
}}
|
|
103
134
|
/>
|
|
135
|
+
</View>
|
|
104
136
|
</View>
|
|
105
137
|
);
|
|
106
138
|
}
|
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
import React, { useState } from "react";
|
|
2
2
|
import { View, Pressable } from "react-native";
|
|
3
3
|
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import type { IconName } from "../icons";
|
|
4
5
|
import { Text } from "./Text";
|
|
5
6
|
import { Icon } from "./Icon";
|
|
7
|
+
import { Divider } from "./primitives";
|
|
6
8
|
|
|
7
|
-
export type ProjectRef = {
|
|
9
|
+
export type ProjectRef = {
|
|
10
|
+
name: string;
|
|
11
|
+
label: string;
|
|
12
|
+
/**
|
|
13
|
+
* Identifies the project within the list. Defaults to the name, which is
|
|
14
|
+
* unique within one repository; a list spanning repositories (the desktop
|
|
15
|
+
* app's, which covers a whole workspace) sets its own, since two of them may
|
|
16
|
+
* each have a "docs".
|
|
17
|
+
*/
|
|
18
|
+
key?: string;
|
|
19
|
+
/**
|
|
20
|
+
* The line under the label. Defaults to the name when it differs from the
|
|
21
|
+
* label; a workspace-wide list uses it to say which repository a project is
|
|
22
|
+
* in.
|
|
23
|
+
*/
|
|
24
|
+
detail?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// A row under the project list that does something other than switch.
|
|
28
|
+
export type ProjectMenuAction = {
|
|
29
|
+
label: string;
|
|
30
|
+
icon?: IconName;
|
|
31
|
+
onSelect: () => void;
|
|
32
|
+
testID?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const refKey = (p: ProjectRef) => p.key ?? p.name;
|
|
8
36
|
|
|
9
37
|
export type ProjectMenuProps = {
|
|
10
|
-
/** The project currently being edited,
|
|
38
|
+
/** The project currently being edited: its key, which is the manifest name unless the list sets keys. */
|
|
11
39
|
current?: string;
|
|
12
40
|
/**
|
|
13
41
|
* Every project of the repository. Omitted, empty, or a single entry renders
|
|
@@ -22,6 +50,13 @@ export type ProjectMenuProps = {
|
|
|
22
50
|
* broken picker.
|
|
23
51
|
*/
|
|
24
52
|
locked?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Offers each project in a window of its own, beside switching this one to
|
|
55
|
+
* it. Only a host with windows to open (the desktop app) passes it.
|
|
56
|
+
*/
|
|
57
|
+
onOpenInNewWindow?: (project: ProjectRef) => void;
|
|
58
|
+
/** Rows under the list, divided from it. */
|
|
59
|
+
actions?: ProjectMenuAction[];
|
|
25
60
|
testID?: string;
|
|
26
61
|
};
|
|
27
62
|
|
|
@@ -40,7 +75,7 @@ export type ProjectMenuProps = {
|
|
|
40
75
|
* Project names are identifiers (they appear in URLs and in cms.config.js), so
|
|
41
76
|
* the name renders mono; the human label renders alongside it when it differs.
|
|
42
77
|
*/
|
|
43
|
-
export function ProjectMenu({ current, projects, onSelect, locked, testID = "project" }: ProjectMenuProps) {
|
|
78
|
+
export function ProjectMenu({ current, projects, onSelect, locked, onOpenInNewWindow, actions, testID = "project" }: ProjectMenuProps) {
|
|
44
79
|
const t = useTheme();
|
|
45
80
|
const [open, setOpen] = useState(false);
|
|
46
81
|
|
|
@@ -49,7 +84,7 @@ export function ProjectMenu({ current, projects, onSelect, locked, testID = "pro
|
|
|
49
84
|
if (list.length === 0) return null;
|
|
50
85
|
if (list.length < 2 && !locked) return null;
|
|
51
86
|
|
|
52
|
-
const active = list.find((p) => p
|
|
87
|
+
const active = list.find((p) => refKey(p) === current);
|
|
53
88
|
const shown = active?.label || active?.name || current || "";
|
|
54
89
|
|
|
55
90
|
const badge = (
|
|
@@ -87,35 +122,74 @@ export function ProjectMenu({ current, projects, onSelect, locked, testID = "pro
|
|
|
87
122
|
/>
|
|
88
123
|
<View
|
|
89
124
|
style={{
|
|
90
|
-
position: "absolute", top: t.control.sm + 4, left: 0, minWidth: 220,
|
|
125
|
+
position: "absolute", top: t.control.sm + 4, left: 0, minWidth: onOpenInNewWindow ? 280 : 220,
|
|
91
126
|
backgroundColor: t.color.surfaceRaised, borderRadius: t.radius.md,
|
|
92
127
|
borderWidth: 1, borderColor: t.color.borderDefault,
|
|
93
128
|
paddingVertical: t.space(1), zIndex: 50,
|
|
94
129
|
}}
|
|
95
130
|
>
|
|
96
|
-
{list.map((p) =>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
style={{
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
<
|
|
131
|
+
{list.map((p) => {
|
|
132
|
+
const key = refKey(p);
|
|
133
|
+
const detail = p.detail ?? (p.label && p.label !== p.name ? p.name : undefined);
|
|
134
|
+
return (
|
|
135
|
+
<View key={key} style={{ flexDirection: "row", alignItems: "center" }}>
|
|
136
|
+
<Pressable
|
|
137
|
+
onPress={() => { setOpen(false); onSelect(p); }}
|
|
138
|
+
style={{
|
|
139
|
+
flex: 1, flexDirection: "row", alignItems: "center", gap: 8,
|
|
140
|
+
paddingHorizontal: t.space(3), paddingVertical: t.space(2),
|
|
141
|
+
}}
|
|
142
|
+
testID={`${testID}-option-${key || "default"}`}
|
|
143
|
+
>
|
|
144
|
+
<Icon
|
|
145
|
+
name={key === current ? "check" : "folder"}
|
|
146
|
+
size={13}
|
|
147
|
+
color={key === current ? t.color.textPrimary : t.color.textTertiary}
|
|
148
|
+
/>
|
|
149
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
150
|
+
<Text variant="sm" numberOfLines={1}>{p.label || p.name}</Text>
|
|
151
|
+
{detail ? (
|
|
152
|
+
<Text variant="monoSm" color="tertiary" numberOfLines={1}>{detail}</Text>
|
|
153
|
+
) : null}
|
|
154
|
+
</View>
|
|
155
|
+
</Pressable>
|
|
156
|
+
{onOpenInNewWindow ? (
|
|
157
|
+
<Pressable
|
|
158
|
+
onPress={() => { setOpen(false); onOpenInNewWindow(p); }}
|
|
159
|
+
accessibilityRole="button"
|
|
160
|
+
accessibilityLabel={`Open ${p.label || p.name} in a new window`}
|
|
161
|
+
style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
|
|
162
|
+
width: 28, height: 28, marginRight: t.space(1.5),
|
|
163
|
+
alignItems: "center", justifyContent: "center", borderRadius: t.radius.md,
|
|
164
|
+
backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
165
|
+
})}
|
|
166
|
+
testID={`${testID}-new-window-${key || "default"}`}
|
|
167
|
+
>
|
|
168
|
+
<Icon name="externalLink" size={13} color={t.color.textTertiary} />
|
|
169
|
+
</Pressable>
|
|
115
170
|
) : null}
|
|
116
171
|
</View>
|
|
117
|
-
|
|
118
|
-
)
|
|
172
|
+
);
|
|
173
|
+
})}
|
|
174
|
+
{actions?.length ? (
|
|
175
|
+
<>
|
|
176
|
+
<Divider style={{ marginVertical: t.space(1) }} />
|
|
177
|
+
{actions.map((a, i) => (
|
|
178
|
+
<Pressable
|
|
179
|
+
key={`${a.label}-${i}`}
|
|
180
|
+
onPress={() => { setOpen(false); a.onSelect(); }}
|
|
181
|
+
style={{
|
|
182
|
+
flexDirection: "row", alignItems: "center", gap: 8,
|
|
183
|
+
paddingHorizontal: t.space(3), paddingVertical: t.space(2),
|
|
184
|
+
}}
|
|
185
|
+
testID={a.testID ?? `${testID}-action-${i}`}
|
|
186
|
+
>
|
|
187
|
+
<Icon name={a.icon ?? "grid"} size={13} color={t.color.textTertiary} />
|
|
188
|
+
<Text variant="sm" numberOfLines={1}>{a.label}</Text>
|
|
189
|
+
</Pressable>
|
|
190
|
+
))}
|
|
191
|
+
</>
|
|
192
|
+
) : null}
|
|
119
193
|
</View>
|
|
120
194
|
</>
|
|
121
195
|
) : null}
|
|
@@ -17,8 +17,17 @@ export type ProtectedBranchModalProps = {
|
|
|
17
17
|
busy?: boolean;
|
|
18
18
|
/** The server's message, when the attempt failed. */
|
|
19
19
|
error?: string | null;
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Create `name` from `branch` and save the pending edit onto it. `name` is
|
|
22
|
+
* the full branch name, prefix applied.
|
|
23
|
+
*/
|
|
21
24
|
onCreate: (name: string) => void;
|
|
25
|
+
/**
|
|
26
|
+
* The repository's branch prefix ("cms/"), shown read-only ahead of the
|
|
27
|
+
* field — see BranchMenu. A suggestion that already carries it is shown
|
|
28
|
+
* without it, so the prefix is never doubled.
|
|
29
|
+
*/
|
|
30
|
+
prefix?: string;
|
|
22
31
|
/** Dismiss. The edit stays as an unsaved draft on the protected branch. */
|
|
23
32
|
onCancel: () => void;
|
|
24
33
|
};
|
|
@@ -36,21 +45,23 @@ export type ProtectedBranchModalProps = {
|
|
|
36
45
|
* branch name later. It stays in storage exactly as any other unsaved work does.
|
|
37
46
|
*/
|
|
38
47
|
export function ProtectedBranchModal({
|
|
39
|
-
branch, documentLabel, suggestedName, busy, error, onCreate, onCancel,
|
|
48
|
+
branch, documentLabel, suggestedName, busy, error, onCreate, onCancel, prefix,
|
|
40
49
|
}: ProtectedBranchModalProps) {
|
|
41
50
|
const t = useTheme();
|
|
42
|
-
const
|
|
51
|
+
const stripPrefix = (s: string) => (prefix && s.startsWith(prefix) ? s.slice(prefix.length) : s);
|
|
52
|
+
const [name, setName] = useState(stripPrefix(suggestedName ?? ""));
|
|
43
53
|
// The suggestion is derived from the document, which the host may resolve a
|
|
44
54
|
// render after the modal opens. Adopted only while the field is untouched.
|
|
45
55
|
const [touched, setTouched] = useState(false);
|
|
46
56
|
useEffect(() => {
|
|
47
|
-
if (!touched && suggestedName) setName(suggestedName);
|
|
48
|
-
|
|
57
|
+
if (!touched && suggestedName) setName(stripPrefix(suggestedName));
|
|
58
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
59
|
+
}, [suggestedName, touched, prefix]);
|
|
49
60
|
|
|
50
61
|
const trimmed = name.trim();
|
|
51
62
|
const submit = () => {
|
|
52
63
|
if (!trimmed || busy) return;
|
|
53
|
-
onCreate(trimmed);
|
|
64
|
+
onCreate((prefix ?? "") + trimmed);
|
|
54
65
|
};
|
|
55
66
|
|
|
56
67
|
return (
|
|
@@ -98,12 +109,13 @@ export function ProtectedBranchModal({
|
|
|
98
109
|
value={name}
|
|
99
110
|
onChangeText={(v) => { setTouched(true); setName(v); }}
|
|
100
111
|
onSubmitEditing={submit}
|
|
101
|
-
placeholder="feat/my-change"
|
|
112
|
+
placeholder={prefix ? "my-change" : "feat/my-change"}
|
|
102
113
|
autoCapitalize="none"
|
|
103
114
|
autoFocus
|
|
104
115
|
mono
|
|
105
116
|
editable={!busy}
|
|
106
117
|
error={!!error}
|
|
118
|
+
prefix={prefix}
|
|
107
119
|
testID="protected-branch-name"
|
|
108
120
|
/>
|
|
109
121
|
<Text variant="monoSm" color="tertiary">{`Branched from ${branch}`}</Text>
|
|
@@ -121,30 +121,22 @@ function DesktopSkeleton() {
|
|
|
121
121
|
/>
|
|
122
122
|
}
|
|
123
123
|
>
|
|
124
|
-
{/*
|
|
125
|
-
<Pane width={t.layout.sidebar} testID="skeleton-pane-nav">
|
|
126
|
-
<SectionLabel><SkeletonBox width={60} height={9} /></SectionLabel>
|
|
127
|
-
{[0, 1, 2].map((i) => <NavRowSkeleton key={i} />)}
|
|
128
|
-
<View style={{ height: t.space(2) }} />
|
|
129
|
-
<SectionLabel><SkeletonBox width={72} height={9} /></SectionLabel>
|
|
130
|
-
{[0, 1].map((i) => <NavRowSkeleton key={i} />)}
|
|
131
|
-
</Pane>
|
|
132
|
-
|
|
133
|
-
{/* entries */}
|
|
124
|
+
{/* master: the sections list, which is what the editor opens on */}
|
|
134
125
|
<Pane
|
|
135
|
-
width={
|
|
136
|
-
testID="skeleton-pane-
|
|
126
|
+
width={t.layout.master}
|
|
127
|
+
testID="skeleton-pane-master"
|
|
137
128
|
header={
|
|
138
129
|
<>
|
|
139
|
-
<View style={{ flex: 1 }}><SkeletonBox width={
|
|
140
|
-
<SkeletonBox width={
|
|
130
|
+
<View style={{ flex: 1 }}><SkeletonBox width={72} height={16} /></View>
|
|
131
|
+
<SkeletonBox width={t.control.sm} height={t.control.sm} radius={t.radius.md} />
|
|
141
132
|
</>
|
|
142
133
|
}
|
|
143
134
|
>
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
135
|
+
<SectionLabel><SkeletonBox width={60} height={9} /></SectionLabel>
|
|
136
|
+
{[0, 1, 2].map((i) => <NavRowSkeleton key={i} />)}
|
|
137
|
+
<View style={{ height: t.space(2) }} />
|
|
138
|
+
<SectionLabel><SkeletonBox width={72} height={9} /></SectionLabel>
|
|
139
|
+
{[0, 1].map((i) => <NavRowSkeleton key={i} />)}
|
|
148
140
|
</Pane>
|
|
149
141
|
|
|
150
142
|
{/* detail */}
|
|
@@ -204,7 +196,8 @@ function MobileSkeleton() {
|
|
|
204
196
|
|
|
205
197
|
/**
|
|
206
198
|
* Full-shell loading placeholder shown while an autologin token is being
|
|
207
|
-
* exchanged. Mirrors the ContentBrowser layout (
|
|
199
|
+
* exchanged. Mirrors the ContentBrowser layout (the master pane and the detail
|
|
200
|
+
* region, at the same widths and with the same borders)
|
|
208
201
|
* so the real content mounts into place without a jarring layout shift — and the
|
|
209
202
|
* login form never flashes on the way in.
|
|
210
203
|
*/
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { View, Pressable } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import type { IconName } from "../icons";
|
|
5
|
+
import { Text } from "./Text";
|
|
6
|
+
import { Icon } from "./Icon";
|
|
7
|
+
import { Avatar, Divider } from "./primitives";
|
|
8
|
+
|
|
9
|
+
export type WorkspaceRef = { id: string; name: string; slug?: string };
|
|
10
|
+
|
|
11
|
+
// A row under the workspace list that does something other than switch, e.g.
|
|
12
|
+
// opening the workspace's overview.
|
|
13
|
+
export type WorkspaceMenuAction = {
|
|
14
|
+
label: string;
|
|
15
|
+
icon?: IconName;
|
|
16
|
+
onSelect: () => void;
|
|
17
|
+
testID?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type WorkspaceMenuProps = {
|
|
21
|
+
/** Every workspace the signed-in user belongs to. */
|
|
22
|
+
workspaces: WorkspaceRef[];
|
|
23
|
+
/** The workspace being shown, by id. */
|
|
24
|
+
current?: string;
|
|
25
|
+
onSelect: (workspace: WorkspaceRef) => void;
|
|
26
|
+
/** Rows under the list, divided from it. */
|
|
27
|
+
actions?: WorkspaceMenuAction[];
|
|
28
|
+
/**
|
|
29
|
+
* What the menu is of. "folder" is a folder on disk standing where a
|
|
30
|
+
* workspace would (the desktop app's local mode): drawn with a folder
|
|
31
|
+
* rather than a monogram, its path (given as the slug) under its name.
|
|
32
|
+
*/
|
|
33
|
+
kind?: "workspace" | "folder";
|
|
34
|
+
testID?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// workspaceInitials builds the one or two letter monogram the avatar shows.
|
|
38
|
+
export function workspaceInitials(name: string): string {
|
|
39
|
+
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
40
|
+
if (parts.length === 0) return "?";
|
|
41
|
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
42
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The top bar's workspace switcher: the workspace's monogram and name, which
|
|
47
|
+
* opens the list of every workspace the user belongs to. It takes the place of
|
|
48
|
+
* the static workspace tag for a host that can move between workspaces (the
|
|
49
|
+
* desktop app); the web editor is addressed to one workspace by its URL and
|
|
50
|
+
* keeps the tag.
|
|
51
|
+
*
|
|
52
|
+
* It is a menu even with one workspace: the list confirms where you are, and
|
|
53
|
+
* the actions below it still apply.
|
|
54
|
+
*/
|
|
55
|
+
export function WorkspaceMenu({ workspaces, current, onSelect, actions, kind = "workspace", testID = "workspace" }: WorkspaceMenuProps) {
|
|
56
|
+
const t = useTheme();
|
|
57
|
+
const [open, setOpen] = useState(false);
|
|
58
|
+
const active = workspaces.find((w) => w.id === current);
|
|
59
|
+
const folder = kind === "folder";
|
|
60
|
+
const name = active?.name ?? (folder ? "Folder" : "Workspace");
|
|
61
|
+
|
|
62
|
+
const rowStyle = ({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
|
|
63
|
+
flexDirection: "row" as const, alignItems: "center" as const, gap: t.space(2),
|
|
64
|
+
paddingHorizontal: t.space(3), paddingVertical: t.space(2),
|
|
65
|
+
backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<View style={{ position: "relative" }}>
|
|
70
|
+
<Pressable
|
|
71
|
+
testID={`${testID}-menu`}
|
|
72
|
+
accessibilityRole="button"
|
|
73
|
+
accessibilityLabel={folder ? `Folder ${name}` : `Workspace ${name}. Switch workspace`}
|
|
74
|
+
onPress={() => setOpen((v) => !v)}
|
|
75
|
+
style={({ pressed }) => ({
|
|
76
|
+
flexDirection: "row", alignItems: "center", gap: 8,
|
|
77
|
+
opacity: pressed ? 0.6 : 1,
|
|
78
|
+
})}
|
|
79
|
+
>
|
|
80
|
+
{folder ? (
|
|
81
|
+
<View
|
|
82
|
+
style={{
|
|
83
|
+
width: 24, height: 24, borderRadius: t.radius.sm, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
84
|
+
backgroundColor: t.color.surfaceSunken, alignItems: "center", justifyContent: "center",
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<Icon name="folder" size={13} color={t.color.textSecondary} />
|
|
88
|
+
</View>
|
|
89
|
+
) : (
|
|
90
|
+
<Avatar initials={workspaceInitials(name)} size={24} />
|
|
91
|
+
)}
|
|
92
|
+
<Text variant="body" weight="semibold" numberOfLines={1} testID={`${testID}-name`}>{name}</Text>
|
|
93
|
+
<Icon name="chevronDown" size={12} color={t.color.textTertiary} />
|
|
94
|
+
</Pressable>
|
|
95
|
+
{open ? (
|
|
96
|
+
<>
|
|
97
|
+
{/* Full-screen catcher so a click anywhere dismisses, matching how the
|
|
98
|
+
other top-bar menus close. */}
|
|
99
|
+
<Pressable
|
|
100
|
+
onPress={() => setOpen(false)}
|
|
101
|
+
style={{ position: "absolute", top: -2000, left: -2000, right: -2000, bottom: -2000 }}
|
|
102
|
+
testID={`${testID}-backdrop`}
|
|
103
|
+
/>
|
|
104
|
+
<View
|
|
105
|
+
testID={`${testID}-menu-list`}
|
|
106
|
+
style={{
|
|
107
|
+
position: "absolute", top: 24 + 8, left: 0, minWidth: 240, maxWidth: 420, zIndex: 50,
|
|
108
|
+
backgroundColor: t.color.surfaceRaised, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
109
|
+
borderRadius: t.radius.md, paddingVertical: t.space(1),
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<View style={{ paddingHorizontal: t.space(3), paddingTop: t.space(1.5), paddingBottom: t.space(1) }}>
|
|
113
|
+
<Text variant="label" color="tertiary">{folder ? "Folder" : "Workspaces"}</Text>
|
|
114
|
+
</View>
|
|
115
|
+
{workspaces.map((w) => (
|
|
116
|
+
<Pressable
|
|
117
|
+
key={w.id}
|
|
118
|
+
testID={`${testID}-option-${w.slug || w.id}`}
|
|
119
|
+
accessibilityRole="menuitem"
|
|
120
|
+
accessibilityState={{ selected: w.id === current }}
|
|
121
|
+
onPress={() => {
|
|
122
|
+
setOpen(false);
|
|
123
|
+
if (w.id !== current) onSelect(w);
|
|
124
|
+
}}
|
|
125
|
+
style={rowStyle}
|
|
126
|
+
>
|
|
127
|
+
<Icon
|
|
128
|
+
name={w.id === current ? "check" : folder ? "folder" : "building"}
|
|
129
|
+
size={13}
|
|
130
|
+
color={w.id === current ? t.color.textPrimary : t.color.textTertiary}
|
|
131
|
+
/>
|
|
132
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
133
|
+
<Text variant="sm" numberOfLines={1}>{w.name}</Text>
|
|
134
|
+
{w.slug ? <Text variant="monoSm" color="tertiary" numberOfLines={1}>{w.slug}</Text> : null}
|
|
135
|
+
</View>
|
|
136
|
+
</Pressable>
|
|
137
|
+
))}
|
|
138
|
+
{actions?.length ? (
|
|
139
|
+
<>
|
|
140
|
+
<Divider style={{ marginVertical: t.space(1) }} />
|
|
141
|
+
{actions.map((a, i) => (
|
|
142
|
+
<Pressable
|
|
143
|
+
key={`${a.label}-${i}`}
|
|
144
|
+
testID={a.testID ?? `${testID}-action-${i}`}
|
|
145
|
+
accessibilityRole="menuitem"
|
|
146
|
+
onPress={() => {
|
|
147
|
+
setOpen(false);
|
|
148
|
+
a.onSelect();
|
|
149
|
+
}}
|
|
150
|
+
style={rowStyle}
|
|
151
|
+
>
|
|
152
|
+
<Icon name={a.icon ?? "grid"} size={13} color={t.color.textTertiary} />
|
|
153
|
+
<Text variant="sm" numberOfLines={1}>{a.label}</Text>
|
|
154
|
+
</Pressable>
|
|
155
|
+
))}
|
|
156
|
+
</>
|
|
157
|
+
) : null}
|
|
158
|
+
</View>
|
|
159
|
+
</>
|
|
160
|
+
) : null}
|
|
161
|
+
</View>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -28,6 +28,9 @@ export type DocumentDraft = {
|
|
|
28
28
|
|
|
29
29
|
const PREFIX = "gitcms.draft.v1:";
|
|
30
30
|
|
|
31
|
+
// The key the store is probed with, which is never a draft.
|
|
32
|
+
const PROBE = PREFIX + "probe";
|
|
33
|
+
|
|
31
34
|
// The in-memory fallback, also the store under a browser that refuses
|
|
32
35
|
// localStorage (Safari in private mode throws on write, not on access).
|
|
33
36
|
const memory = new Map<string, string>();
|
|
@@ -39,16 +42,26 @@ type Store = {
|
|
|
39
42
|
keys(): string[];
|
|
40
43
|
};
|
|
41
44
|
|
|
45
|
+
// The store, chosen once. The probe below writes to localStorage, and every
|
|
46
|
+
// write is a storage event in the origin's other pages. Probed on every read,
|
|
47
|
+
// two open editors answered each other forever: the event invalidated the
|
|
48
|
+
// list, whose re-render read the store, which probed again.
|
|
49
|
+
let chosen: Store | null = null;
|
|
50
|
+
|
|
42
51
|
function store(): Store {
|
|
52
|
+
if (!chosen) chosen = choose();
|
|
53
|
+
return chosen;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function choose(): Store {
|
|
43
57
|
try {
|
|
44
58
|
const ls = typeof localStorage !== "undefined" ? localStorage : null;
|
|
45
59
|
if (ls) {
|
|
46
60
|
// Probed rather than assumed: private-mode Safari exposes localStorage and
|
|
47
61
|
// throws QuotaExceededError on the first write, and finding that out on
|
|
48
62
|
// the user's first keystroke is worse than finding it out now.
|
|
49
|
-
|
|
50
|
-
ls.
|
|
51
|
-
ls.removeItem(probe);
|
|
63
|
+
ls.setItem(PROBE, "1");
|
|
64
|
+
ls.removeItem(PROBE);
|
|
52
65
|
return {
|
|
53
66
|
get: (k) => ls.getItem(k),
|
|
54
67
|
set: (k, v) => ls.setItem(k, v),
|
|
@@ -95,7 +108,7 @@ function ids(): Set<string> {
|
|
|
95
108
|
cache = new Set(
|
|
96
109
|
store()
|
|
97
110
|
.keys()
|
|
98
|
-
.filter((k) => k.startsWith(PREFIX))
|
|
111
|
+
.filter((k) => k.startsWith(PREFIX) && k !== PROBE)
|
|
99
112
|
.map((k) => k.slice(PREFIX.length)),
|
|
100
113
|
);
|
|
101
114
|
}
|
|
@@ -118,6 +131,8 @@ export function subscribeDrafts(onChange: () => void): () => void {
|
|
|
118
131
|
if (!storageBound && typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
119
132
|
storageBound = true;
|
|
120
133
|
window.addEventListener("storage", (e) => {
|
|
134
|
+
// Another page probing its store has written no draft.
|
|
135
|
+
if (e.key === PROBE) return;
|
|
121
136
|
if (!e.key || e.key.startsWith(PREFIX)) invalidate();
|
|
122
137
|
});
|
|
123
138
|
}
|
|
@@ -39,24 +39,50 @@ export function Screen({
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* What a host that draws its own window chrome needs from the top bar: room at
|
|
44
|
+
* the leading edge for the platform's window controls (the macOS traffic
|
|
45
|
+
* lights, when the app hides the title bar), and the bar marked as the place
|
|
46
|
+
* the window is dragged by. The web and mobile apps have a browser or an OS
|
|
47
|
+
* doing this for them and pass nothing.
|
|
48
|
+
*/
|
|
49
|
+
export type WindowChrome = {
|
|
50
|
+
/** Width, in px, kept clear at the bar's leading edge. */
|
|
51
|
+
leadingInset?: number;
|
|
52
|
+
/**
|
|
53
|
+
* The bar's height, when it has to match the platform's title bar: the
|
|
54
|
+
* controls drawn over it are centred on that, not on the bar.
|
|
55
|
+
*/
|
|
56
|
+
height?: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
42
59
|
/** Horizontal top bar with left / center / right slots. */
|
|
43
60
|
export function TopBar({
|
|
44
61
|
left,
|
|
45
62
|
center,
|
|
46
63
|
right,
|
|
64
|
+
windowChrome,
|
|
47
65
|
}: {
|
|
48
66
|
left?: React.ReactNode;
|
|
49
67
|
center?: React.ReactNode;
|
|
50
68
|
right?: React.ReactNode;
|
|
69
|
+
windowChrome?: WindowChrome;
|
|
51
70
|
}) {
|
|
52
71
|
const t = useTheme();
|
|
53
72
|
return (
|
|
54
73
|
<View
|
|
74
|
+
// The bar is where a chromeless window is dragged from. The host reads
|
|
75
|
+
// this attribute (data-window-drag) on mousedown, and only over the bar
|
|
76
|
+
// itself: the controls on it stay controls.
|
|
77
|
+
// @ts-expect-error react-native-web accepts dataSet -> data-* attributes
|
|
78
|
+
dataSet={windowChrome ? { windowDrag: "true" } : undefined}
|
|
79
|
+
testID="top-bar"
|
|
55
80
|
style={{
|
|
56
|
-
height: t.layout.topbar,
|
|
81
|
+
height: windowChrome?.height ?? t.layout.topbar,
|
|
57
82
|
flexDirection: "row",
|
|
58
83
|
alignItems: "center",
|
|
59
84
|
paddingHorizontal: t.space(3),
|
|
85
|
+
paddingLeft: t.space(3) + (windowChrome?.leadingInset ?? 0),
|
|
60
86
|
gap: t.space(3),
|
|
61
87
|
borderBottomWidth: 1,
|
|
62
88
|
// Hairline appropriate to the dark chrome in both modes.
|
|
@@ -176,6 +202,12 @@ export function Pane({
|
|
|
176
202
|
gap: t.space(2),
|
|
177
203
|
borderBottomWidth: 1,
|
|
178
204
|
borderBottomColor: t.color.borderSubtle,
|
|
205
|
+
// Above the body, so a menu opened from the header (the master
|
|
206
|
+
// pane's title switcher) paints over the list beneath it rather
|
|
207
|
+
// than under it. Views stack in tree order otherwise, and the body
|
|
208
|
+
// comes second.
|
|
209
|
+
position: "relative",
|
|
210
|
+
zIndex: 10,
|
|
179
211
|
}}
|
|
180
212
|
>
|
|
181
213
|
{header}
|
package/src/icons.ts
CHANGED
|
@@ -47,6 +47,11 @@ export const icons = {
|
|
|
47
47
|
copy: ["M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2Z", "M4 16a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2"],
|
|
48
48
|
lock: ["M5 11h14a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2Z", "M7 11V7a5 5 0 0 1 10 0v4"],
|
|
49
49
|
panelLeft: ["M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2Z", "M9 3v18"],
|
|
50
|
+
// Corner arrows (maximize/minimize): the pair the markdown editor already
|
|
51
|
+
// draws for its focus mode, so "take over the screen" reads the same whether
|
|
52
|
+
// it is a field or a whole pane doing it.
|
|
53
|
+
maximize: ["M15 3h6v6", "M9 21H3v-6", "m21 3-7 7", "m3 21 7-7"],
|
|
54
|
+
minimize: ["M4 14h6v6", "M20 10h-6V4", "m14 10 7-7", "m3 21 7-7"],
|
|
50
55
|
more: ["M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z", "M19 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z", "M5 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"],
|
|
51
56
|
gripVertical: [
|
|
52
57
|
"M9 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z", "M9 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z", "M9 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z",
|