@gogitcms/design-system 0.15.0-next.3

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 (70) hide show
  1. package/README.md +77 -0
  2. package/css/components.css +312 -0
  3. package/css/tokens.css +212 -0
  4. package/package.json +65 -0
  5. package/src/ThemeProvider.tsx +86 -0
  6. package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  7. package/src/__tests__/BranchImport.test.tsx +46 -0
  8. package/src/__tests__/Button.test.tsx +45 -0
  9. package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  11. package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  12. package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  13. package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  14. package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  15. package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  16. package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  17. package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  18. package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  19. package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  20. package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  21. package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  22. package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  23. package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  24. package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  25. package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  26. package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  27. package/src/__tests__/MediaBrowser.test.tsx +353 -0
  28. package/src/__tests__/MediaField.test.tsx +185 -0
  29. package/src/__tests__/Notifications.test.tsx +69 -0
  30. package/src/__tests__/Onboarding.test.tsx +287 -0
  31. package/src/__tests__/cssTokens.test.ts +201 -0
  32. package/src/__tests__/fieldComponents.test.ts +43 -0
  33. package/src/__tests__/reorder.test.ts +58 -0
  34. package/src/components/ApplyChangesModal.tsx +348 -0
  35. package/src/components/BranchImport.tsx +157 -0
  36. package/src/components/BranchMenu.tsx +192 -0
  37. package/src/components/Button.tsx +131 -0
  38. package/src/components/ChangeDetail.tsx +472 -0
  39. package/src/components/ChangeRequestSummary.tsx +173 -0
  40. package/src/components/CollabField.tsx +388 -0
  41. package/src/components/ContentBrowser.tsx +5073 -0
  42. package/src/components/Icon.tsx +28 -0
  43. package/src/components/Icon.web.tsx +31 -0
  44. package/src/components/Input.tsx +106 -0
  45. package/src/components/MediaBrowser.tsx +766 -0
  46. package/src/components/MediaField.tsx +670 -0
  47. package/src/components/MediaPreview.tsx +91 -0
  48. package/src/components/MediaPreview.web.tsx +169 -0
  49. package/src/components/NavRow.tsx +105 -0
  50. package/src/components/Notifications.tsx +301 -0
  51. package/src/components/Onboarding.tsx +751 -0
  52. package/src/components/ProjectMenu.tsx +124 -0
  53. package/src/components/Segment.tsx +87 -0
  54. package/src/components/Skeleton.tsx +216 -0
  55. package/src/components/Spinner.tsx +44 -0
  56. package/src/components/Text.tsx +85 -0
  57. package/src/components/documentDrafts.ts +213 -0
  58. package/src/components/layout.tsx +284 -0
  59. package/src/components/primitives.tsx +143 -0
  60. package/src/components/reorder.ts +40 -0
  61. package/src/fieldComponents.ts +63 -0
  62. package/src/icons.ts +102 -0
  63. package/src/index.ts +198 -0
  64. package/src/media.ts +229 -0
  65. package/src/theme.ts +116 -0
  66. package/src/web/Button.tsx +110 -0
  67. package/src/web/Icon.tsx +52 -0
  68. package/src/web/Input.tsx +39 -0
  69. package/src/web/index.ts +43 -0
  70. package/src/web/primitives.tsx +119 -0
