@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.
Files changed (33) hide show
  1. package/README.md +5 -2
  2. package/css/tokens.css +1 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/BranchMenu.test.tsx +38 -0
  5. package/src/__tests__/ContentBrowser.changes.test.tsx +23 -14
  6. package/src/__tests__/ContentBrowser.contentslot.test.tsx +10 -6
  7. package/src/__tests__/ContentBrowser.expand.test.tsx +170 -0
  8. package/src/__tests__/ContentBrowser.forms.test.tsx +25 -20
  9. package/src/__tests__/ContentBrowser.listrow.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.master.test.tsx +147 -0
  11. package/src/__tests__/ContentBrowser.media.test.tsx +2 -1
  12. package/src/__tests__/ContentBrowser.references.test.tsx +3 -3
  13. package/src/__tests__/ContentBrowser.reorder.test.tsx +2 -3
  14. package/src/__tests__/ContentBrowser.window.test.tsx +203 -0
  15. package/src/__tests__/MediaBrowser.test.tsx +15 -9
  16. package/src/__tests__/ProtectedBranchModal.test.tsx +14 -0
  17. package/src/__tests__/cssTokens.test.ts +1 -0
  18. package/src/__tests__/documentDrafts.test.ts +59 -0
  19. package/src/components/BranchMenu.tsx +14 -4
  20. package/src/components/ContentBrowser.tsx +584 -460
  21. package/src/components/Input.tsx +37 -5
  22. package/src/components/MediaField.tsx +2 -2
  23. package/src/components/Notifications.tsx +10 -1
  24. package/src/components/ProjectMenu.tsx +100 -26
  25. package/src/components/ProtectedBranchModal.tsx +19 -7
  26. package/src/components/Skeleton.tsx +12 -19
  27. package/src/components/WorkspaceMenu.tsx +163 -0
  28. package/src/components/documentDrafts.ts +19 -4
  29. package/src/components/layout.tsx +33 -1
  30. package/src/icons.ts +5 -0
  31. package/src/index.ts +4 -1
  32. package/src/references.ts +1 -1
  33. package/src/theme.ts +3 -1
@@ -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
  }
@@ -61,7 +61,7 @@ export type MediaFieldProps = {
61
61
  // object field and an empty string field look identical at runtime — and
62
62
  // guessing would write the wrong shape into the document on first pick.
63
63
  shape: "string" | "object";
64
- // The media set this field browses, from the config's display.media.
64
+ // The media set this field browses, from the config's media block.
65
65
  set?: string;
66
66
  // Which form a string field writes. Ignored for the object form, which stores
67
67
  // the asset id.
@@ -388,7 +388,7 @@ function AmbiguousNotice({
388
388
  // for an image without knowing anything about the picker UI.
389
389
  //
390
390
  // `set` defaults to the first configured set when the field names none (a body
391
- // field has no display.media), and the picker's own switcher covers the rest.
391
+ // field has no media block), and the picker's own switcher covers the rest.
392
392
  export function useFieldMedia(opts: { set?: string; storeAs?: string }): {
393
393
  media: FieldMediaApi | undefined;
394
394
  picker: React.ReactNode;
@@ -127,10 +127,16 @@ function NotificationRow({
127
127
  item,
128
128
  divided,
129
129
  onPress,
130
+ // How much of the body a row shows. Two lines suits the bell's menu, which is
131
+ // a glance at what happened; the full-screen list gives it more, because the
132
+ // body of an error notification is the error itself and a reader who opened
133
+ // the list to find out what went wrong has nowhere else to read it.
134
+ bodyLines = 2,
130
135
  }: {
131
136
  item: NotificationItem;
132
137
  divided?: boolean;
133
138
  onPress?: () => void;
139
+ bodyLines?: number;
134
140
  }) {
135
141
  const t = useTheme();
136
142
  const titleColor = item.level === "error" ? t.color.diffDelFg : t.color.textPrimary;
@@ -199,7 +205,9 @@ function NotificationRow({
199
205
  </View>
200
206
  <Text variant="monoSm" color="tertiary">{relativeTime(item.createdAt)}</Text>
201
207
  </View>
202
- {item.body ? <InlineCode text={item.body} variant="monoSm" color={t.color.textSecondary} numberOfLines={2} /> : null}
208
+ {item.body ? (
209
+ <InlineCode text={item.body} variant="monoSm" color={t.color.textSecondary} numberOfLines={bodyLines} />
210
+ ) : null}
203
211
  {meta ? (
204
212
  <Text variant="monoSm" color="tertiary" numberOfLines={1}>{meta}</Text>
205
213
  ) : null}
@@ -412,6 +420,7 @@ export function NotificationList({
412
420
  item={n}
413
421
  divided={i < notifications.length - 1}
414
422
  onPress={onPressItem ? () => onPressItem(n) : undefined}
423
+ bodyLines={8}
415
424
  />
416
425
  ))}
417
426
  </ScrollView>
@@ -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 (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, 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 (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.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,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
- 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
  }