@gogitcms/design-system 0.16.0-next.13 → 0.16.0-next.15

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.
@@ -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: error
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
  }
@@ -8,7 +8,7 @@ import type { IconName } from "../icons";
8
8
  import { Spinner } from "./Spinner";
9
9
 
10
10
  // One notification, shaped for display. Transport-agnostic: the frontend/mobile
11
- // apps map GraphQL rows and the desktop and dashboard map REST rows into this.
11
+ // apps map GraphQL rows and the dashboard maps REST rows into this.
12
12
  export type NotificationItem = {
13
13
  id: string;
14
14
  title: string;
@@ -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 = { name: string; label: string };
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 (one covering
15
+ * a whole workspace) sets its own, since two of them may each have a
16
+ * "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, by manifest name. */
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 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.name === current);
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
- <Pressable
98
- key={p.name}
99
- onPress={() => { setOpen(false); onSelect(p); }}
100
- style={{
101
- flexDirection: "row", alignItems: "center", gap: 8,
102
- paddingHorizontal: t.space(3), paddingVertical: t.space(2),
103
- }}
104
- testID={`${testID}-option-${p.name || "default"}`}
105
- >
106
- <Icon
107
- name={p.name === current ? "check" : "folder"}
108
- size={13}
109
- color={p.name === current ? t.color.textPrimary : t.color.textTertiary}
110
- />
111
- <View style={{ flex: 1 }}>
112
- <Text variant="sm" numberOfLines={1}>{p.label || p.name}</Text>
113
- {p.label && p.label !== p.name ? (
114
- <Text variant="monoSm" color="tertiary" numberOfLines={1}>{p.name}</Text>
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
- </Pressable>
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
- /** Create `name` from `branch` and save the pending edit onto it. */
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 [name, setName] = useState(suggestedName ?? "");
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
- }, [suggestedName, touched]);
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
- {/* nav */}
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={340}
136
- testID="skeleton-pane-entries"
126
+ width={t.layout.master}
127
+ testID="skeleton-pane-master"
137
128
  header={
138
129
  <>
139
- <View style={{ flex: 1 }}><SkeletonBox width={120} height={16} /></View>
140
- <SkeletonBox width={72} height={t.control.sm} radius={t.radius.md} />
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
- <View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }}>
145
- <SkeletonBox width={150} height={10} />
146
- </View>
147
- {[0, 1, 2, 3, 4, 5].map((i) => <EntryRowSkeleton key={i} />)}
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 (same panes, widths, and borders)
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,162 @@
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 (an 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
+ * web editor is addressed to one workspace by its URL and keeps the tag.
50
+ *
51
+ * It is a menu even with one workspace: the list confirms where you are, and
52
+ * the actions below it still apply.
53
+ */
54
+ export function WorkspaceMenu({ workspaces, current, onSelect, actions, kind = "workspace", testID = "workspace" }: WorkspaceMenuProps) {
55
+ const t = useTheme();
56
+ const [open, setOpen] = useState(false);
57
+ const active = workspaces.find((w) => w.id === current);
58
+ const folder = kind === "folder";
59
+ const name = active?.name ?? (folder ? "Folder" : "Workspace");
60
+
61
+ const rowStyle = ({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
62
+ flexDirection: "row" as const, alignItems: "center" as const, gap: t.space(2),
63
+ paddingHorizontal: t.space(3), paddingVertical: t.space(2),
64
+ backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
65
+ });
66
+
67
+ return (
68
+ <View style={{ position: "relative" }}>
69
+ <Pressable
70
+ testID={`${testID}-menu`}
71
+ accessibilityRole="button"
72
+ accessibilityLabel={folder ? `Folder ${name}` : `Workspace ${name}. Switch workspace`}
73
+ onPress={() => setOpen((v) => !v)}
74
+ style={({ pressed }) => ({
75
+ flexDirection: "row", alignItems: "center", gap: 8,
76
+ opacity: pressed ? 0.6 : 1,
77
+ })}
78
+ >
79
+ {folder ? (
80
+ <View
81
+ style={{
82
+ width: 24, height: 24, borderRadius: t.radius.sm, borderWidth: 1, borderColor: t.color.borderDefault,
83
+ backgroundColor: t.color.surfaceSunken, alignItems: "center", justifyContent: "center",
84
+ }}
85
+ >
86
+ <Icon name="folder" size={13} color={t.color.textSecondary} />
87
+ </View>
88
+ ) : (
89
+ <Avatar initials={workspaceInitials(name)} size={24} />
90
+ )}
91
+ <Text variant="body" weight="semibold" numberOfLines={1} testID={`${testID}-name`}>{name}</Text>
92
+ <Icon name="chevronDown" size={12} color={t.color.textTertiary} />
93
+ </Pressable>
94
+ {open ? (
95
+ <>
96
+ {/* Full-screen catcher so a click anywhere dismisses, matching how the
97
+ other top-bar menus close. */}
98
+ <Pressable
99
+ onPress={() => setOpen(false)}
100
+ style={{ position: "absolute", top: -2000, left: -2000, right: -2000, bottom: -2000 }}
101
+ testID={`${testID}-backdrop`}
102
+ />
103
+ <View
104
+ testID={`${testID}-menu-list`}
105
+ style={{
106
+ position: "absolute", top: 24 + 8, left: 0, minWidth: 240, maxWidth: 420, zIndex: 50,
107
+ backgroundColor: t.color.surfaceRaised, borderWidth: 1, borderColor: t.color.borderDefault,
108
+ borderRadius: t.radius.md, paddingVertical: t.space(1),
109
+ }}
110
+ >
111
+ <View style={{ paddingHorizontal: t.space(3), paddingTop: t.space(1.5), paddingBottom: t.space(1) }}>
112
+ <Text variant="label" color="tertiary">{folder ? "Folder" : "Workspaces"}</Text>
113
+ </View>
114
+ {workspaces.map((w) => (
115
+ <Pressable
116
+ key={w.id}
117
+ testID={`${testID}-option-${w.slug || w.id}`}
118
+ accessibilityRole="menuitem"
119
+ accessibilityState={{ selected: w.id === current }}
120
+ onPress={() => {
121
+ setOpen(false);
122
+ if (w.id !== current) onSelect(w);
123
+ }}
124
+ style={rowStyle}
125
+ >
126
+ <Icon
127
+ name={w.id === current ? "check" : folder ? "folder" : "building"}
128
+ size={13}
129
+ color={w.id === current ? t.color.textPrimary : t.color.textTertiary}
130
+ />
131
+ <View style={{ flex: 1, minWidth: 0 }}>
132
+ <Text variant="sm" numberOfLines={1}>{w.name}</Text>
133
+ {w.slug ? <Text variant="monoSm" color="tertiary" numberOfLines={1}>{w.slug}</Text> : null}
134
+ </View>
135
+ </Pressable>
136
+ ))}
137
+ {actions?.length ? (
138
+ <>
139
+ <Divider style={{ marginVertical: t.space(1) }} />
140
+ {actions.map((a, i) => (
141
+ <Pressable
142
+ key={`${a.label}-${i}`}
143
+ testID={a.testID ?? `${testID}-action-${i}`}
144
+ accessibilityRole="menuitem"
145
+ onPress={() => {
146
+ setOpen(false);
147
+ a.onSelect();
148
+ }}
149
+ style={rowStyle}
150
+ >
151
+ <Icon name={a.icon ?? "grid"} size={13} color={t.color.textTertiary} />
152
+ <Text variant="sm" numberOfLines={1}>{a.label}</Text>
153
+ </Pressable>
154
+ ))}
155
+ </>
156
+ ) : null}
157
+ </View>
158
+ </>
159
+ ) : null}
160
+ </View>
161
+ );
162
+ }
@@ -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
- const probe = PREFIX + "probe";
50
- ls.setItem(probe, "1");
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}