@@ -0,0 +1,91 @@
1
+ import React from "react";
2
+ import { View, Image, Linking, Pressable } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { MediaAsset, formatBytes, kindIcon } from "../media";
7
+
8
+ // Native previews. (Web renders through MediaPreview.web.tsx via platform
9
+ // resolution, and gets real <video>/<audio>/<object> playback.)
10
+ //
11
+ // Native deliberately stops at images: video, audio and PDF playback would each
12
+ // pull in a separate native dependency, and the editor's job on a phone is to
13
+ // let you see *which* asset a field points at, not to be a media player. Those
14
+ // kinds render the same chip as any other file, which opens the asset in the
15
+ // system browser on tap.
16
+
17
+ export type MediaPreviewProps = {
18
+ media: MediaAsset;
19
+ maxHeight?: number;
20
+ compact?: boolean;
21
+ };
22
+
23
+ export function MediaPreview({ media, maxHeight = 240, compact }: MediaPreviewProps) {
24
+ const t = useTheme();
25
+
26
+ // SVG needs react-native-svg's SvgUri, an optional peer dependency. Rendering
27
+ // it through <Image> would silently show nothing on Android, so it takes the
28
+ // chip path instead — visible and honest.
29
+ const renderable = media.kind === "image";
30
+ if (compact || !renderable) return <MediaChip media={media} />;
31
+
32
+ return (
33
+ <View
34
+ style={{
35
+ borderWidth: 1,
36
+ borderColor: t.color.borderDefault,
37
+ borderRadius: t.radius.md,
38
+ backgroundColor: t.color.surfaceSunken,
39
+ overflow: "hidden",
40
+ }}
41
+ >
42
+ <Image
43
+ source={{ uri: media.url }}
44
+ accessibilityLabel={media.repoPath}
45
+ resizeMode="contain"
46
+ style={{ width: "100%", height: maxHeight }}
47
+ />
48
+ </View>
49
+ );
50
+ }
51
+
52
+ // MediaChip is the fallback representation: an icon, the file name, its size,
53
+ // and a tap target that opens the asset externally.
54
+ export function MediaChip({ media }: { media: MediaAsset }) {
55
+ const t = useTheme();
56
+ return (
57
+ <Pressable
58
+ onPress={() => Linking.openURL(media.url)}
59
+ style={{
60
+ flexDirection: "row",
61
+ alignItems: "center",
62
+ gap: t.space(3),
63
+ padding: t.space(3),
64
+ borderWidth: 1,
65
+ borderColor: t.color.borderDefault,
66
+ borderRadius: t.radius.md,
67
+ backgroundColor: t.color.surfaceRaised,
68
+ }}
69
+ >
70
+ <Icon name={kindIcon(media.kind)} size={20} color={t.color.textSecondary} />
71
+ <View style={{ flex: 1, minWidth: 0 }}>
72
+ <Text variant="body" numberOfLines={1}>
73
+ {fileName(media.repoPath)}
74
+ </Text>
75
+ <Text variant="xs" color="tertiary">
76
+ {[media.mimeType, formatBytes(media.size), dimensions(media)].filter(Boolean).join(" · ")}
77
+ </Text>
78
+ </View>
79
+ <Icon name="download" size={16} color={t.color.textSecondary} />
80
+ </Pressable>
81
+ );
82
+ }
83
+
84
+ export function fileName(repoPath: string): string {
85
+ const i = repoPath.lastIndexOf("/");
86
+ return i >= 0 ? repoPath.slice(i + 1) : repoPath;
87
+ }
88
+
89
+ export function dimensions(media: MediaAsset): string {
90
+ return media.width && media.height ? `${media.width}×${media.height}` : "";
91
+ }
@@ -0,0 +1,169 @@
1
+ import React from "react";
2
+ import { View } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import { MediaAsset, formatBytes, kindIcon } from "../media";
7
+
8
+ // Web previews. Native renders through MediaPreview.tsx, which falls back to a
9
+ // file chip for the kinds that need DOM media elements.
10
+
11
+ export type MediaPreviewProps = {
12
+ media: MediaAsset;
13
+ // Cap on the rendered height. The preview never exceeds it; images letterbox
14
+ // inside it rather than pushing the rest of the form off-screen.
15
+ maxHeight?: number;
16
+ // Compact renders the chip form regardless of kind — used in the picker grid
17
+ // and anywhere a full preview would be too heavy.
18
+ compact?: boolean;
19
+ };
20
+
21
+ export function MediaPreview({ media, maxHeight = 240, compact }: MediaPreviewProps) {
22
+ const t = useTheme();
23
+
24
+ if (compact) return <MediaChip media={media} />;
25
+
26
+ const frame = {
27
+ borderWidth: 1,
28
+ borderColor: t.color.borderDefault,
29
+ borderRadius: t.radius.md,
30
+ backgroundColor: t.color.surfaceSunken,
31
+ overflow: "hidden" as const,
32
+ };
33
+
34
+ switch (media.kind) {
35
+ case "image":
36
+ case "svg":
37
+ // Both render through <img>. SVG is loaded as an image rather than inlined
38
+ // so the document's scripts and styles cannot escape into this page.
39
+ return (
40
+ <View style={frame}>
41
+ <img
42
+ src={media.url}
43
+ alt={media.repoPath}
44
+ style={{
45
+ display: "block",
46
+ width: "100%",
47
+ maxHeight,
48
+ objectFit: "contain",
49
+ // A transparent PNG over a light surface should still read as
50
+ // transparent rather than as white.
51
+ background: "transparent",
52
+ }}
53
+ />
54
+ </View>
55
+ );
56
+
57
+ case "video":
58
+ return (
59
+ <View style={frame}>
60
+ <video
61
+ src={media.url}
62
+ controls
63
+ // Metadata only: an editor with several video fields should not pull
64
+ // megabytes for previews the author may never play.
65
+ preload="metadata"
66
+ style={{ display: "block", width: "100%", maxHeight }}
67
+ />
68
+ </View>
69
+ );
70
+
71
+ case "audio":
72
+ return (
73
+ <View style={{ gap: t.space(2) }}>
74
+ <MediaChip media={media} />
75
+ <audio src={media.url} controls preload="metadata" style={{ width: "100%" }} />
76
+ </View>
77
+ );
78
+
79
+ case "pdf":
80
+ return (
81
+ <View style={{ gap: t.space(2) }}>
82
+ <View style={{ ...frame, height: maxHeight }}>
83
+ {/* <object> degrades to its children when the browser has no PDF
84
+ viewer, which <iframe> does not. */}
85
+ <object
86
+ data={media.url}
87
+ type="application/pdf"
88
+ style={{ display: "block", width: "100%", height: "100%" }}
89
+ >
90
+ <View style={{ padding: t.space(4), gap: t.space(2), alignItems: "center" }}>
91
+ <Text variant="xs" color="tertiary">This browser can't display PDFs inline.</Text>
92
+ <MediaDownloadLink media={media} label="Open PDF" />
93
+ </View>
94
+ </object>
95
+ </View>
96
+ <MediaDownloadLink media={media} label="Open in new tab" />
97
+ </View>
98
+ );
99
+
100
+ default:
101
+ return <MediaChip media={media} />;
102
+ }
103
+ }
104
+
105
+ // MediaChip is the fallback representation: an icon, the file name, its size,
106
+ // and a download affordance. It is what every non-previewable kind gets, and
107
+ // what the picker grid uses for anything that is not an image.
108
+ export function MediaChip({ media }: { media: MediaAsset }) {
109
+ const t = useTheme();
110
+ return (
111
+ <View
112
+ style={{
113
+ flexDirection: "row",
114
+ alignItems: "center",
115
+ gap: t.space(3),
116
+ padding: t.space(3),
117
+ borderWidth: 1,
118
+ borderColor: t.color.borderDefault,
119
+ borderRadius: t.radius.md,
120
+ backgroundColor: t.color.surfaceRaised,
121
+ }}
122
+ >
123
+ <Icon name={kindIcon(media.kind)} size={20} color={t.color.textSecondary} />
124
+ <View style={{ flex: 1, minWidth: 0 }}>
125
+ <Text variant="body" numberOfLines={1}>
126
+ {fileName(media.repoPath)}
127
+ </Text>
128
+ <Text variant="xs" color="tertiary">
129
+ {[media.mimeType, formatBytes(media.size), dimensions(media)].filter(Boolean).join(" · ")}
130
+ </Text>
131
+ </View>
132
+ <MediaDownloadLink media={media} />
133
+ </View>
134
+ );
135
+ }
136
+
137
+ function MediaDownloadLink({ media, label }: { media: MediaAsset; label?: string }) {
138
+ const t = useTheme();
139
+ return (
140
+ <a
141
+ href={media.url}
142
+ target="_blank"
143
+ rel="noreferrer"
144
+ // download hints the filename for kinds a browser would otherwise navigate
145
+ // to; for previewable kinds the new tab is the more useful behaviour.
146
+ download={media.kind === "file" ? fileName(media.repoPath) : undefined}
147
+ style={{
148
+ display: "inline-flex",
149
+ alignItems: "center",
150
+ gap: 6,
151
+ color: t.color.textSecondary,
152
+ textDecoration: "none",
153
+ fontSize: 12,
154
+ }}
155
+ >
156
+ <Icon name="download" size={16} color={t.color.textSecondary} />
157
+ {label}
158
+ </a>
159
+ );
160
+ }
161
+
162
+ export function fileName(repoPath: string): string {
163
+ const i = repoPath.lastIndexOf("/");
164
+ return i >= 0 ? repoPath.slice(i + 1) : repoPath;
165
+ }
166
+
167
+ export function dimensions(media: MediaAsset): string {
168
+ return media.width && media.height ? `${media.width}×${media.height}` : "";
169
+ }
@@ -0,0 +1,105 @@
1
+ import React from "react";
2
+ import { Pressable, View } from "react-native";
3
+ import { useTheme } from "../ThemeProvider";
4
+ import { Text } from "./Text";
5
+ import { Icon } from "./Icon";
6
+ import type { IconName } from "../icons";
7
+
8
+ export type NavRowProps = {
9
+ label: string;
10
+ icon?: IconName;
11
+ count?: number | string;
12
+ /** Unresolved merge conflicts in this item, shown as a red pill. */
13
+ conflicts?: number;
14
+ meta?: string;
15
+ active?: boolean;
16
+ onPress?: () => void;
17
+ showChevron?: boolean;
18
+ divided?: boolean;
19
+ /** "compact" for the desktop sidebar, "large" for the mobile list. */
20
+ size?: "compact" | "large";
21
+ testID?: string;
22
+ };
23
+
24
+ export function NavRow({
25
+ label,
26
+ icon,
27
+ count,
28
+ conflicts,
29
+ meta,
30
+ active,
31
+ onPress,
32
+ showChevron,
33
+ divided,
34
+ size = "compact",
35
+ testID,
36
+ }: NavRowProps) {
37
+ const t = useTheme();
38
+ const large = size === "large";
39
+ const height = large ? 56 : t.control.lg;
40
+ const iconSize = large ? 20 : 16;
41
+
42
+ return (
43
+ <Pressable
44
+ testID={testID}
45
+ accessibilityRole="button"
46
+ accessibilityLabel={label}
47
+ // The active row was signalled only by a background colour, which conveys
48
+ // nothing to a screen reader — and left the current section unannounced.
49
+ aria-current={active ? "page" : undefined}
50
+ onPress={onPress}
51
+ style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
52
+ height,
53
+ // Content is inset, but the border sits on the full-width row so the
54
+ // divider still spans the whole screen on mobile.
55
+ paddingHorizontal: t.space(large ? 4 : 3),
56
+ flexDirection: "row",
57
+ alignItems: "center",
58
+ gap: t.space(3),
59
+ borderRadius: large ? 0 : t.radius.md,
60
+ backgroundColor:
61
+ active ? t.color.surfaceActive : hovered || pressed ? t.color.surfaceHover : "transparent",
62
+ borderBottomWidth: divided ? 1 : 0,
63
+ borderBottomColor: t.color.borderSubtle,
64
+ })}
65
+ >
66
+ {icon ? (
67
+ <Icon name={icon} size={iconSize} color={active ? t.color.textPrimary : t.color.textSecondary} />
68
+ ) : null}
69
+ <Text
70
+ variant={large ? "bodyLg" : "body"}
71
+ weight={active ? "medium" : "regular"}
72
+ color={active ? "primary" : "primary"}
73
+ style={{ flex: 1 }}
74
+ numberOfLines={1}
75
+ >
76
+ {label}
77
+ </Text>
78
+ {meta ? (
79
+ <Text variant="monoSm" color="tertiary">{meta}</Text>
80
+ ) : null}
81
+ {/* A red conflict pill takes the count's place while there are conflicts to
82
+ resolve — a collection with conflicts is the one that needs attention. */}
83
+ {conflicts && conflicts > 0 ? (
84
+ <View
85
+ testID={testID ? `${testID}-conflicts` : undefined}
86
+ style={{
87
+ minWidth: 18, height: 18, paddingHorizontal: t.space(1.5),
88
+ alignItems: "center", justifyContent: "center",
89
+ borderRadius: t.radius.pill, backgroundColor: t.color.diffDelBg,
90
+ borderWidth: 1, borderColor: t.color.diffDelFg,
91
+ }}
92
+ >
93
+ <Text variant="monoSm" weight="semibold" color={t.color.diffDelFg}>{String(conflicts)}</Text>
94
+ </View>
95
+ ) : count != null ? (
96
+ <Text variant={large ? "mono" : "monoSm"} color="tertiary">
97
+ {String(count)}
98
+ </Text>
99
+ ) : null}
100
+ {showChevron ? (
101
+ <Icon name="chevronRight" size={large ? 20 : 16} color={t.color.textTertiary} />
102
+ ) : null}
103
+ </Pressable>
104
+ );
105
+ }
@@ -0,0 +1,301 @@
1
+ import React, { useState } from "react";
2
+ import { View, Pressable, ScrollView } from "react-native";
3
+ import { useTheme, useThemeMode, ThemeScope } from "../ThemeProvider";
4
+ import { lightTheme, darkTheme } from "../theme";
5
+ import { Text } from "./Text";
6
+ import { Icon } from "./Icon";
7
+ import { Spinner } from "./Spinner";
8
+
9
+ // One notification, shaped for display. Transport-agnostic: the frontend/mobile
10
+ // apps map GraphQL rows and the desktop maps REST rows into this.
11
+ export type NotificationItem = {
12
+ id: string;
13
+ title: string;
14
+ body?: string;
15
+ level: "info" | "error";
16
+ read: boolean;
17
+ createdAt: string; // ISO 8601
18
+ author?: string; // display name/email of who caused it, when known
19
+ };
20
+
21
+ // relativeTime renders a compact age ("just now", "5m", "3h", "2d").
22
+ function relativeTime(iso: string): string {
23
+ const then = Date.parse(iso);
24
+ if (Number.isNaN(then)) return "";
25
+ const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
26
+ if (secs < 45) return "just now";
27
+ const mins = Math.floor(secs / 60);
28
+ if (mins < 60) return `${mins}m`;
29
+ const hours = Math.floor(mins / 60);
30
+ if (hours < 24) return `${hours}h`;
31
+ const days = Math.floor(hours / 24);
32
+ return `${days}d`;
33
+ }
34
+
35
+ // A single notification row: an unread dot + level-tinted title, body, and age.
36
+ function NotificationRow({
37
+ item,
38
+ divided,
39
+ onPress,
40
+ }: {
41
+ item: NotificationItem;
42
+ divided?: boolean;
43
+ onPress?: () => void;
44
+ }) {
45
+ const t = useTheme();
46
+ const titleColor = item.level === "error" ? t.color.diffDelFg : t.color.textPrimary;
47
+ return (
48
+ <Pressable
49
+ onPress={onPress}
50
+ style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
51
+ flexDirection: "row",
52
+ gap: t.space(2.5),
53
+ paddingHorizontal: t.space(4),
54
+ paddingVertical: t.space(3),
55
+ borderBottomWidth: divided ? 1 : 0,
56
+ borderBottomColor: t.color.borderSubtle,
57
+ backgroundColor: hovered || pressed ? t.color.surfaceHover : "transparent",
58
+ })}
59
+ >
60
+ <View
61
+ style={{
62
+ width: 8,
63
+ height: 8,
64
+ marginTop: 5,
65
+ borderRadius: t.radius.pill,
66
+ backgroundColor: item.read ? "transparent" : t.color.diffDelFg,
67
+ }}
68
+ />
69
+ <View style={{ flex: 1, gap: 2 }}>
70
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
71
+ <Text
72
+ variant="sm"
73
+ weight={item.read ? "medium" : "semibold"}
74
+ color={titleColor}
75
+ numberOfLines={2}
76
+ style={{ flex: 1 }}
77
+ >
78
+ {item.title}
79
+ </Text>
80
+ <Text variant="monoSm" color="tertiary">{relativeTime(item.createdAt)}</Text>
81
+ </View>
82
+ {item.body ? (
83
+ <Text variant="monoSm" color="secondary" numberOfLines={2}>{item.body}</Text>
84
+ ) : null}
85
+ {item.author ? (
86
+ <Text variant="monoSm" color="tertiary" numberOfLines={1}>{`by ${item.author}`}</Text>
87
+ ) : null}
88
+ </View>
89
+ </Pressable>
90
+ );
91
+ }
92
+
93
+ export type NotificationBellProps = {
94
+ notifications: NotificationItem[];
95
+ unreadCount: number;
96
+ // Called when the menu opens — the host marks the shown notifications read
97
+ // and/or refreshes the list.
98
+ onOpen?: () => void;
99
+ // Called when "Read more" is pressed — the host navigates to the full screen.
100
+ onViewAll?: () => void;
101
+ // Work is running in the background for whatever this bell is scoped to — an
102
+ // import over the open branch, today. The bell becomes a spinner for as long
103
+ // as it lasts: the outcome arrives as a notification, so the icon that will
104
+ // announce it is the honest place to say one is coming. The menu keeps
105
+ // working throughout; only the glyph changes.
106
+ busy?: boolean;
107
+ // What the spinner is waiting on, for the accessibility label. Ignored unless
108
+ // `busy`.
109
+ busyLabel?: string;
110
+ size?: "sm" | "md" | "lg";
111
+ testID?: string;
112
+ };
113
+
114
+ // NotificationBell is the bell button with an unread dot and a popup menu
115
+ // showing up to the last 5 notifications plus a "Read more" footer. It renders
116
+ // identically on web (react-native-web) and native.
117
+ export function NotificationBell({
118
+ notifications,
119
+ unreadCount,
120
+ onOpen,
121
+ onViewAll,
122
+ busy,
123
+ busyLabel = "Working",
124
+ size = "sm",
125
+ testID,
126
+ }: NotificationBellProps) {
127
+ const t = useTheme();
128
+ // The bell button sits in the dark top-bar chrome, but its dropdown should
129
+ // match the page, not the bar — so the menu renders under the app theme.
130
+ const { mode } = useThemeMode();
131
+ const pageTheme = mode === "dark" ? darkTheme : lightTheme;
132
+ const [open, setOpen] = useState(false);
133
+ const box = t.control[size];
134
+ const recent = notifications.slice(0, 5);
135
+
136
+ function toggle() {
137
+ const next = !open;
138
+ setOpen(next);
139
+ if (next) onOpen?.();
140
+ }
141
+
142
+ return (
143
+ <View style={{ position: "relative" }}>
144
+ <Pressable
145
+ testID={testID ?? "notification-bell"}
146
+ accessibilityRole="button"
147
+ accessibilityLabel={
148
+ busy
149
+ ? `${busyLabel}. Notifications`
150
+ : unreadCount > 0
151
+ ? `Notifications, ${unreadCount} unread`
152
+ : "Notifications"
153
+ }
154
+ onPress={toggle}
155
+ style={({ pressed }) => ({
156
+ width: box,
157
+ height: box,
158
+ alignItems: "center",
159
+ justifyContent: "center",
160
+ borderRadius: t.radius.md,
161
+ borderWidth: 1,
162
+ borderColor: open ? t.color.borderDefault : "transparent",
163
+ backgroundColor: open || pressed ? t.color.surfaceHover : "transparent",
164
+ })}
165
+ >
166
+ {busy ? (
167
+ <Spinner testID="notification-busy" size={size === "sm" ? 16 : 18} color={t.color.textSecondary} />
168
+ ) : (
169
+ <Icon name="bell" size={size === "sm" ? 16 : 18} color={t.color.textSecondary} />
170
+ )}
171
+ {/* The unread dot rides on the spinner too. Unread notifications don't
172
+ stop being unread because something else is running. */}
173
+ {unreadCount > 0 ? (
174
+ <View
175
+ testID="notification-unread-dot"
176
+ style={{
177
+ position: "absolute",
178
+ top: 5,
179
+ right: 5,
180
+ width: 8,
181
+ height: 8,
182
+ borderRadius: t.radius.pill,
183
+ backgroundColor: t.color.diffDelFg,
184
+ borderWidth: 1,
185
+ borderColor: t.color.surfacePage,
186
+ }}
187
+ />
188
+ ) : null}
189
+ </Pressable>
190
+
191
+ {open ? (
192
+ <ThemeScope theme={pageTheme}>
193
+ {/* Outside-press backdrop. `position: fixed` (web) covers the viewport;
194
+ on native the bell toggles the menu closed instead. */}
195
+ <Pressable
196
+ onPress={() => setOpen(false)}
197
+ // @ts-expect-error react-native-web accepts position: fixed
198
+ style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, zIndex: 40 }}
199
+ />
200
+ <View
201
+ testID="notification-menu"
202
+ style={{
203
+ position: "absolute",
204
+ top: box + t.space(1),
205
+ right: 0,
206
+ width: 320,
207
+ maxWidth: 320,
208
+ zIndex: 50,
209
+ backgroundColor: pageTheme.color.surfaceRaised,
210
+ borderWidth: 1,
211
+ borderColor: pageTheme.color.borderDefault,
212
+ borderRadius: t.radius.lg,
213
+ overflow: "hidden",
214
+ }}
215
+ >
216
+ <View
217
+ style={{
218
+ flexDirection: "row",
219
+ alignItems: "center",
220
+ justifyContent: "space-between",
221
+ paddingHorizontal: t.space(4),
222
+ paddingVertical: t.space(3),
223
+ borderBottomWidth: 1,
224
+ borderBottomColor: pageTheme.color.borderSubtle,
225
+ }}
226
+ >
227
+ <Text variant="sm" weight="semibold">Notifications</Text>
228
+ {unreadCount > 0 ? (
229
+ <Text variant="monoSm" color="tertiary">{`${unreadCount} unread`}</Text>
230
+ ) : null}
231
+ </View>
232
+
233
+ {recent.length === 0 ? (
234
+ <View style={{ padding: t.space(5), alignItems: "center" }}>
235
+ <Text variant="sm" color="tertiary" testID="notification-empty">You're all caught up</Text>
236
+ </View>
237
+ ) : (
238
+ <ScrollView style={{ maxHeight: 360 }}>
239
+ {recent.map((n, i) => (
240
+ <NotificationRow key={n.id} item={n} divided={i < recent.length - 1} />
241
+ ))}
242
+ </ScrollView>
243
+ )}
244
+
245
+ <Pressable
246
+ testID="notification-read-more"
247
+ onPress={() => {
248
+ setOpen(false);
249
+ onViewAll?.();
250
+ }}
251
+ style={({ pressed, hovered }: { pressed: boolean; hovered?: boolean }) => ({
252
+ paddingVertical: t.space(3),
253
+ alignItems: "center",
254
+ borderTopWidth: 1,
255
+ borderTopColor: pageTheme.color.borderSubtle,
256
+ backgroundColor: hovered || pressed ? pageTheme.color.surfaceHover : "transparent",
257
+ })}
258
+ >
259
+ <Text variant="sm" weight="medium">Read more</Text>
260
+ </Pressable>
261
+ </View>
262
+ </ThemeScope>
263
+ ) : null}
264
+ </View>
265
+ );
266
+ }
267
+
268
+ export type NotificationListProps = {
269
+ notifications: NotificationItem[];
270
+ emptyMessage?: string;
271
+ onPressItem?: (item: NotificationItem) => void;
272
+ };
273
+
274
+ // NotificationList is the full-screen list for a context (workspace/repository),
275
+ // rendered by the app when the user chooses "Read more".
276
+ export function NotificationList({
277
+ notifications,
278
+ emptyMessage = "No notifications yet",
279
+ onPressItem,
280
+ }: NotificationListProps) {
281
+ const t = useTheme();
282
+ if (notifications.length === 0) {
283
+ return (
284
+ <View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: t.space(6) }}>
285
+ <Text variant="body" color="tertiary" testID="notification-list-empty">{emptyMessage}</Text>
286
+ </View>
287
+ );
288
+ }
289
+ return (
290
+ <ScrollView style={{ flex: 1 }} testID="notification-list">
291
+ {notifications.map((n, i) => (
292
+ <NotificationRow
293
+ key={n.id}
294
+ item={n}
295
+ divided={i < notifications.length - 1}
296
+ onPress={onPressItem ? () => onPressItem(n) : undefined}
297
+ />
298
+ ))}
299
+ </ScrollView>
300
+ );
301
+ }