@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.
- package/README.md +77 -0
- package/css/components.css +312 -0
- package/css/tokens.css +212 -0
- package/package.json +65 -0
- package/src/ThemeProvider.tsx +86 -0
- package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
- package/src/__tests__/BranchImport.test.tsx +46 -0
- package/src/__tests__/Button.test.tsx +45 -0
- package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
- package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
- package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
- package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
- package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
- package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
- package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
- package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
- package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
- package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
- package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
- package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
- package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
- package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
- package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
- package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
- package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
- package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
- package/src/__tests__/MediaBrowser.test.tsx +353 -0
- package/src/__tests__/MediaField.test.tsx +185 -0
- package/src/__tests__/Notifications.test.tsx +69 -0
- package/src/__tests__/Onboarding.test.tsx +287 -0
- package/src/__tests__/cssTokens.test.ts +201 -0
- package/src/__tests__/fieldComponents.test.ts +43 -0
- package/src/__tests__/reorder.test.ts +58 -0
- package/src/components/ApplyChangesModal.tsx +348 -0
- package/src/components/BranchImport.tsx +157 -0
- package/src/components/BranchMenu.tsx +192 -0
- package/src/components/Button.tsx +131 -0
- package/src/components/ChangeDetail.tsx +472 -0
- package/src/components/ChangeRequestSummary.tsx +173 -0
- package/src/components/CollabField.tsx +388 -0
- package/src/components/ContentBrowser.tsx +5073 -0
- package/src/components/Icon.tsx +28 -0
- package/src/components/Icon.web.tsx +31 -0
- package/src/components/Input.tsx +106 -0
- package/src/components/MediaBrowser.tsx +766 -0
- package/src/components/MediaField.tsx +670 -0
- package/src/components/MediaPreview.tsx +91 -0
- package/src/components/MediaPreview.web.tsx +169 -0
- package/src/components/NavRow.tsx +105 -0
- package/src/components/Notifications.tsx +301 -0
- package/src/components/Onboarding.tsx +751 -0
- package/src/components/ProjectMenu.tsx +124 -0
- package/src/components/Segment.tsx +87 -0
- package/src/components/Skeleton.tsx +216 -0
- package/src/components/Spinner.tsx +44 -0
- package/src/components/Text.tsx +85 -0
- package/src/components/documentDrafts.ts +213 -0
- package/src/components/layout.tsx +284 -0
- package/src/components/primitives.tsx +143 -0
- package/src/components/reorder.ts +40 -0
- package/src/fieldComponents.ts +63 -0
- package/src/icons.ts +102 -0
- package/src/index.ts +198 -0
- package/src/media.ts +229 -0
- package/src/theme.ts +116 -0
- package/src/web/Button.tsx +110 -0
- package/src/web/Icon.tsx +52 -0
- package/src/web/Input.tsx +39 -0
- package/src/web/index.ts +43 -0
- package/src/web/primitives.tsx +119 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Unsaved edits, kept in the browser until the author saves them.
|
|
2
|
+
//
|
|
3
|
+
// The editor used to autosave every keystroke to the API, which meant every
|
|
4
|
+
// half-finished sentence became a row in the database and, through the
|
|
5
|
+
// exporter, a commit on the branch. Editing is now explicit: edits accumulate
|
|
6
|
+
// here, the header says the document has unsaved changes, and Save is what
|
|
7
|
+
// reaches the API and git.
|
|
8
|
+
//
|
|
9
|
+
// That makes this the only copy of work in progress, so it is written to
|
|
10
|
+
// localStorage rather than sessionStorage: a closed tab, a crashed browser or a
|
|
11
|
+
// reboot must not lose an afternoon's writing. The draft is offered back when
|
|
12
|
+
// the document is reopened, and Discard is the way out.
|
|
13
|
+
//
|
|
14
|
+
// Not every host has localStorage — the design system also runs in the mobile
|
|
15
|
+
// app, where `window` has no storage at all — so a missing store degrades to an
|
|
16
|
+
// in-memory map: drafts still survive navigating between documents within the
|
|
17
|
+
// session, which is the case that matters there, and nothing throws.
|
|
18
|
+
|
|
19
|
+
/** One document's unsaved edits. */
|
|
20
|
+
export type DocumentDraft = {
|
|
21
|
+
/** Schema version, so a future format change can ignore old drafts. */
|
|
22
|
+
v: 1;
|
|
23
|
+
/** The editor's value map, exactly as it edits it. */
|
|
24
|
+
values: Record<string, unknown>;
|
|
25
|
+
/** When it was last written, for the "unsaved since" affordance. */
|
|
26
|
+
at: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const PREFIX = "gitcms.draft.v1:";
|
|
30
|
+
|
|
31
|
+
// The in-memory fallback, also the store under a browser that refuses
|
|
32
|
+
// localStorage (Safari in private mode throws on write, not on access).
|
|
33
|
+
const memory = new Map<string, string>();
|
|
34
|
+
|
|
35
|
+
type Store = {
|
|
36
|
+
get(key: string): string | null;
|
|
37
|
+
set(key: string, value: string): void;
|
|
38
|
+
remove(key: string): void;
|
|
39
|
+
keys(): string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function store(): Store {
|
|
43
|
+
try {
|
|
44
|
+
const ls = typeof localStorage !== "undefined" ? localStorage : null;
|
|
45
|
+
if (ls) {
|
|
46
|
+
// Probed rather than assumed: private-mode Safari exposes localStorage and
|
|
47
|
+
// throws QuotaExceededError on the first write, and finding that out on
|
|
48
|
+
// 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);
|
|
52
|
+
return {
|
|
53
|
+
get: (k) => ls.getItem(k),
|
|
54
|
+
set: (k, v) => ls.setItem(k, v),
|
|
55
|
+
remove: (k) => ls.removeItem(k),
|
|
56
|
+
keys: () => Object.keys(ls),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
/* fall through to memory */
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
get: (k) => memory.get(k) ?? null,
|
|
64
|
+
set: (k, v) => {
|
|
65
|
+
memory.set(k, v);
|
|
66
|
+
},
|
|
67
|
+
remove: (k) => {
|
|
68
|
+
memory.delete(k);
|
|
69
|
+
},
|
|
70
|
+
keys: () => [...memory.keys()],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --- change notification ----------------------------------------------------
|
|
75
|
+
//
|
|
76
|
+
// The list marks documents that have drafts and counts them in its header, so
|
|
77
|
+
// it has to re-render when one is written, saved or discarded — including from
|
|
78
|
+
// another tab, where the same author may be editing the same repository.
|
|
79
|
+
//
|
|
80
|
+
// The cached id set is what makes this usable from a render: `hasDraft` is
|
|
81
|
+
// called once per visible row, and re-reading localStorage on every one of them
|
|
82
|
+
// would put a synchronous storage scan in the middle of scrolling.
|
|
83
|
+
|
|
84
|
+
const listeners = new Set<() => void>();
|
|
85
|
+
let cache: Set<string> | null = null;
|
|
86
|
+
let storageBound = false;
|
|
87
|
+
|
|
88
|
+
function invalidate() {
|
|
89
|
+
cache = null;
|
|
90
|
+
for (const l of listeners) l();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ids(): Set<string> {
|
|
94
|
+
if (!cache) {
|
|
95
|
+
cache = new Set(
|
|
96
|
+
store()
|
|
97
|
+
.keys()
|
|
98
|
+
.filter((k) => k.startsWith(PREFIX))
|
|
99
|
+
.map((k) => k.slice(PREFIX.length)),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return cache;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* subscribeDrafts registers a callback for any change to the set of documents
|
|
107
|
+
* with unsaved edits. Returns the unsubscribe.
|
|
108
|
+
*
|
|
109
|
+
* Shaped for useSyncExternalStore, which is why the readers below return
|
|
110
|
+
* primitives: a boolean and a number are stable between renders without the
|
|
111
|
+
* caching dance a returned Set or array would need.
|
|
112
|
+
*/
|
|
113
|
+
export function subscribeDrafts(onChange: () => void): () => void {
|
|
114
|
+
listeners.add(onChange);
|
|
115
|
+
// A draft written in another tab is a real change to what this list should
|
|
116
|
+
// show. Bound once, lazily, so a host that never renders a list never adds a
|
|
117
|
+
// window listener.
|
|
118
|
+
if (!storageBound && typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
119
|
+
storageBound = true;
|
|
120
|
+
window.addEventListener("storage", (e) => {
|
|
121
|
+
if (!e.key || e.key.startsWith(PREFIX)) invalidate();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return () => {
|
|
125
|
+
listeners.delete(onChange);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* refreshDrafts re-reads the store and notifies subscribers.
|
|
131
|
+
*
|
|
132
|
+
* The cache above assumes every write to these keys goes through this module,
|
|
133
|
+
* which holds within a tab — except when something clears storage wholesale
|
|
134
|
+
* (a host signing the user out, a test between cases). Those callers say so
|
|
135
|
+
* here rather than leaving the list showing badges for drafts that no longer
|
|
136
|
+
* exist. Cross-tab changes need no call: the storage event covers them.
|
|
137
|
+
*/
|
|
138
|
+
export function refreshDrafts(): void {
|
|
139
|
+
invalidate();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** hasDraft reports whether a document has unsaved edits. */
|
|
143
|
+
export function hasDraft(documentId: string): boolean {
|
|
144
|
+
return ids().has(documentId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** draftCount is how many documents have unsaved edits. */
|
|
148
|
+
export function draftCount(): number {
|
|
149
|
+
return ids().size;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* readDraft returns the stored draft for a document, or null.
|
|
154
|
+
*
|
|
155
|
+
* Anything unparseable or from another version is treated as absent and
|
|
156
|
+
* removed: a draft is a convenience, and refusing to open a document because
|
|
157
|
+
* its cached copy is malformed would be the worst possible trade.
|
|
158
|
+
*/
|
|
159
|
+
export function readDraft(documentId: string): DocumentDraft | null {
|
|
160
|
+
const s = store();
|
|
161
|
+
const key = PREFIX + documentId;
|
|
162
|
+
const raw = s.get(key);
|
|
163
|
+
if (!raw) return null;
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(raw) as DocumentDraft;
|
|
166
|
+
if (parsed?.v !== 1 || typeof parsed.values !== "object" || parsed.values === null) {
|
|
167
|
+
s.remove(key);
|
|
168
|
+
invalidate();
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
return parsed;
|
|
172
|
+
} catch {
|
|
173
|
+
s.remove(key);
|
|
174
|
+
invalidate();
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** writeDraft stores a document's unsaved values. */
|
|
180
|
+
export function writeDraft(documentId: string, values: Record<string, unknown>): void {
|
|
181
|
+
const draft: DocumentDraft = { v: 1, values, at: Date.now() };
|
|
182
|
+
const isNew = !ids().has(documentId);
|
|
183
|
+
try {
|
|
184
|
+
store().set(PREFIX + documentId, JSON.stringify(draft));
|
|
185
|
+
// Only the set of *which* documents have drafts is observable, so a
|
|
186
|
+
// rewrite of one that is already marked notifies nobody — that is every
|
|
187
|
+
// keystroke after the first.
|
|
188
|
+
if (isNew) invalidate();
|
|
189
|
+
} catch {
|
|
190
|
+
// Out of quota, or a value that will not serialize. The edit is still in
|
|
191
|
+
// component state and still savable; only the crash-safety copy is lost, so
|
|
192
|
+
// this must not interrupt typing.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** clearDraft forgets a document's unsaved values — after a save, or a discard. */
|
|
197
|
+
export function clearDraft(documentId: string): void {
|
|
198
|
+
try {
|
|
199
|
+
store().remove(PREFIX + documentId);
|
|
200
|
+
invalidate();
|
|
201
|
+
} catch {
|
|
202
|
+
/* nothing to do: a draft that cannot be removed is still only a cache */
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** draftIds lists every document with unsaved edits, for a global indicator. */
|
|
207
|
+
export function draftIds(): string[] {
|
|
208
|
+
try {
|
|
209
|
+
return [...ids()];
|
|
210
|
+
} catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, ScrollView, StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { useTheme, useThemeMode, ThemeScope } from "../ThemeProvider";
|
|
4
|
+
import { darkTheme } from "../theme";
|
|
5
|
+
import { Text } from "./Text";
|
|
6
|
+
import { IconButton } from "./Button";
|
|
7
|
+
|
|
8
|
+
/** Full-height page background wrapper (used by simple screens like login). */
|
|
9
|
+
export function Screen({
|
|
10
|
+
children,
|
|
11
|
+
center,
|
|
12
|
+
padded = true,
|
|
13
|
+
testID,
|
|
14
|
+
style,
|
|
15
|
+
}: {
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
center?: boolean;
|
|
18
|
+
padded?: boolean;
|
|
19
|
+
testID?: string;
|
|
20
|
+
style?: StyleProp<ViewStyle>;
|
|
21
|
+
}) {
|
|
22
|
+
const t = useTheme();
|
|
23
|
+
return (
|
|
24
|
+
<View
|
|
25
|
+
testID={testID}
|
|
26
|
+
style={[
|
|
27
|
+
{
|
|
28
|
+
flex: 1,
|
|
29
|
+
backgroundColor: t.color.surfacePage,
|
|
30
|
+
padding: padded ? t.space(6) : 0,
|
|
31
|
+
justifyContent: center ? "center" : "flex-start",
|
|
32
|
+
gap: t.space(3),
|
|
33
|
+
},
|
|
34
|
+
style,
|
|
35
|
+
]}
|
|
36
|
+
>
|
|
37
|
+
{children}
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Horizontal top bar with left / center / right slots. */
|
|
43
|
+
export function TopBar({
|
|
44
|
+
left,
|
|
45
|
+
center,
|
|
46
|
+
right,
|
|
47
|
+
}: {
|
|
48
|
+
left?: React.ReactNode;
|
|
49
|
+
center?: React.ReactNode;
|
|
50
|
+
right?: React.ReactNode;
|
|
51
|
+
}) {
|
|
52
|
+
const t = useTheme();
|
|
53
|
+
return (
|
|
54
|
+
<View
|
|
55
|
+
style={{
|
|
56
|
+
height: t.layout.topbar,
|
|
57
|
+
flexDirection: "row",
|
|
58
|
+
alignItems: "center",
|
|
59
|
+
paddingHorizontal: t.space(3),
|
|
60
|
+
gap: t.space(3),
|
|
61
|
+
borderBottomWidth: 1,
|
|
62
|
+
// Hairline appropriate to the dark chrome in both modes.
|
|
63
|
+
borderBottomColor: darkTheme.color.borderDefault,
|
|
64
|
+
// Permanent dark bar: near-black in light mode, charcoal in dark.
|
|
65
|
+
backgroundColor: t.color.surfaceTopBar,
|
|
66
|
+
// Establish a stacking context above the content panes so top-bar
|
|
67
|
+
// overlays (e.g. the notification menu) render on top of them.
|
|
68
|
+
position: "relative",
|
|
69
|
+
zIndex: 10,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{/* The bar's inline contents render against the dark palette so text and
|
|
73
|
+
icons stay legible on the dark chrome, in both app themes. Overlays
|
|
74
|
+
that open from here (e.g. the notification menu) restore the page
|
|
75
|
+
theme themselves. */}
|
|
76
|
+
<ThemeScope theme={darkTheme}>
|
|
77
|
+
{/* The center slot is positioned against the BAR, not laid out between
|
|
78
|
+
its neighbours. As a flex sibling it would center in whatever space
|
|
79
|
+
the left and right slots left over — and those are never equal
|
|
80
|
+
widths (the workspace tag plus branch name is far wider than the
|
|
81
|
+
icon cluster), so it would sit visibly off-center. Absolute
|
|
82
|
+
positioning makes it centered on screen, which is the only centering
|
|
83
|
+
anyone can actually see.
|
|
84
|
+
|
|
85
|
+
Rendered first so the left/right slots paint above it: menus open
|
|
86
|
+
from those (branch, notifications) and must not fall behind it.
|
|
87
|
+
|
|
88
|
+
The overlay spans the whole bar, so it must not swallow clicks meant
|
|
89
|
+
for the slots underneath — hence `pointerEvents: "none"` here and
|
|
90
|
+
"auto" on the inner wrapper, which is the only combination that
|
|
91
|
+
actually works. React Native's `box-none` would express this in one
|
|
92
|
+
place, but react-native-web emits it verbatim as
|
|
93
|
+
`pointer-events: box-none`, which is not a valid CSS value: browsers
|
|
94
|
+
drop the declaration, the overlay falls back to `auto`, and the
|
|
95
|
+
branch menu, bell and sign-out all go dead. */}
|
|
96
|
+
{center ? (
|
|
97
|
+
<View
|
|
98
|
+
style={{
|
|
99
|
+
position: "absolute",
|
|
100
|
+
left: 0, right: 0, top: 0, bottom: 0,
|
|
101
|
+
flexDirection: "row",
|
|
102
|
+
alignItems: "center",
|
|
103
|
+
justifyContent: "center",
|
|
104
|
+
pointerEvents: "none",
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<View style={{ pointerEvents: "auto" }}>{center}</View>
|
|
108
|
+
</View>
|
|
109
|
+
) : null}
|
|
110
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>{left}</View>
|
|
111
|
+
{/* Pushes the right slot to the trailing edge now that nothing between
|
|
112
|
+
them is flexing. */}
|
|
113
|
+
<View style={{ flex: 1 }} />
|
|
114
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(1) }}>{right}</View>
|
|
115
|
+
</ThemeScope>
|
|
116
|
+
</View>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A vertical column in the desktop multi-pane layout. Fixed `width` for
|
|
122
|
+
* sidebars/lists, or `flex` for the main content pane.
|
|
123
|
+
*/
|
|
124
|
+
export function Pane({
|
|
125
|
+
children,
|
|
126
|
+
width,
|
|
127
|
+
flex,
|
|
128
|
+
header,
|
|
129
|
+
borderRight = true,
|
|
130
|
+
scroll = true,
|
|
131
|
+
testID,
|
|
132
|
+
dataSet,
|
|
133
|
+
}: {
|
|
134
|
+
children: React.ReactNode;
|
|
135
|
+
width?: number;
|
|
136
|
+
flex?: number;
|
|
137
|
+
header?: React.ReactNode;
|
|
138
|
+
borderRight?: boolean;
|
|
139
|
+
scroll?: boolean;
|
|
140
|
+
testID?: string;
|
|
141
|
+
// react-native-web maps this to data-* attributes on the pane's root element.
|
|
142
|
+
dataSet?: Record<string, string | number>;
|
|
143
|
+
}) {
|
|
144
|
+
const t = useTheme();
|
|
145
|
+
// minHeight:0 lets the ScrollView shrink within the pane instead of growing
|
|
146
|
+
// it — so the body scrolls and the header (a sibling above it) stays put.
|
|
147
|
+
const body = scroll ? (
|
|
148
|
+
<ScrollView style={{ flex: 1, minHeight: 0 }} contentContainerStyle={{ paddingVertical: t.space(2) }}>
|
|
149
|
+
{children}
|
|
150
|
+
</ScrollView>
|
|
151
|
+
) : (
|
|
152
|
+
<View style={{ flex: 1, minHeight: 0 }}>{children}</View>
|
|
153
|
+
);
|
|
154
|
+
return (
|
|
155
|
+
<View
|
|
156
|
+
testID={testID}
|
|
157
|
+
// @ts-expect-error react-native-web accepts dataSet -> data-* attributes
|
|
158
|
+
dataSet={dataSet}
|
|
159
|
+
style={{
|
|
160
|
+
width,
|
|
161
|
+
flex: width ? undefined : flex ?? 1,
|
|
162
|
+
minHeight: 0,
|
|
163
|
+
overflow: "hidden",
|
|
164
|
+
borderRightWidth: borderRight ? 1 : 0,
|
|
165
|
+
borderRightColor: t.color.borderSubtle,
|
|
166
|
+
backgroundColor: t.color.surfacePage,
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
{header ? (
|
|
170
|
+
<View
|
|
171
|
+
style={{
|
|
172
|
+
height: t.layout.topbar,
|
|
173
|
+
paddingHorizontal: t.space(4),
|
|
174
|
+
flexDirection: "row",
|
|
175
|
+
alignItems: "center",
|
|
176
|
+
gap: t.space(2),
|
|
177
|
+
borderBottomWidth: 1,
|
|
178
|
+
borderBottomColor: t.color.borderSubtle,
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{header}
|
|
182
|
+
</View>
|
|
183
|
+
) : null}
|
|
184
|
+
{body}
|
|
185
|
+
</View>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Desktop shell: a top bar over a horizontal row of panes. */
|
|
190
|
+
export function AppShell({
|
|
191
|
+
topBar,
|
|
192
|
+
banner,
|
|
193
|
+
children,
|
|
194
|
+
testID,
|
|
195
|
+
}: {
|
|
196
|
+
topBar?: React.ReactNode;
|
|
197
|
+
// Optional full-width strip below the top bar (e.g. a read-only notice).
|
|
198
|
+
banner?: React.ReactNode;
|
|
199
|
+
children: React.ReactNode;
|
|
200
|
+
testID?: string;
|
|
201
|
+
}) {
|
|
202
|
+
const t = useTheme();
|
|
203
|
+
return (
|
|
204
|
+
<View testID={testID} style={{ flex: 1, backgroundColor: t.color.surfacePage }}>
|
|
205
|
+
{topBar}
|
|
206
|
+
{banner}
|
|
207
|
+
<View style={{ flex: 1, minHeight: 0, flexDirection: "row" }}>{children}</View>
|
|
208
|
+
</View>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Mobile drill-down screen: a compact header row, an optional large title, and
|
|
214
|
+
* a scrolling body — matching the native mobile layout.
|
|
215
|
+
*/
|
|
216
|
+
export function MobileScreen({
|
|
217
|
+
header,
|
|
218
|
+
title,
|
|
219
|
+
banner,
|
|
220
|
+
overlay,
|
|
221
|
+
children,
|
|
222
|
+
testID,
|
|
223
|
+
// Set false when the children own their own scrolling (e.g. a FlatList) so a
|
|
224
|
+
// VirtualizedList isn't nested inside this ScrollView.
|
|
225
|
+
scroll = true,
|
|
226
|
+
}: {
|
|
227
|
+
header?: React.ReactNode;
|
|
228
|
+
title?: string;
|
|
229
|
+
// Full-width strip below the header (e.g. a read-only notice).
|
|
230
|
+
banner?: React.ReactNode;
|
|
231
|
+
// Absolutely-positioned overlay covering the whole screen (e.g. a modal),
|
|
232
|
+
// rendered outside the scroll area so it never scrolls with the body.
|
|
233
|
+
overlay?: React.ReactNode;
|
|
234
|
+
children: React.ReactNode;
|
|
235
|
+
testID?: string;
|
|
236
|
+
scroll?: boolean;
|
|
237
|
+
}) {
|
|
238
|
+
const t = useTheme();
|
|
239
|
+
return (
|
|
240
|
+
<View testID={testID} style={{ flex: 1, backgroundColor: t.color.surfacePage }}>
|
|
241
|
+
{header ? (
|
|
242
|
+
<View
|
|
243
|
+
style={{
|
|
244
|
+
paddingHorizontal: t.space(4),
|
|
245
|
+
paddingTop: t.space(3),
|
|
246
|
+
flexDirection: "row",
|
|
247
|
+
alignItems: "center",
|
|
248
|
+
gap: t.space(3),
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
{header}
|
|
252
|
+
</View>
|
|
253
|
+
) : null}
|
|
254
|
+
{title ? (
|
|
255
|
+
<View style={{ paddingHorizontal: t.space(4), paddingTop: t.space(3), paddingBottom: t.space(3) }}>
|
|
256
|
+
<Text variant="h1" weight="bold" style={{ fontSize: 32, lineHeight: 38 }}>
|
|
257
|
+
{title}
|
|
258
|
+
</Text>
|
|
259
|
+
</View>
|
|
260
|
+
) : null}
|
|
261
|
+
{banner}
|
|
262
|
+
{scroll ? (
|
|
263
|
+
<ScrollView style={{ flex: 1 }}>{children}</ScrollView>
|
|
264
|
+
) : (
|
|
265
|
+
<View style={{ flex: 1, minHeight: 0 }}>{children}</View>
|
|
266
|
+
)}
|
|
267
|
+
{overlay}
|
|
268
|
+
</View>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Sun/moon button wired to the theme provider. */
|
|
273
|
+
export function ThemeToggle({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
|
|
274
|
+
const { mode, toggleMode } = useThemeMode();
|
|
275
|
+
return (
|
|
276
|
+
<IconButton
|
|
277
|
+
name={mode === "dark" ? "sun" : "moon"}
|
|
278
|
+
onPress={toggleMode}
|
|
279
|
+
size={size}
|
|
280
|
+
label={mode === "dark" ? "Switch to light theme" : "Switch to dark theme"}
|
|
281
|
+
testID="theme-toggle"
|
|
282
|
+
/>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
|
|
6
|
+
/** Bordered surface card — defined by a hairline border, never a shadow. */
|
|
7
|
+
export function Card({
|
|
8
|
+
children,
|
|
9
|
+
padding = 4,
|
|
10
|
+
style,
|
|
11
|
+
testID,
|
|
12
|
+
}: {
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
padding?: number;
|
|
15
|
+
style?: StyleProp<ViewStyle>;
|
|
16
|
+
testID?: string;
|
|
17
|
+
}) {
|
|
18
|
+
const t = useTheme();
|
|
19
|
+
return (
|
|
20
|
+
<View
|
|
21
|
+
testID={testID}
|
|
22
|
+
style={[
|
|
23
|
+
{
|
|
24
|
+
backgroundColor: t.color.surfaceRaised,
|
|
25
|
+
borderWidth: 1,
|
|
26
|
+
borderColor: t.color.borderDefault,
|
|
27
|
+
borderRadius: t.radius.md,
|
|
28
|
+
padding: t.space(padding),
|
|
29
|
+
},
|
|
30
|
+
style,
|
|
31
|
+
]}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
</View>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BadgeTone = "neutral" | "add" | "del" | "strong";
|
|
39
|
+
|
|
40
|
+
/** Small pill for counts and git status letters (M/A/U/R/D). */
|
|
41
|
+
export function Badge({
|
|
42
|
+
label,
|
|
43
|
+
tone = "neutral",
|
|
44
|
+
mono = true,
|
|
45
|
+
testID,
|
|
46
|
+
}: {
|
|
47
|
+
label: string;
|
|
48
|
+
tone?: BadgeTone;
|
|
49
|
+
mono?: boolean;
|
|
50
|
+
testID?: string;
|
|
51
|
+
}) {
|
|
52
|
+
const t = useTheme();
|
|
53
|
+
const tones: Record<BadgeTone, { bg: string; fg: string; border: string }> = {
|
|
54
|
+
neutral: { bg: t.color.surfaceSunken, fg: t.color.textSecondary, border: t.color.borderSubtle },
|
|
55
|
+
add: { bg: t.color.diffAddBg, fg: t.color.diffAddFg, border: t.color.diffAddBg },
|
|
56
|
+
del: { bg: t.color.diffDelBg, fg: t.color.diffDelFg, border: t.color.diffDelBg },
|
|
57
|
+
strong: { bg: t.color.surfaceInverted, fg: t.color.textInverted, border: t.color.surfaceInverted },
|
|
58
|
+
};
|
|
59
|
+
const c = tones[tone];
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
testID={testID}
|
|
63
|
+
style={{
|
|
64
|
+
paddingHorizontal: t.space(1.5),
|
|
65
|
+
height: 18,
|
|
66
|
+
justifyContent: "center",
|
|
67
|
+
borderRadius: t.radius.sm,
|
|
68
|
+
backgroundColor: c.bg,
|
|
69
|
+
borderWidth: 1,
|
|
70
|
+
borderColor: c.border,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<Text variant="monoSm" mono={mono} color={c.fg} weight="medium">
|
|
74
|
+
{label}
|
|
75
|
+
</Text>
|
|
76
|
+
</View>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Git diff stat: `+N` in add color, `-N` in del color. */
|
|
81
|
+
export function DiffStat({ added, removed }: { added?: number; removed?: number }) {
|
|
82
|
+
const t = useTheme();
|
|
83
|
+
return (
|
|
84
|
+
<View style={{ flexDirection: "row", gap: t.space(1.5) }}>
|
|
85
|
+
{added != null && added > 0 ? (
|
|
86
|
+
<Text variant="monoSm" color={t.color.diffAddFg} weight="medium">{`+${added}`}</Text>
|
|
87
|
+
) : null}
|
|
88
|
+
{removed != null && removed > 0 ? (
|
|
89
|
+
<Text variant="monoSm" color={t.color.diffDelFg} weight="medium">{`-${removed}`}</Text>
|
|
90
|
+
) : null}
|
|
91
|
+
</View>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Square initials avatar. */
|
|
96
|
+
export function Avatar({ initials, size = 28 }: { initials: string; size?: number }) {
|
|
97
|
+
const t = useTheme();
|
|
98
|
+
return (
|
|
99
|
+
<View
|
|
100
|
+
style={{
|
|
101
|
+
width: size,
|
|
102
|
+
height: size,
|
|
103
|
+
borderRadius: t.radius.sm,
|
|
104
|
+
borderWidth: 1,
|
|
105
|
+
borderColor: t.color.borderDefault,
|
|
106
|
+
backgroundColor: t.color.surfaceSunken,
|
|
107
|
+
alignItems: "center",
|
|
108
|
+
justifyContent: "center",
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
<Text variant="monoSm" weight="semibold" color="secondary">
|
|
112
|
+
{initials}
|
|
113
|
+
</Text>
|
|
114
|
+
</View>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Hairline rule. */
|
|
119
|
+
export function Divider({ vertical, style }: { vertical?: boolean; style?: StyleProp<ViewStyle> }) {
|
|
120
|
+
const t = useTheme();
|
|
121
|
+
return (
|
|
122
|
+
<View
|
|
123
|
+
style={[
|
|
124
|
+
vertical
|
|
125
|
+
? { width: 1, alignSelf: "stretch", backgroundColor: t.color.borderSubtle }
|
|
126
|
+
: { height: 1, alignSelf: "stretch", backgroundColor: t.color.borderSubtle },
|
|
127
|
+
style,
|
|
128
|
+
]}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Uppercase tracked mono micro-label used for nav section headers. */
|
|
134
|
+
export function SectionLabel({ children, style }: { children: React.ReactNode; style?: StyleProp<ViewStyle> }) {
|
|
135
|
+
const t = useTheme();
|
|
136
|
+
return (
|
|
137
|
+
<View style={[{ paddingHorizontal: t.space(4), paddingVertical: t.space(2) }, style]}>
|
|
138
|
+
<Text variant="label" color="tertiary">
|
|
139
|
+
{children}
|
|
140
|
+
</Text>
|
|
141
|
+
</View>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Pure geometry/list helpers for the desktop ContentBrowser's resizable panes
|
|
2
|
+
// and drag-to-reorder columns. Kept side-effect-free so they unit-test without a
|
|
3
|
+
// DOM (the drag/resize gestures themselves are exercised in the browser).
|
|
4
|
+
|
|
5
|
+
/** Clamp `n` into the inclusive [min, max] range. */
|
|
6
|
+
export function clamp(n: number, min: number, max: number): number {
|
|
7
|
+
return Math.max(min, Math.min(max, n));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reorder `ids` by moving `dragId` to `slot` — a landing position expressed in
|
|
12
|
+
* the ORIGINAL list's coordinates (0..ids.length). Because removing the dragged
|
|
13
|
+
* id shifts later positions left by one, a slot past the id's own index is
|
|
14
|
+
* decremented. Returns a new array; a no-op (unknown id) returns the input.
|
|
15
|
+
*/
|
|
16
|
+
export function reorder(ids: string[], dragId: string, slot: number): string[] {
|
|
17
|
+
const from = ids.indexOf(dragId);
|
|
18
|
+
if (from === -1) return ids;
|
|
19
|
+
const without = ids.filter((x) => x !== dragId);
|
|
20
|
+
const to = clamp(from < slot ? slot - 1 : slot, 0, without.length);
|
|
21
|
+
without.splice(to, 0, dragId);
|
|
22
|
+
return without;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The insertion slot (0..widths.length) for a pointer at content-x `x` over a
|
|
27
|
+
* horizontal row of columns with the given `widths`, laid out left-to-right. A
|
|
28
|
+
* column is "passed" once the pointer clears its midpoint, so dropping snaps to
|
|
29
|
+
* whichever gap the cursor is nearest.
|
|
30
|
+
*/
|
|
31
|
+
export function slotAtX(x: number, widths: number[]): number {
|
|
32
|
+
let left = 0;
|
|
33
|
+
let slot = 0;
|
|
34
|
+
for (const w of widths) {
|
|
35
|
+
if (x <= left + w / 2) break;
|
|
36
|
+
slot++;
|
|
37
|
+
left += w;
|
|
38
|
+
}
|
|
39
|
+
return slot;
|
|
40
|
+
}
|