@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,388 @@
1
+ import React, { useSyncExternalStore } from "react";
2
+ import { View, TextInput, Pressable } from "react-native";
3
+ import { Text } from "./Text";
4
+ import { Input } from "./Input";
5
+ import { useTheme } from "../ThemeProvider";
6
+ import type { CollabApi, CollabParticipant, CollabPeer, CollabRegister, CollabText } from "./ContentBrowser";
7
+
8
+ // useCollabText subscribes a component to one text field's shared CRDT value and
9
+ // the peers focused on it. Value/peers come straight from the binding so all
10
+ // clients converge; setValue writes back to the CRDT.
11
+ export function useCollabText(binding: CollabText) {
12
+ const value = useSyncExternalStore(binding.subscribe, binding.get, binding.get);
13
+ const peers = useSyncExternalStore(binding.subscribe, binding.peers, binding.peers);
14
+ return { value, peers, setValue: binding.set };
15
+ }
16
+
17
+ // useCollabRegister binds a scalar field (select/boolean/…) to its shared
18
+ // last-writer-wins register.
19
+ export function useCollabRegister(binding: CollabRegister) {
20
+ const value = useSyncExternalStore(binding.subscribe, binding.get, binding.get);
21
+ return { value, setValue: binding.set };
22
+ }
23
+
24
+ // useCollabSynced reports whether the shared document reflects the server room
25
+ // yet. Until it does, every binding reads empty — not because the field is
26
+ // empty, but because nothing has arrived — and a control that trusts that shows
27
+ // a document's saved value as blank.
28
+ //
29
+ // A host with no synced notion (tests, older impls) omits the pair; those
30
+ // consumers act immediately, as they did before it existed.
31
+ export function useCollabSynced(collab: CollabApi): boolean {
32
+ const subscribe = React.useCallback(
33
+ (cb: () => void) => collab.subscribeSynced?.(cb) ?? (() => {}),
34
+ [collab],
35
+ );
36
+ const get = React.useCallback(() => collab.synced?.() ?? true, [collab]);
37
+ return useSyncExternalStore(subscribe, get, get);
38
+ }
39
+
40
+ /**
41
+ * Holds a control together across the gap between it mounting and the shared
42
+ * document arriving.
43
+ *
44
+ * Two things went wrong in that window, both from the same mistake — reading an
45
+ * empty binding as though it were the field's value. The control showed a saved
46
+ * document as blank for as long as the handshake took. And an edit made in that
47
+ * moment went into the shared doc *before* the server's seeded value did, where
48
+ * it did not replace it but merged with it: typing "draft" into a field that
49
+ * turns out to hold "shipped" left "shippeddraft" behind, in the document, with
50
+ * nothing on screen to say where it came from.
51
+ *
52
+ * So until the doc has arrived the field is an ordinary local control. It shows
53
+ * the value the host loaded from the document, and an edit is held rather than
54
+ * written. Arrival writes the held edit through as a single replacement — which
55
+ * is what the user meant by it — and hands the field to the CRDT for good.
56
+ *
57
+ * Arrival is once-only, and a value in hand counts as proof of it whatever the
58
+ * provider reports. A later disconnect therefore changes nothing: edits keep
59
+ * going straight into the doc, so an offline stretch still merges the way a CRDT
60
+ * is meant to rather than being replayed over whatever happened meanwhile.
61
+ */
62
+ export function useCollabValue<T>(
63
+ collab: CollabApi,
64
+ shared: T,
65
+ local: T,
66
+ write: (next: T) => void,
67
+ ): { value: T; set: (next: T) => void } {
68
+ const synced = useCollabSynced(collab);
69
+ const everSynced = React.useRef(false);
70
+ if (synced) everSynced.current = true;
71
+ // `undefined` is a register nobody has written yet; "" is a Y.Text that has
72
+ // either not arrived or is genuinely empty — which of the two is what `synced`
73
+ // is here to settle.
74
+ const unset = shared === undefined || shared === null || shared === "";
75
+ const arrived = everSynced.current || !unset;
76
+ const [held, setHeld] = React.useState<[T] | null>(null);
77
+ // Held in a ref: the caller passes a fresh closure over its bindings each
78
+ // render, and in the effect's dependencies that would replay the flush.
79
+ const writeRef = React.useRef(write);
80
+ writeRef.current = write;
81
+
82
+ React.useEffect(() => {
83
+ if (!arrived || !held) return;
84
+ writeRef.current(held[0]);
85
+ setHeld(null);
86
+ }, [arrived, held]);
87
+
88
+ return {
89
+ // A register still unwritten after arrival falls back to the local value
90
+ // too: nothing has claimed the field, so the document's own value is the
91
+ // best answer. An arrived *text* reading "" is an answer — the field is
92
+ // empty, possibly because a peer just cleared it — and stands.
93
+ value: held ? held[0] : arrived && shared !== undefined ? shared : local,
94
+ set: (next: T) => {
95
+ if (arrived) writeRef.current(next);
96
+ else setHeld([next]);
97
+ },
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Reports a collaborator's change to the host as though the user had made it.
103
+ *
104
+ * A collab-bound control has two sources of truth and only ever told the host
105
+ * about one of them. Typing called `onChange`, so the host's value map, its save
106
+ * state and the draft it feeds the preview all moved. A change arriving over the
107
+ * CRDT re-rendered the control — through `useSyncExternalStore`, which is what
108
+ * makes the text on screen correct — and stopped there. The field showed the new
109
+ * value while every consumer of `onChange` still held the old one.
110
+ *
111
+ * Two things fell out of that. The preview never rebuilt for anyone but the
112
+ * author of the edit, because a draft is only sent when `onChange` fires. And
113
+ * the host's map stayed stale, so the next save wrote this field back as it was
114
+ * before the collaborator touched it — losing their work in the committed
115
+ * document, silently, with both screens showing the newer text.
116
+ *
117
+ * So: return an `emit` for the local path to call in place of `onChange`, and
118
+ * fire `onChange` for anything that changes the value without going through it.
119
+ * `emit` is what keeps a local edit from arriving twice — it settles the value
120
+ * on the way out, so the CRDT echo of the user's own keystroke is not read back
121
+ * as somebody else's change.
122
+ *
123
+ * The value is settled on the first render as well, so a field that mounts
124
+ * already in sync announces nothing: at that point the host seeded the map from
125
+ * the same document, and an edit event there would mark an untouched document
126
+ * unsaved.
127
+ */
128
+ export function useCollabMirror<T>(value: T, onChange: (v: T) => void): (v: T) => void {
129
+ const settled = React.useRef(value);
130
+ // Held in a ref because the host passes a fresh arrow each render — in the
131
+ // effect's dependencies it would re-run on every render of the form, and the
132
+ // guard below is the only reason that would be harmless rather than a loop.
133
+ const cb = React.useRef(onChange);
134
+ cb.current = onChange;
135
+
136
+ React.useEffect(() => {
137
+ if (Object.is(settled.current, value)) return;
138
+ settled.current = value;
139
+ cb.current(value);
140
+ }, [value]);
141
+
142
+ return React.useCallback((next: T) => {
143
+ settled.current = next;
144
+ cb.current(next);
145
+ }, []);
146
+ }
147
+
148
+ // useCollabPeers re-renders on any awareness change (tick-based) and returns the
149
+ // peers focused exactly at a field path — for presence on non-text controls.
150
+ export function useCollabPeers(collab: CollabApi, path: string): CollabPeer[] {
151
+ useSyncExternalStore(collab.subscribeAwareness, collab.awarenessVersion, collab.awarenessVersion);
152
+ return collab.peersAt(path);
153
+ }
154
+
155
+ // useCollabPeersUnder is like useCollabPeers but for a group prefix (any peer
156
+ // focused on a descendant field), driving the group's presence highlight.
157
+ export function useCollabPeersUnder(collab: CollabApi, prefix: string): CollabPeer[] {
158
+ useSyncExternalStore(collab.subscribeAwareness, collab.awarenessVersion, collab.awarenessVersion);
159
+ return collab.peersUnder(prefix);
160
+ }
161
+
162
+ // useCollabParticipants re-renders on awareness changes and returns everyone
163
+ // connected to the document (excluding self).
164
+ export function useCollabParticipants(collab: CollabApi): CollabParticipant[] {
165
+ useSyncExternalStore(collab.subscribeAwareness, collab.awarenessVersion, collab.awarenessVersion);
166
+ return collab.participants();
167
+ }
168
+
169
+ function initials(name: string): string {
170
+ const parts = name.trim().split(/[\s._-]+/).filter(Boolean);
171
+ if (parts.length === 0) return "?";
172
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
173
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
174
+ }
175
+
176
+ // PresenceAvatars renders overlapping, colored avatars for everyone connected to
177
+ // the document. Clicking one that is editing a field calls onFocusPeer with that
178
+ // field's path so the host can jump to it.
179
+ export function PresenceAvatars({
180
+ collab,
181
+ onFocusPeer,
182
+ }: {
183
+ collab: CollabApi;
184
+ onFocusPeer: (path: string) => void;
185
+ }) {
186
+ const t = useTheme();
187
+ const people = useCollabParticipants(collab);
188
+ if (people.length === 0) return null;
189
+ const size = 24;
190
+ return (
191
+ <View style={{ flexDirection: "row", alignItems: "center" }}>
192
+ {people.map((p, i) => {
193
+ const editing = !!p.focus;
194
+ return (
195
+ <Pressable
196
+ key={p.id}
197
+ onPress={() => p.focus && onFocusPeer(p.focus)}
198
+ disabled={!editing}
199
+ accessibilityLabel={editing ? `${p.name} — editing ${p.focus}` : p.name}
200
+ style={[
201
+ {
202
+ width: size,
203
+ height: size,
204
+ borderRadius: size / 2,
205
+ backgroundColor: p.color,
206
+ borderWidth: 2,
207
+ borderColor: t.color.surfaceRaised,
208
+ alignItems: "center",
209
+ justifyContent: "center",
210
+ marginLeft: i === 0 ? 0 : -8,
211
+ },
212
+ // react-native-web accepts CSS cursor; not in the RN ViewStyle type.
213
+ { cursor: editing ? "pointer" : "default" } as unknown as object,
214
+ ]}
215
+ >
216
+ <Text variant="monoSm" color="inverted" style={{ fontSize: 10, lineHeight: 12 }}>
217
+ {initials(p.name)}
218
+ </Text>
219
+ </Pressable>
220
+ );
221
+ })}
222
+ </View>
223
+ );
224
+ }
225
+
226
+ // PresenceField wraps a control with a colored outline and floating name chips
227
+ // while one or more peers are focused on it. With no peers it renders its child
228
+ // unchanged (no layout shift beyond a transparent border).
229
+ export function PresenceField({
230
+ peers,
231
+ children,
232
+ }: {
233
+ peers: CollabPeer[];
234
+ children: React.ReactNode;
235
+ }) {
236
+ const t = useTheme();
237
+ const active = peers.length > 0;
238
+ const accent = active ? peers[0].color : "transparent";
239
+ return (
240
+ <View style={{ position: "relative" }}>
241
+ <View
242
+ style={{
243
+ borderWidth: 2,
244
+ borderColor: accent,
245
+ borderRadius: t.radius.md,
246
+ // Negative margin keeps the control's own layout box unchanged whether
247
+ // or not the presence border is showing.
248
+ margin: -2,
249
+ padding: 0,
250
+ }}
251
+ >
252
+ {children}
253
+ </View>
254
+ {active ? (
255
+ <View
256
+ pointerEvents="none"
257
+ style={{ position: "absolute", top: -16, right: 0, flexDirection: "row", gap: 4 }}
258
+ >
259
+ {peers.map((p) => (
260
+ <View
261
+ key={p.id}
262
+ style={{
263
+ backgroundColor: p.color,
264
+ borderRadius: 4,
265
+ paddingHorizontal: 6,
266
+ paddingVertical: 1,
267
+ }}
268
+ >
269
+ <Text variant="monoSm" color="inverted" style={{ fontSize: 10, lineHeight: 14 }}>
270
+ {p.name}
271
+ </Text>
272
+ </View>
273
+ ))}
274
+ </View>
275
+ ) : null}
276
+ </View>
277
+ );
278
+ }
279
+
280
+ // CollabInput is a single-line text field bound to a shared CRDT value. Local
281
+ // edits write to the CRDT and mirror to onChange (so the host's autosave/
282
+ // validation see the converged value); focus/blur announce presence. `local` is
283
+ // the host's own value for the field, which stands in until the shared document
284
+ // arrives (see useCollabValue).
285
+ export function CollabInput({
286
+ collab,
287
+ path,
288
+ local,
289
+ label,
290
+ mono,
291
+ keyboardType,
292
+ readOnly,
293
+ error,
294
+ onChange,
295
+ focusSignal,
296
+ }: {
297
+ collab: CollabApi;
298
+ path: string;
299
+ local: string;
300
+ label?: string;
301
+ mono?: boolean;
302
+ keyboardType?: "default" | "numeric";
303
+ readOnly?: boolean;
304
+ error?: boolean;
305
+ onChange: (v: string) => void;
306
+ focusSignal?: number;
307
+ }) {
308
+ const { value: shared, peers, setValue } = useCollabText(collab.text(path));
309
+ const { value, set } = useCollabValue(collab, shared, local, setValue);
310
+ const emit = useCollabMirror(value, onChange);
311
+ return (
312
+ <PresenceField peers={peers}>
313
+ <Input
314
+ label={label}
315
+ value={value}
316
+ onChangeText={(v) => {
317
+ set(v);
318
+ emit(v);
319
+ }}
320
+ mono={mono}
321
+ keyboardType={keyboardType}
322
+ size="md"
323
+ editable={!readOnly}
324
+ error={error}
325
+ onFocus={() => collab.setFocus(path)}
326
+ onBlur={() => collab.setFocus(null)}
327
+ focusSignal={focusSignal}
328
+ />
329
+ </PresenceField>
330
+ );
331
+ }
332
+
333
+ // CollabTextarea is the multi-line counterpart of CollabInput.
334
+ export function CollabTextarea({
335
+ collab,
336
+ path,
337
+ local,
338
+ readOnly,
339
+ error,
340
+ onChange,
341
+ focusSignal,
342
+ }: {
343
+ collab: CollabApi;
344
+ path: string;
345
+ local: string;
346
+ readOnly?: boolean;
347
+ error?: boolean;
348
+ onChange: (v: string) => void;
349
+ focusSignal?: number;
350
+ }) {
351
+ const t = useTheme();
352
+ const { value: shared, peers, setValue } = useCollabText(collab.text(path));
353
+ const { value, set } = useCollabValue(collab, shared, local, setValue);
354
+ const emit = useCollabMirror(value, onChange);
355
+ const ref = React.useRef<TextInput>(null);
356
+ React.useEffect(() => {
357
+ if (focusSignal) ref.current?.focus();
358
+ }, [focusSignal]);
359
+ return (
360
+ <PresenceField peers={peers}>
361
+ <TextInput
362
+ ref={ref}
363
+ multiline
364
+ editable={!readOnly}
365
+ value={value}
366
+ onChangeText={(v) => {
367
+ set(v);
368
+ emit(v);
369
+ }}
370
+ onFocus={() => collab.setFocus(path)}
371
+ onBlur={() => collab.setFocus(null)}
372
+ // @ts-expect-error react-native-web
373
+ style={{
374
+ minHeight: 96,
375
+ padding: t.space(3),
376
+ borderWidth: 1,
377
+ borderColor: error ? t.color.diffDelFg : t.color.borderDefault,
378
+ borderRadius: t.radius.md,
379
+ backgroundColor: readOnly ? t.color.surfaceSunken : t.color.surfaceRaised,
380
+ color: readOnly ? t.color.textSecondary : t.color.textPrimary,
381
+ fontSize: 13,
382
+ textAlignVertical: "top",
383
+ outlineStyle: "none",
384
+ }}
385
+ />
386
+ </PresenceField>
387
+ );
388
+ }