@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,124 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { View, Pressable } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
import { Icon } from "./Icon";
|
|
6
|
+
|
|
7
|
+
export type ProjectRef = { name: string; label: string };
|
|
8
|
+
|
|
9
|
+
export type ProjectMenuProps = {
|
|
10
|
+
/** The project currently being edited, by manifest name. */
|
|
11
|
+
current?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Every project of the repository. Omitted, empty, or a single entry renders
|
|
14
|
+
* nothing at all — see the component note.
|
|
15
|
+
*/
|
|
16
|
+
projects?: ProjectRef[];
|
|
17
|
+
onSelect?: (project: ProjectRef) => void;
|
|
18
|
+
/**
|
|
19
|
+
* True when cms.config.js pins the deployment to one project. The current
|
|
20
|
+
* project is still shown — the operator should be able to see which one they
|
|
21
|
+
* are in — but it is inert and marked, so a locked editor never looks like a
|
|
22
|
+
* broken picker.
|
|
23
|
+
*/
|
|
24
|
+
locked?: boolean;
|
|
25
|
+
testID?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The top bar's project control, sitting to the LEFT of the branch menu: which
|
|
30
|
+
* project you are editing is a coarser choice than which branch of it, and the
|
|
31
|
+
* bar should read the same direction the URL does.
|
|
32
|
+
*
|
|
33
|
+
* It renders NOTHING when there is nothing to choose — no manifest, one project,
|
|
34
|
+
* or a repository whose projects have not loaded. That is deliberate and is the
|
|
35
|
+
* difference between this and BranchMenu, which always shows a badge: a branch
|
|
36
|
+
* is a fact worth displaying even when there is one, while "you are in the only
|
|
37
|
+
* project" is noise that would appear in every single-project repository — that
|
|
38
|
+
* is to say, in almost all of them.
|
|
39
|
+
*
|
|
40
|
+
* Project names are identifiers (they appear in URLs and in cms.config.js), so
|
|
41
|
+
* the name renders mono; the human label renders alongside it when it differs.
|
|
42
|
+
*/
|
|
43
|
+
export function ProjectMenu({ current, projects, onSelect, locked, testID = "project" }: ProjectMenuProps) {
|
|
44
|
+
const t = useTheme();
|
|
45
|
+
const [open, setOpen] = useState(false);
|
|
46
|
+
|
|
47
|
+
const list = projects ?? [];
|
|
48
|
+
// A locked editor shows its one project; otherwise there must be a real choice.
|
|
49
|
+
if (list.length === 0) return null;
|
|
50
|
+
if (list.length < 2 && !locked) return null;
|
|
51
|
+
|
|
52
|
+
const active = list.find((p) => p.name === current);
|
|
53
|
+
const shown = active?.label || active?.name || current || "";
|
|
54
|
+
|
|
55
|
+
const badge = (
|
|
56
|
+
<View
|
|
57
|
+
style={{
|
|
58
|
+
flexDirection: "row", alignItems: "center", gap: 6,
|
|
59
|
+
paddingHorizontal: t.space(2), height: t.control.sm,
|
|
60
|
+
borderRadius: t.radius.md, borderWidth: 1, borderColor: t.color.borderDefault,
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<Icon name="folder" size={13} color={t.color.textSecondary} />
|
|
64
|
+
<Text variant="monoSm" color="secondary" numberOfLines={1}>{shown}</Text>
|
|
65
|
+
{locked ? <Icon name="lock" size={12} color={t.color.textTertiary} /> : null}
|
|
66
|
+
{!locked ? <Icon name="chevronDown" size={12} color={t.color.textTertiary} /> : null}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (locked || !onSelect) {
|
|
71
|
+
return <View testID={`${testID}-badge`}>{badge}</View>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<View style={{ position: "relative" }}>
|
|
76
|
+
<Pressable onPress={() => setOpen((v) => !v)} testID={`${testID}-trigger`}>
|
|
77
|
+
{badge}
|
|
78
|
+
</Pressable>
|
|
79
|
+
{open ? (
|
|
80
|
+
<>
|
|
81
|
+
{/* Full-screen catcher so a click anywhere dismisses, matching how the
|
|
82
|
+
other top-bar menus close. */}
|
|
83
|
+
<Pressable
|
|
84
|
+
onPress={() => setOpen(false)}
|
|
85
|
+
style={{ position: "absolute", top: -2000, left: -2000, right: -2000, bottom: -2000 }}
|
|
86
|
+
testID={`${testID}-backdrop`}
|
|
87
|
+
/>
|
|
88
|
+
<View
|
|
89
|
+
style={{
|
|
90
|
+
position: "absolute", top: t.control.sm + 4, left: 0, minWidth: 220,
|
|
91
|
+
backgroundColor: t.color.surfaceRaised, borderRadius: t.radius.md,
|
|
92
|
+
borderWidth: 1, borderColor: t.color.borderDefault,
|
|
93
|
+
paddingVertical: t.space(1), zIndex: 50,
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
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>
|
|
115
|
+
) : null}
|
|
116
|
+
</View>
|
|
117
|
+
</Pressable>
|
|
118
|
+
))}
|
|
119
|
+
</View>
|
|
120
|
+
</>
|
|
121
|
+
) : null}
|
|
122
|
+
</View>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, Pressable } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Text } from "./Text";
|
|
5
|
+
|
|
6
|
+
export type SegmentItem = {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
/** Rendered dimmed and unpressable. Used for a surface with nothing to show. */
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type SegmentProps = {
|
|
14
|
+
items: SegmentItem[];
|
|
15
|
+
activeKey: string;
|
|
16
|
+
onSelect: (key: string) => void;
|
|
17
|
+
testID?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Segmented control for switching between surfaces of the same content — the
|
|
22
|
+
* editor's Edit / Changes switch, which lives in the top bar's center slot.
|
|
23
|
+
*
|
|
24
|
+
* The track is a sunken well with a hairline border; the active item is a raised
|
|
25
|
+
* pill on the page surface. That inversion (active = lighter) is what makes the
|
|
26
|
+
* selection legible against the permanently dark top bar without introducing a
|
|
27
|
+
* colour, which the monochrome system doesn't have.
|
|
28
|
+
*/
|
|
29
|
+
export function Segment({ items, activeKey, onSelect, testID }: SegmentProps) {
|
|
30
|
+
const t = useTheme();
|
|
31
|
+
return (
|
|
32
|
+
<View
|
|
33
|
+
testID={testID}
|
|
34
|
+
accessibilityRole="tablist"
|
|
35
|
+
style={{
|
|
36
|
+
flexDirection: "row",
|
|
37
|
+
alignItems: "center",
|
|
38
|
+
padding: 2,
|
|
39
|
+
gap: 2,
|
|
40
|
+
borderRadius: t.radius.md,
|
|
41
|
+
borderWidth: 1,
|
|
42
|
+
borderColor: t.color.borderSubtle,
|
|
43
|
+
backgroundColor: t.color.surfaceSunken,
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{items.map((item) => {
|
|
47
|
+
const active = item.key === activeKey;
|
|
48
|
+
return (
|
|
49
|
+
<Pressable
|
|
50
|
+
key={item.key}
|
|
51
|
+
testID={`segment-${item.key}`}
|
|
52
|
+
accessibilityRole="tab"
|
|
53
|
+
// aria-selected is set directly rather than through
|
|
54
|
+
// accessibilityState: react-native-web drops the `selected` flag but
|
|
55
|
+
// does forward aria-* props. The active item is also NOT marked
|
|
56
|
+
// `disabled` — only a genuinely unavailable one is — because
|
|
57
|
+
// react-native-web renders disabled as aria-disabled, which would
|
|
58
|
+
// announce the tab you are currently on as unavailable.
|
|
59
|
+
aria-selected={active}
|
|
60
|
+
disabled={item.disabled}
|
|
61
|
+
onPress={() => {
|
|
62
|
+
if (!active) onSelect(item.key);
|
|
63
|
+
}}
|
|
64
|
+
style={({ pressed }) => ({
|
|
65
|
+
height: t.control.sm - 6,
|
|
66
|
+
paddingHorizontal: t.space(3),
|
|
67
|
+
justifyContent: "center",
|
|
68
|
+
borderRadius: t.radius.sm,
|
|
69
|
+
backgroundColor: active ? t.color.surfaceRaised : "transparent",
|
|
70
|
+
// Hover/press fades rather than shifting hue — the only motion the
|
|
71
|
+
// system allows on a control this small.
|
|
72
|
+
opacity: item.disabled ? 0.4 : pressed && !active ? 0.6 : 1,
|
|
73
|
+
})}
|
|
74
|
+
>
|
|
75
|
+
<Text
|
|
76
|
+
variant="sm"
|
|
77
|
+
weight={active ? "semibold" : "regular"}
|
|
78
|
+
color={active ? "primary" : "secondary"}
|
|
79
|
+
>
|
|
80
|
+
{item.label}
|
|
81
|
+
</Text>
|
|
82
|
+
</Pressable>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { Animated, View, Easing, StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { useTheme, useResponsive } from "../ThemeProvider";
|
|
4
|
+
import { AppShell, MobileScreen, Pane, TopBar } from "./layout";
|
|
5
|
+
import { SectionLabel } from "./primitives";
|
|
6
|
+
|
|
7
|
+
// A single pulsing opacity, shared by every SkeletonBox on screen so the shimmer
|
|
8
|
+
// stays in phase. Created once per skeleton tree and provided via context.
|
|
9
|
+
const PulseContext = createContext<Animated.AnimatedInterpolation<number> | null>(null);
|
|
10
|
+
|
|
11
|
+
function usePulse(): Animated.AnimatedInterpolation<number> {
|
|
12
|
+
const shared = useContext(PulseContext);
|
|
13
|
+
// Fallback for a SkeletonBox used outside a provider — its own local loop.
|
|
14
|
+
const local = useRef(new Animated.Value(0)).current;
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (shared) return;
|
|
17
|
+
const loop = Animated.loop(
|
|
18
|
+
Animated.sequence([
|
|
19
|
+
Animated.timing(local, { toValue: 1, duration: 700, easing: Easing.inOut(Easing.ease), useNativeDriver: false }),
|
|
20
|
+
Animated.timing(local, { toValue: 0, duration: 700, easing: Easing.inOut(Easing.ease), useNativeDriver: false }),
|
|
21
|
+
]),
|
|
22
|
+
);
|
|
23
|
+
loop.start();
|
|
24
|
+
return () => loop.stop();
|
|
25
|
+
}, [shared, local]);
|
|
26
|
+
return shared ?? local.interpolate({ inputRange: [0, 1], outputRange: [0.55, 1] });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function PulseProvider({ children }: { children: React.ReactNode }) {
|
|
30
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const loop = Animated.loop(
|
|
33
|
+
Animated.sequence([
|
|
34
|
+
Animated.timing(progress, { toValue: 1, duration: 700, easing: Easing.inOut(Easing.ease), useNativeDriver: false }),
|
|
35
|
+
Animated.timing(progress, { toValue: 0, duration: 700, easing: Easing.inOut(Easing.ease), useNativeDriver: false }),
|
|
36
|
+
]),
|
|
37
|
+
);
|
|
38
|
+
loop.start();
|
|
39
|
+
return () => loop.stop();
|
|
40
|
+
}, [progress]);
|
|
41
|
+
const opacity = useMemo(
|
|
42
|
+
() => progress.interpolate({ inputRange: [0, 1], outputRange: [0.55, 1] }),
|
|
43
|
+
[progress],
|
|
44
|
+
);
|
|
45
|
+
return <PulseContext.Provider value={opacity}>{children}</PulseContext.Provider>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A single pulsing placeholder block. Width/height/radius are caller-controlled. */
|
|
49
|
+
export function SkeletonBox({
|
|
50
|
+
width,
|
|
51
|
+
height,
|
|
52
|
+
radius,
|
|
53
|
+
style,
|
|
54
|
+
}: {
|
|
55
|
+
width?: number | `${number}%`;
|
|
56
|
+
height?: number;
|
|
57
|
+
radius?: number;
|
|
58
|
+
style?: StyleProp<ViewStyle>;
|
|
59
|
+
}) {
|
|
60
|
+
const t = useTheme();
|
|
61
|
+
const opacity = usePulse();
|
|
62
|
+
return (
|
|
63
|
+
<Animated.View
|
|
64
|
+
style={[
|
|
65
|
+
{
|
|
66
|
+
width,
|
|
67
|
+
height: height ?? 12,
|
|
68
|
+
borderRadius: radius ?? t.radius.sm,
|
|
69
|
+
backgroundColor: t.color.surfaceSunken,
|
|
70
|
+
opacity,
|
|
71
|
+
},
|
|
72
|
+
style,
|
|
73
|
+
]}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// One placeholder nav item: icon square + label line.
|
|
79
|
+
function NavRowSkeleton() {
|
|
80
|
+
const t = useTheme();
|
|
81
|
+
return (
|
|
82
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2.5), paddingHorizontal: t.space(4), height: t.control.md }}>
|
|
83
|
+
<SkeletonBox width={16} height={16} radius={t.radius.sm} />
|
|
84
|
+
<SkeletonBox width={110} height={12} />
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One placeholder entry: title line over path line.
|
|
90
|
+
function EntryRowSkeleton() {
|
|
91
|
+
const t = useTheme();
|
|
92
|
+
return (
|
|
93
|
+
<View style={{ paddingHorizontal: t.space(4), paddingVertical: t.space(2), gap: t.space(1.5) }}>
|
|
94
|
+
<SkeletonBox width="70%" height={12} />
|
|
95
|
+
<SkeletonBox width="45%" height={10} />
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function DesktopSkeleton() {
|
|
101
|
+
const t = useTheme();
|
|
102
|
+
return (
|
|
103
|
+
<AppShell
|
|
104
|
+
testID="autologin-skeleton"
|
|
105
|
+
topBar={
|
|
106
|
+
<TopBar
|
|
107
|
+
left={
|
|
108
|
+
<>
|
|
109
|
+
<SkeletonBox width={24} height={24} radius={t.radius.sm} />
|
|
110
|
+
<SkeletonBox width={130} height={14} />
|
|
111
|
+
<SkeletonBox width={72} height={t.control.sm} radius={t.radius.md} />
|
|
112
|
+
</>
|
|
113
|
+
}
|
|
114
|
+
right={
|
|
115
|
+
<>
|
|
116
|
+
<SkeletonBox width={t.control.sm} height={t.control.sm} radius={t.radius.md} />
|
|
117
|
+
<SkeletonBox width={t.control.sm} height={t.control.sm} radius={t.radius.md} />
|
|
118
|
+
<SkeletonBox width={28} height={28} radius={t.radius.sm} />
|
|
119
|
+
</>
|
|
120
|
+
}
|
|
121
|
+
/>
|
|
122
|
+
}
|
|
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 */}
|
|
134
|
+
<Pane
|
|
135
|
+
width={340}
|
|
136
|
+
testID="skeleton-pane-entries"
|
|
137
|
+
header={
|
|
138
|
+
<>
|
|
139
|
+
<View style={{ flex: 1 }}><SkeletonBox width={120} height={16} /></View>
|
|
140
|
+
<SkeletonBox width={72} height={t.control.sm} radius={t.radius.md} />
|
|
141
|
+
</>
|
|
142
|
+
}
|
|
143
|
+
>
|
|
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} />)}
|
|
148
|
+
</Pane>
|
|
149
|
+
|
|
150
|
+
{/* detail */}
|
|
151
|
+
<Pane flex={1} borderRight={false} scroll={false} testID="skeleton-pane-detail">
|
|
152
|
+
<View
|
|
153
|
+
style={{
|
|
154
|
+
height: t.layout.topbar, paddingHorizontal: t.space(5),
|
|
155
|
+
flexDirection: "row", alignItems: "center", gap: t.space(2),
|
|
156
|
+
borderBottomWidth: 1, borderBottomColor: t.color.borderSubtle,
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<SkeletonBox width={220} height={12} />
|
|
160
|
+
</View>
|
|
161
|
+
<View style={{ padding: t.space(5), gap: t.space(4), flex: 1 }}>
|
|
162
|
+
<View style={{ gap: t.space(2) }}>
|
|
163
|
+
<SkeletonBox width={48} height={10} />
|
|
164
|
+
<SkeletonBox width="55%" height={24} radius={t.radius.md} />
|
|
165
|
+
</View>
|
|
166
|
+
<View style={{ gap: t.space(2), flex: 1 }}>
|
|
167
|
+
<SkeletonBox width={48} height={10} />
|
|
168
|
+
<SkeletonBox width="100%" height={0} radius={t.radius.md} style={{ flex: 1, minHeight: 160 }} />
|
|
169
|
+
</View>
|
|
170
|
+
</View>
|
|
171
|
+
</Pane>
|
|
172
|
+
</AppShell>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function MobileSkeleton() {
|
|
177
|
+
const t = useTheme();
|
|
178
|
+
return (
|
|
179
|
+
<MobileScreen
|
|
180
|
+
testID="autologin-skeleton"
|
|
181
|
+
header={
|
|
182
|
+
<>
|
|
183
|
+
<SkeletonBox width={32} height={32} radius={t.radius.sm} />
|
|
184
|
+
<View style={{ flex: 1, gap: t.space(1.5) }}>
|
|
185
|
+
<SkeletonBox width={140} height={14} />
|
|
186
|
+
<SkeletonBox width={90} height={10} />
|
|
187
|
+
</View>
|
|
188
|
+
<SkeletonBox width={t.control.md} height={t.control.md} radius={t.radius.md} />
|
|
189
|
+
</>
|
|
190
|
+
}
|
|
191
|
+
>
|
|
192
|
+
<View style={{ paddingHorizontal: t.space(4), paddingTop: t.space(3), paddingBottom: t.space(3) }}>
|
|
193
|
+
<SkeletonBox width={160} height={30} radius={t.radius.md} />
|
|
194
|
+
</View>
|
|
195
|
+
<SectionLabel><SkeletonBox width={60} height={9} /></SectionLabel>
|
|
196
|
+
{[0, 1, 2, 3].map((i) => (
|
|
197
|
+
<View key={i} style={{ paddingVertical: t.space(1) }}>
|
|
198
|
+
<NavRowSkeleton />
|
|
199
|
+
</View>
|
|
200
|
+
))}
|
|
201
|
+
</MobileScreen>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Full-shell loading placeholder shown while an autologin token is being
|
|
207
|
+
* exchanged. Mirrors the ContentBrowser layout (same panes, widths, and borders)
|
|
208
|
+
* so the real content mounts into place without a jarring layout shift — and the
|
|
209
|
+
* login form never flashes on the way in.
|
|
210
|
+
*/
|
|
211
|
+
export function ContentBrowserSkeleton() {
|
|
212
|
+
const { isDesktop } = useResponsive();
|
|
213
|
+
return (
|
|
214
|
+
<PulseProvider>{isDesktop ? <DesktopSkeleton /> : <MobileSkeleton />}</PulseProvider>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from "react";
|
|
2
|
+
import { Animated, Easing } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
import { Icon } from "./Icon";
|
|
5
|
+
|
|
6
|
+
export type SpinnerProps = {
|
|
7
|
+
size?: number;
|
|
8
|
+
color?: string;
|
|
9
|
+
testID?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A continuously rotating ring — "something is happening, and it will finish".
|
|
14
|
+
*
|
|
15
|
+
* Deliberately the line-icon set's `loader` rather than the platform's
|
|
16
|
+
* ActivityIndicator: this one sits inline among other 16px icons (in place of
|
|
17
|
+
* the notification bell, most of all) and has to match their weight and color
|
|
18
|
+
* exactly. ActivityIndicator is still the right choice inside a button, where it
|
|
19
|
+
* stands alone.
|
|
20
|
+
*/
|
|
21
|
+
export function Spinner({ size = 16, color, testID }: SpinnerProps) {
|
|
22
|
+
const t = useTheme();
|
|
23
|
+
const spin = useRef(new Animated.Value(0)).current;
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const loop = Animated.loop(
|
|
26
|
+
Animated.timing(spin, {
|
|
27
|
+
toValue: 1,
|
|
28
|
+
duration: 900,
|
|
29
|
+
easing: Easing.linear,
|
|
30
|
+
// The web renderer drives transforms off the main thread anyway, and the
|
|
31
|
+
// native driver would forbid the interpolation below on some versions.
|
|
32
|
+
useNativeDriver: false,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
loop.start();
|
|
36
|
+
return () => loop.stop();
|
|
37
|
+
}, [spin]);
|
|
38
|
+
const rotate = spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] });
|
|
39
|
+
return (
|
|
40
|
+
<Animated.View testID={testID} style={{ width: size, height: size, transform: [{ rotate }] }}>
|
|
41
|
+
<Icon name="loader" size={size} color={color ?? t.color.textSecondary} />
|
|
42
|
+
</Animated.View>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Text as RNText, StyleProp, TextStyle } from "react-native";
|
|
3
|
+
import { useTheme } from "../ThemeProvider";
|
|
4
|
+
|
|
5
|
+
type Variant =
|
|
6
|
+
| "display" | "h1" | "h2" | "h3"
|
|
7
|
+
| "body" | "bodyLg" | "sm" | "xs"
|
|
8
|
+
| "mono" | "monoSm" | "label";
|
|
9
|
+
|
|
10
|
+
type ColorRole = "primary" | "secondary" | "tertiary" | "disabled" | "inverted";
|
|
11
|
+
type Weight = "light" | "regular" | "medium" | "semibold" | "bold";
|
|
12
|
+
|
|
13
|
+
export type TextProps = {
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
variant?: Variant;
|
|
16
|
+
color?: ColorRole | string;
|
|
17
|
+
weight?: Weight;
|
|
18
|
+
mono?: boolean;
|
|
19
|
+
align?: "left" | "center" | "right";
|
|
20
|
+
numberOfLines?: number;
|
|
21
|
+
style?: StyleProp<TextStyle>;
|
|
22
|
+
testID?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function Text({
|
|
26
|
+
children,
|
|
27
|
+
variant = "body",
|
|
28
|
+
color = "primary",
|
|
29
|
+
weight,
|
|
30
|
+
mono,
|
|
31
|
+
align,
|
|
32
|
+
numberOfLines,
|
|
33
|
+
style,
|
|
34
|
+
testID,
|
|
35
|
+
...props
|
|
36
|
+
}: TextProps) {
|
|
37
|
+
const t = useTheme();
|
|
38
|
+
|
|
39
|
+
const spec = variant === "label" ? t.type.xs : t.type[variant];
|
|
40
|
+
const isMono = mono || variant === "mono" || variant === "monoSm" || variant === "label";
|
|
41
|
+
const isLabel = variant === "label";
|
|
42
|
+
|
|
43
|
+
const roleColor: Record<ColorRole, string> = {
|
|
44
|
+
primary: t.color.textPrimary,
|
|
45
|
+
secondary: t.color.textSecondary,
|
|
46
|
+
tertiary: t.color.textTertiary,
|
|
47
|
+
disabled: t.color.textDisabled,
|
|
48
|
+
inverted: t.color.textInverted,
|
|
49
|
+
};
|
|
50
|
+
const resolvedColor =
|
|
51
|
+
color in roleColor ? roleColor[color as ColorRole] : (color as string);
|
|
52
|
+
|
|
53
|
+
const defaultWeight: Weight =
|
|
54
|
+
variant === "display" || variant === "h1" ? "bold"
|
|
55
|
+
: variant === "h2" || variant === "h3" ? "semibold"
|
|
56
|
+
: isLabel ? "medium"
|
|
57
|
+
: "regular";
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<RNText
|
|
61
|
+
testID={testID}
|
|
62
|
+
numberOfLines={numberOfLines}
|
|
63
|
+
style={[
|
|
64
|
+
{
|
|
65
|
+
fontFamily: isMono ? t.font.mono : t.font.sans,
|
|
66
|
+
fontSize: isLabel ? t.type.xs.size : spec.size,
|
|
67
|
+
lineHeight: isLabel ? t.type.xs.line : spec.line,
|
|
68
|
+
color: resolvedColor,
|
|
69
|
+
fontWeight: t.weight[weight ?? defaultWeight],
|
|
70
|
+
letterSpacing:
|
|
71
|
+
isLabel ? t.letterSpacing.label
|
|
72
|
+
: variant === "display" || variant === "h1" || variant === "h2"
|
|
73
|
+
? t.letterSpacing.tight
|
|
74
|
+
: t.letterSpacing.normal,
|
|
75
|
+
textTransform: isLabel ? "uppercase" : "none",
|
|
76
|
+
textAlign: align,
|
|
77
|
+
},
|
|
78
|
+
style,
|
|
79
|
+
]}
|
|
80
|
+
{...props}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</RNText>
|
|
84
|
+
);
|
|
85
|
+
}
|