@startsimpli/ui-native 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +174 -2
- package/package.json +60 -3
- package/src/__tests__/charts-math.test.ts +73 -0
- package/src/__tests__/styles-dashboard.test.ts +92 -0
- package/src/__tests__/styles-primitives.test.ts +114 -0
- package/src/__tests__/styles.test.ts +101 -0
- package/src/__tests__/theme.test.ts +23 -0
- package/src/__tests__/tokens.test.ts +33 -0
- package/src/charts-math.ts +106 -0
- package/src/components/AccentContext.tsx +22 -0
- package/src/components/Alert.tsx +27 -0
- package/src/components/Avatar.tsx +42 -0
- package/src/components/Badge.tsx +18 -0
- package/src/components/Button.tsx +40 -0
- package/src/components/Card.tsx +30 -0
- package/src/components/Checkbox.tsx +31 -0
- package/src/components/Chip.tsx +26 -0
- package/src/components/CompareChart.tsx +56 -0
- package/src/components/DataTable.tsx +89 -0
- package/src/components/DemographicBars.tsx +50 -0
- package/src/components/DonutChart.tsx +57 -0
- package/src/components/DonutLegend.tsx +49 -0
- package/src/components/DualRangeControl.tsx +31 -0
- package/src/components/EmptyState.tsx +63 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/Flag.tsx +26 -0
- package/src/components/GradientBox.tsx +48 -0
- package/src/components/HeroBanner.tsx +160 -0
- package/src/components/Input.tsx +33 -0
- package/src/components/Label.tsx +9 -0
- package/src/components/ListRow.tsx +44 -0
- package/src/components/MetricRow.tsx +48 -0
- package/src/components/MultiLineLegend.tsx +30 -0
- package/src/components/Progress.tsx +18 -0
- package/src/components/Screen.tsx +38 -0
- package/src/components/SegmentedControl.tsx +31 -0
- package/src/components/Separator.tsx +13 -0
- package/src/components/ShareBar.tsx +32 -0
- package/src/components/Skeleton.tsx +29 -0
- package/src/components/SkeletonList.tsx +23 -0
- package/src/components/Sparkline.tsx +38 -0
- package/src/components/Spinner.tsx +8 -0
- package/src/components/StatTile.tsx +69 -0
- package/src/components/Switch.tsx +17 -0
- package/src/components/TabBar.tsx +50 -0
- package/src/components/Text.tsx +13 -0
- package/src/components/ThemeProvider.tsx +25 -0
- package/src/components/Timeline.tsx +47 -0
- package/src/components/TrendCard.tsx +39 -0
- package/src/components/TrendChart.tsx +68 -0
- package/src/index.ts +64 -0
- package/src/styles.ts +479 -0
- package/src/theme.ts +46 -0
- package/src/tokens.ts +145 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { DonutChart } from './DonutChart';
|
|
4
|
+
import { Text } from './Text';
|
|
5
|
+
|
|
6
|
+
export interface DonutLegendItem {
|
|
7
|
+
label: string;
|
|
8
|
+
/** Segment magnitude. */
|
|
9
|
+
value: number;
|
|
10
|
+
color: string;
|
|
11
|
+
/** Optional secondary line under the label. */
|
|
12
|
+
caption?: string;
|
|
13
|
+
/** Right-aligned content (formatted value, badge, sparkline…). */
|
|
14
|
+
trailing?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DonutLegendProps {
|
|
18
|
+
items: DonutLegendItem[];
|
|
19
|
+
size?: number;
|
|
20
|
+
thickness?: number;
|
|
21
|
+
/** Content rendered in the donut hole. */
|
|
22
|
+
center?: ReactNode;
|
|
23
|
+
/** Gap between the donut and the legend. */
|
|
24
|
+
gap?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Donut chart beside a colored-dot legend. The common "breakdown" block
|
|
28
|
+
* (revenue by source, fans by platform, …). */
|
|
29
|
+
export function DonutLegend({ items, size = 130, thickness = 16, center, gap = 16 }: DonutLegendProps) {
|
|
30
|
+
return (
|
|
31
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', gap }}>
|
|
32
|
+
<DonutChart size={size} thickness={thickness} segments={items.map((i) => ({ value: i.value, color: i.color }))}>
|
|
33
|
+
{center}
|
|
34
|
+
</DonutChart>
|
|
35
|
+
<View style={{ flex: 1, gap: 10 }}>
|
|
36
|
+
{items.map((it, idx) => (
|
|
37
|
+
<View key={`${it.label}-${idx}`} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
|
38
|
+
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: it.color }} />
|
|
39
|
+
<View style={{ flex: 1 }}>
|
|
40
|
+
<Text variant="muted">{it.label}</Text>
|
|
41
|
+
{it.caption ? <Text variant="caption">{it.caption}</Text> : null}
|
|
42
|
+
</View>
|
|
43
|
+
{it.trailing ? <View>{it.trailing}</View> : null}
|
|
44
|
+
</View>
|
|
45
|
+
))}
|
|
46
|
+
</View>
|
|
47
|
+
</View>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
|
2
|
+
import { Text } from './Text';
|
|
3
|
+
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
|
4
|
+
|
|
5
|
+
export interface DualRangeControlProps<T extends string | number> {
|
|
6
|
+
/** Comparison baselines, e.g. previous period / same period last year / none. */
|
|
7
|
+
options: SegmentedOption<T>[];
|
|
8
|
+
value: T;
|
|
9
|
+
onChange: (value: T) => void;
|
|
10
|
+
/** Leading label, e.g. "Compare to". */
|
|
11
|
+
label?: string;
|
|
12
|
+
style?: StyleProp<ViewStyle>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Period-comparison selector: pick what the current range is measured against
|
|
16
|
+
* (the period math lives in @startsimpli/utils). A labeled SegmentedControl —
|
|
17
|
+
* generic over the baseline value type. */
|
|
18
|
+
export function DualRangeControl<T extends string | number>({
|
|
19
|
+
options,
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
label,
|
|
23
|
+
style,
|
|
24
|
+
}: DualRangeControlProps<T>) {
|
|
25
|
+
return (
|
|
26
|
+
<View style={[{ gap: 6 }, style]}>
|
|
27
|
+
{label ? <Text variant="muted">{label}</Text> : null}
|
|
28
|
+
<SegmentedControl options={options} value={value} onChange={onChange} />
|
|
29
|
+
</View>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { Text } from './Text';
|
|
4
|
+
import { Button } from './Button';
|
|
5
|
+
import { useTheme } from './ThemeProvider';
|
|
6
|
+
|
|
7
|
+
export interface EmptyStateProps {
|
|
8
|
+
/** Large glyph above the title — an emoji string or a node (e.g. an icon). */
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Optional primary action. Both label and handler must be set to render it. */
|
|
13
|
+
actionLabel?: string;
|
|
14
|
+
onAction?: () => void;
|
|
15
|
+
/** Layout overrides (e.g. margins), merged after the base style. */
|
|
16
|
+
style?: StyleProp<ViewStyle>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Centered zero-data / first-run placeholder: optional glyph, a title, an
|
|
21
|
+
* optional description, and an optional primary action. Use it instead of
|
|
22
|
+
* leaving a screen blank (or showing a bare table header) when there's nothing
|
|
23
|
+
* to display yet.
|
|
24
|
+
*/
|
|
25
|
+
export function EmptyState({ icon, title, description, actionLabel, onAction, style }: EmptyStateProps) {
|
|
26
|
+
const theme = useTheme();
|
|
27
|
+
return (
|
|
28
|
+
<View
|
|
29
|
+
style={[
|
|
30
|
+
{
|
|
31
|
+
alignItems: 'center',
|
|
32
|
+
justifyContent: 'center',
|
|
33
|
+
paddingVertical: theme.spacing['2xl'],
|
|
34
|
+
paddingHorizontal: theme.spacing.xl,
|
|
35
|
+
gap: theme.spacing.sm,
|
|
36
|
+
},
|
|
37
|
+
style,
|
|
38
|
+
]}
|
|
39
|
+
>
|
|
40
|
+
{icon != null
|
|
41
|
+
? typeof icon === 'string'
|
|
42
|
+
? <Text style={{ fontSize: 40, lineHeight: 48 }}>{icon}</Text>
|
|
43
|
+
: icon
|
|
44
|
+
: null}
|
|
45
|
+
<Text variant="heading" style={{ textAlign: 'center' }}>
|
|
46
|
+
{title}
|
|
47
|
+
</Text>
|
|
48
|
+
{description ? (
|
|
49
|
+
<Text variant="muted" style={{ textAlign: 'center' }}>
|
|
50
|
+
{description}
|
|
51
|
+
</Text>
|
|
52
|
+
) : null}
|
|
53
|
+
{actionLabel && onAction ? (
|
|
54
|
+
<Button
|
|
55
|
+
title={actionLabel}
|
|
56
|
+
variant="primary"
|
|
57
|
+
onPress={onAction}
|
|
58
|
+
style={{ marginTop: theme.spacing.sm, alignSelf: 'stretch' }}
|
|
59
|
+
/>
|
|
60
|
+
) : null}
|
|
61
|
+
</View>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Component, type ReactNode } from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { Screen } from './Screen';
|
|
4
|
+
import { Text } from './Text';
|
|
5
|
+
import { Alert } from './Alert';
|
|
6
|
+
import { Button } from './Button';
|
|
7
|
+
|
|
8
|
+
export interface ErrorBoundaryProps {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
/** Custom fallback; receives a `reset` to clear the error and re-render. */
|
|
11
|
+
fallback?: (reset: () => void, error: Error) => ReactNode;
|
|
12
|
+
/** Side-effect on catch (logging/telemetry). */
|
|
13
|
+
onError?: (error: Error) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface State {
|
|
17
|
+
error: Error | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Catches render errors in its subtree and shows a fallback instead of an
|
|
21
|
+
* unmounted (blank) tree. Must be a class component (React error boundary). */
|
|
22
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, State> {
|
|
23
|
+
state: State = { error: null };
|
|
24
|
+
|
|
25
|
+
static getDerivedStateFromError(error: Error): State {
|
|
26
|
+
return { error };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
componentDidCatch(error: Error) {
|
|
30
|
+
this.props.onError?.(error);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
reset = () => this.setState({ error: null });
|
|
34
|
+
|
|
35
|
+
render() {
|
|
36
|
+
const { error } = this.state;
|
|
37
|
+
if (error) {
|
|
38
|
+
if (this.props.fallback) return this.props.fallback(this.reset, error);
|
|
39
|
+
return (
|
|
40
|
+
<Screen>
|
|
41
|
+
<View style={{ flex: 1, justifyContent: 'center', gap: 16 }}>
|
|
42
|
+
<Alert title="Something went wrong" variant="destructive">
|
|
43
|
+
An unexpected error occurred.
|
|
44
|
+
</Alert>
|
|
45
|
+
<Button title="Try again" variant="primary" onPress={this.reset} />
|
|
46
|
+
</View>
|
|
47
|
+
</Screen>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return this.props.children;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Image, type StyleProp, type ImageStyle } from 'react-native';
|
|
2
|
+
|
|
3
|
+
export interface FlagProps {
|
|
4
|
+
/** ISO 3166-1 alpha-2 country code (e.g. 'EG'), case-insensitive. */
|
|
5
|
+
code: string;
|
|
6
|
+
/** Flag width in px; height follows the standard 4:3 ratio. */
|
|
7
|
+
size?: number;
|
|
8
|
+
style?: StyleProp<ImageStyle>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A real country-flag image. Emoji flags ("🇪🇬") fall back to tofu boxes on any
|
|
13
|
+
* platform without a regional-indicator emoji font (most browsers/OSs outside
|
|
14
|
+
* Apple), so UI uses this instead. Sourced from the flagcdn image CDN by code.
|
|
15
|
+
*/
|
|
16
|
+
export function Flag({ code, size = 20, style }: FlagProps) {
|
|
17
|
+
const cc = code.trim().toLowerCase();
|
|
18
|
+
const height = Math.round((size * 3) / 4);
|
|
19
|
+
return (
|
|
20
|
+
<Image
|
|
21
|
+
accessibilityLabel={code.toUpperCase()}
|
|
22
|
+
source={{ uri: `https://flagcdn.com/w80/${cc}.png` }}
|
|
23
|
+
style={[{ width: size, height, borderRadius: 2 }, style]}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg';
|
|
4
|
+
import { useTheme } from './ThemeProvider';
|
|
5
|
+
|
|
6
|
+
export interface GradientBoxProps {
|
|
7
|
+
/** Two or more gradient colors, top-left to bottom-right by default. */
|
|
8
|
+
colors: string[];
|
|
9
|
+
/** Gradient direction as fractional coords. Defaults to a 140°-ish diagonal. */
|
|
10
|
+
start?: { x: number; y: number };
|
|
11
|
+
end?: { x: number; y: number };
|
|
12
|
+
borderRadius?: number;
|
|
13
|
+
style?: StyleProp<ViewStyle>;
|
|
14
|
+
children?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let gradSeq = 0;
|
|
18
|
+
|
|
19
|
+
/** A box whose background is a linear gradient (via react-native-svg), with
|
|
20
|
+
* children rendered on top. Generic — heroes, banners, callouts. */
|
|
21
|
+
export function GradientBox({
|
|
22
|
+
colors,
|
|
23
|
+
start = { x: 0, y: 0 },
|
|
24
|
+
end = { x: 1, y: 1 },
|
|
25
|
+
borderRadius,
|
|
26
|
+
style,
|
|
27
|
+
children,
|
|
28
|
+
}: GradientBoxProps) {
|
|
29
|
+
const theme = useTheme();
|
|
30
|
+
const id = `grad${gradSeq++}`;
|
|
31
|
+
const radius = borderRadius ?? theme.radii.lg;
|
|
32
|
+
const stops = colors.length >= 2 ? colors : [colors[0] ?? theme.colors.primary, colors[0] ?? theme.colors.primary];
|
|
33
|
+
return (
|
|
34
|
+
<View style={[{ borderRadius: radius, overflow: 'hidden' }, style]}>
|
|
35
|
+
<Svg width="100%" height="100%" style={{ position: 'absolute' }}>
|
|
36
|
+
<Defs>
|
|
37
|
+
<LinearGradient id={id} x1={start.x} y1={start.y} x2={end.x} y2={end.y}>
|
|
38
|
+
{stops.map((c, i) => (
|
|
39
|
+
<Stop key={i} offset={stops.length === 1 ? 0 : i / (stops.length - 1)} stopColor={c} />
|
|
40
|
+
))}
|
|
41
|
+
</LinearGradient>
|
|
42
|
+
</Defs>
|
|
43
|
+
<Rect x={0} y={0} width="100%" height="100%" fill={`url(#${id})`} />
|
|
44
|
+
</Svg>
|
|
45
|
+
{children}
|
|
46
|
+
</View>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-bleed gradient hero region. Composes GradientBox with sensible defaults
|
|
3
|
+
* for a screen-top hero: rounded corners, generous padding, optional accent
|
|
4
|
+
* overlay. If `colors` is omitted, derives a 2-stop gradient from the current
|
|
5
|
+
* accent → deep slate, giving each screen the artist-immersive feel.
|
|
6
|
+
*
|
|
7
|
+
* Optional `backdrop` lays an image (e.g. an artist portrait) UNDER the
|
|
8
|
+
* gradient with low opacity, so the screen reads as "the artist's page".
|
|
9
|
+
*
|
|
10
|
+
* Generic — any app can use this for hero sections; the accent comes from
|
|
11
|
+
* AccentContext, so the colour rotates per the screen's context.
|
|
12
|
+
*/
|
|
13
|
+
import { type ReactNode } from 'react';
|
|
14
|
+
import {
|
|
15
|
+
Image,
|
|
16
|
+
View,
|
|
17
|
+
type ImageSourcePropType,
|
|
18
|
+
type StyleProp,
|
|
19
|
+
type ViewStyle,
|
|
20
|
+
} from 'react-native';
|
|
21
|
+
import { GradientBox } from './GradientBox';
|
|
22
|
+
import { useTheme } from './ThemeProvider';
|
|
23
|
+
import { useAccent } from './AccentContext';
|
|
24
|
+
|
|
25
|
+
export interface HeroBannerProps {
|
|
26
|
+
/** Override the auto-derived gradient. Two or more colours. */
|
|
27
|
+
colors?: string[];
|
|
28
|
+
/** Override accent (otherwise pulled from AccentContext). */
|
|
29
|
+
accent?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Optional image laid behind / over the gradient.
|
|
32
|
+
* - mode 'photo' (default): image is the visual, darkened-bottom overlay so
|
|
33
|
+
* text stays legible (Apple-Music album-page hero).
|
|
34
|
+
* - mode 'tint': accent gradient is the visual, image overlays at
|
|
35
|
+
* low opacity to add texture (data-forward heroes).
|
|
36
|
+
*/
|
|
37
|
+
backdrop?: ImageSourcePropType;
|
|
38
|
+
/** How to layer the backdrop. Defaults to 'photo'. */
|
|
39
|
+
backdropMode?: 'photo' | 'tint';
|
|
40
|
+
/** Backdrop opacity (0–1). Defaults: photo=0.85, tint=0.22. */
|
|
41
|
+
backdropOpacity?: number;
|
|
42
|
+
/** Container style. Margins, padding, borderRadius overrides land here. */
|
|
43
|
+
style?: StyleProp<ViewStyle>;
|
|
44
|
+
/** Inner content (numbers, labels). */
|
|
45
|
+
children?: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function HeroBanner({
|
|
49
|
+
colors,
|
|
50
|
+
accent,
|
|
51
|
+
backdrop,
|
|
52
|
+
backdropMode = 'photo',
|
|
53
|
+
backdropOpacity,
|
|
54
|
+
style,
|
|
55
|
+
children,
|
|
56
|
+
}: HeroBannerProps) {
|
|
57
|
+
const theme = useTheme();
|
|
58
|
+
const fallbackAccent = useAccent();
|
|
59
|
+
const stops = colors ?? [accent ?? fallbackAccent, theme.colors.background];
|
|
60
|
+
const opacity = backdropOpacity ?? (backdropMode === 'tint' ? 0.22 : 0.85);
|
|
61
|
+
|
|
62
|
+
// Tint mode: accent gradient is the visual; portrait sits over it at low
|
|
63
|
+
// opacity for texture. Children render on top of both.
|
|
64
|
+
if (backdrop && backdropMode === 'tint') {
|
|
65
|
+
return (
|
|
66
|
+
<View
|
|
67
|
+
style={[
|
|
68
|
+
{ borderRadius: theme.radii.lg, overflow: 'hidden' },
|
|
69
|
+
style,
|
|
70
|
+
]}
|
|
71
|
+
>
|
|
72
|
+
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
|
|
73
|
+
<GradientBox
|
|
74
|
+
colors={stops}
|
|
75
|
+
start={{ x: 0, y: 0 }}
|
|
76
|
+
end={{ x: 1, y: 1 }}
|
|
77
|
+
style={{ width: '100%', height: '100%' }}
|
|
78
|
+
/>
|
|
79
|
+
</View>
|
|
80
|
+
<View
|
|
81
|
+
pointerEvents="none"
|
|
82
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
|
|
83
|
+
>
|
|
84
|
+
<Image
|
|
85
|
+
source={backdrop}
|
|
86
|
+
style={{ width: '100%', height: '100%', opacity }}
|
|
87
|
+
resizeMode="cover"
|
|
88
|
+
/>
|
|
89
|
+
</View>
|
|
90
|
+
<View
|
|
91
|
+
style={{
|
|
92
|
+
paddingVertical: theme.spacing.xl,
|
|
93
|
+
paddingHorizontal: theme.spacing.lg,
|
|
94
|
+
gap: theme.spacing.xs,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{children}
|
|
98
|
+
</View>
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// If a backdrop is provided we need a clipping wrapper so the image
|
|
104
|
+
// (absolute-positioned beneath the gradient layer) is rounded to the same
|
|
105
|
+
// borderRadius as the gradient. Otherwise the GradientBox draws directly.
|
|
106
|
+
if (backdrop) {
|
|
107
|
+
// Photo mode: artist photo at full size + a vertical darkening overlay so
|
|
108
|
+
// text on top stays legible. Accent isn't in the gradient here — the
|
|
109
|
+
// image itself is the visual.
|
|
110
|
+
return (
|
|
111
|
+
<View
|
|
112
|
+
style={[
|
|
113
|
+
{
|
|
114
|
+
borderRadius: theme.radii.lg,
|
|
115
|
+
overflow: 'hidden',
|
|
116
|
+
backgroundColor: theme.colors.background,
|
|
117
|
+
},
|
|
118
|
+
style,
|
|
119
|
+
]}
|
|
120
|
+
>
|
|
121
|
+
<Image
|
|
122
|
+
source={backdrop}
|
|
123
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, opacity }}
|
|
124
|
+
resizeMode="cover"
|
|
125
|
+
/>
|
|
126
|
+
<GradientBox
|
|
127
|
+
colors={['#00000000', '#000000B3']}
|
|
128
|
+
start={{ x: 0, y: 0 }}
|
|
129
|
+
end={{ x: 0, y: 1 }}
|
|
130
|
+
style={{
|
|
131
|
+
paddingVertical: theme.spacing.xl,
|
|
132
|
+
paddingHorizontal: theme.spacing.lg,
|
|
133
|
+
gap: theme.spacing.xs,
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</GradientBox>
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<GradientBox
|
|
144
|
+
colors={stops}
|
|
145
|
+
start={{ x: 0, y: 0 }}
|
|
146
|
+
end={{ x: 1, y: 1 }}
|
|
147
|
+
borderRadius={theme.radii.lg}
|
|
148
|
+
style={[
|
|
149
|
+
{
|
|
150
|
+
paddingVertical: theme.spacing.xl,
|
|
151
|
+
paddingHorizontal: theme.spacing.lg,
|
|
152
|
+
gap: theme.spacing.xs,
|
|
153
|
+
},
|
|
154
|
+
style,
|
|
155
|
+
]}
|
|
156
|
+
>
|
|
157
|
+
{children}
|
|
158
|
+
</GradientBox>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { TextInput, type TextInputProps } from 'react-native';
|
|
3
|
+
import { useTheme } from './ThemeProvider';
|
|
4
|
+
import { inputStyle } from '../styles';
|
|
5
|
+
|
|
6
|
+
export interface InputProps extends TextInputProps {
|
|
7
|
+
invalid?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Themed single-line text field. Mirrors @startsimpli/ui input. */
|
|
11
|
+
export function Input({ invalid = false, style, onFocus, onBlur, ...rest }: InputProps) {
|
|
12
|
+
const theme = useTheme();
|
|
13
|
+
const [focused, setFocused] = useState(false);
|
|
14
|
+
return (
|
|
15
|
+
<TextInput
|
|
16
|
+
placeholderTextColor={theme.colors.mutedForeground}
|
|
17
|
+
style={[
|
|
18
|
+
inputStyle(theme, { invalid }),
|
|
19
|
+
focused ? { borderColor: theme.colors.ring } : null,
|
|
20
|
+
style,
|
|
21
|
+
]}
|
|
22
|
+
onFocus={(e) => {
|
|
23
|
+
setFocused(true);
|
|
24
|
+
onFocus?.(e);
|
|
25
|
+
}}
|
|
26
|
+
onBlur={(e) => {
|
|
27
|
+
setFocused(false);
|
|
28
|
+
onBlur?.(e);
|
|
29
|
+
}}
|
|
30
|
+
{...rest}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Text as RNText, type TextProps } from 'react-native';
|
|
2
|
+
import { useTheme } from './ThemeProvider';
|
|
3
|
+
import { labelStyle } from '../styles';
|
|
4
|
+
|
|
5
|
+
/** Form field label. */
|
|
6
|
+
export function Label({ style, ...rest }: TextProps) {
|
|
7
|
+
const theme = useTheme();
|
|
8
|
+
return <RNText style={[labelStyle(theme), style]} {...rest} />;
|
|
9
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { View, Pressable, Text as RNText, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useTheme } from './ThemeProvider';
|
|
4
|
+
import { listRowStyle, listRowTitleStyle, listRowSubtitleStyle } from '../styles';
|
|
5
|
+
|
|
6
|
+
export interface ListRowProps {
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
/** Leading node, e.g. an Avatar or icon. */
|
|
10
|
+
leading?: ReactNode;
|
|
11
|
+
/** Trailing node, e.g. a value, Badge, or chevron. */
|
|
12
|
+
trailing?: ReactNode;
|
|
13
|
+
/** When set, the row becomes a pressable. */
|
|
14
|
+
onPress?: () => void;
|
|
15
|
+
style?: StyleProp<ViewStyle>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Leading · (title / subtitle) · trailing row. Pressable when `onPress` is set. */
|
|
19
|
+
export function ListRow({ title, subtitle, leading, trailing, onPress, style }: ListRowProps) {
|
|
20
|
+
const theme = useTheme();
|
|
21
|
+
const content = (
|
|
22
|
+
<>
|
|
23
|
+
{leading ? <View>{leading}</View> : null}
|
|
24
|
+
<View style={{ flex: 1 }}>
|
|
25
|
+
<RNText style={listRowTitleStyle(theme)}>{title}</RNText>
|
|
26
|
+
{subtitle ? <RNText style={listRowSubtitleStyle(theme)}>{subtitle}</RNText> : null}
|
|
27
|
+
</View>
|
|
28
|
+
{trailing ? <View>{trailing}</View> : null}
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
if (onPress) {
|
|
33
|
+
return (
|
|
34
|
+
<Pressable
|
|
35
|
+
accessibilityRole="button"
|
|
36
|
+
onPress={onPress}
|
|
37
|
+
style={({ pressed }) => [listRowStyle(theme), pressed ? { opacity: 0.6 } : null, style]}
|
|
38
|
+
>
|
|
39
|
+
{content}
|
|
40
|
+
</Pressable>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return <View style={[listRowStyle(theme), style]}>{content}</View>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { View, Pressable, Text as RNText, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useTheme } from './ThemeProvider';
|
|
4
|
+
import { Sparkline } from './Sparkline';
|
|
5
|
+
import { listRowStyle, listRowTitleStyle, listRowSubtitleStyle } from '../styles';
|
|
6
|
+
|
|
7
|
+
export interface MetricRowProps {
|
|
8
|
+
/** Leading node (logo / avatar / icon). */
|
|
9
|
+
leading?: ReactNode;
|
|
10
|
+
title: string;
|
|
11
|
+
subtitle?: string;
|
|
12
|
+
/** Optional inline trend sparkline between subtitle and trailing. */
|
|
13
|
+
trend?: number[];
|
|
14
|
+
trendColor?: string;
|
|
15
|
+
/** Trailing node (delta badge, value column, chevron…). */
|
|
16
|
+
trailing?: ReactNode;
|
|
17
|
+
onPress?: () => void;
|
|
18
|
+
style?: StyleProp<ViewStyle>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Leading · (title/subtitle) · sparkline · trailing row — a ListRow augmented
|
|
22
|
+
* with an inline trend. Pressable when `onPress` is set. */
|
|
23
|
+
export function MetricRow({ leading, title, subtitle, trend, trendColor, trailing, onPress, style }: MetricRowProps) {
|
|
24
|
+
const theme = useTheme();
|
|
25
|
+
const content = (
|
|
26
|
+
<>
|
|
27
|
+
{leading ? <View>{leading}</View> : null}
|
|
28
|
+
<View style={{ flex: 1 }}>
|
|
29
|
+
<RNText style={listRowTitleStyle(theme)}>{title}</RNText>
|
|
30
|
+
{subtitle ? <RNText style={listRowSubtitleStyle(theme)}>{subtitle}</RNText> : null}
|
|
31
|
+
</View>
|
|
32
|
+
{trend && trend.length > 0 ? <Sparkline data={trend} color={trendColor} width={54} height={22} /> : null}
|
|
33
|
+
{trailing ? <View>{trailing}</View> : null}
|
|
34
|
+
</>
|
|
35
|
+
);
|
|
36
|
+
if (onPress) {
|
|
37
|
+
return (
|
|
38
|
+
<Pressable
|
|
39
|
+
accessibilityRole="button"
|
|
40
|
+
onPress={onPress}
|
|
41
|
+
style={({ pressed }) => [listRowStyle(theme), pressed ? { opacity: 0.6 } : null, style]}
|
|
42
|
+
>
|
|
43
|
+
{content}
|
|
44
|
+
</Pressable>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return <View style={[listRowStyle(theme), style]}>{content}</View>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { View, type StyleProp, type ViewStyle } from 'react-native';
|
|
2
|
+
import { Text } from './Text';
|
|
3
|
+
|
|
4
|
+
export interface MultiLineLegendItem {
|
|
5
|
+
label: string;
|
|
6
|
+
color: string;
|
|
7
|
+
/** Optional right-aligned pre-formatted value (e.g. "1.2M"). */
|
|
8
|
+
value?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MultiLineLegendProps {
|
|
12
|
+
items: MultiLineLegendItem[];
|
|
13
|
+
style?: StyleProp<ViewStyle>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Color-swatch legend for multi-series line charts (CompareChart). The swatch
|
|
17
|
+
* is a short line — signalling a series, not a donut slice. Wraps to fit. */
|
|
18
|
+
export function MultiLineLegend({ items, style }: MultiLineLegendProps) {
|
|
19
|
+
return (
|
|
20
|
+
<View style={[{ flexDirection: 'row', flexWrap: 'wrap', gap: 14 }, style]}>
|
|
21
|
+
{items.map((it, i) => (
|
|
22
|
+
<View key={`${it.label}-${i}`} style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
|
23
|
+
<View style={{ width: 14, height: 3, borderRadius: 2, backgroundColor: it.color }} />
|
|
24
|
+
<Text variant="muted">{it.label}</Text>
|
|
25
|
+
{it.value ? <Text variant="caption">{it.value}</Text> : null}
|
|
26
|
+
</View>
|
|
27
|
+
))}
|
|
28
|
+
</View>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { View } from 'react-native';
|
|
2
|
+
import { useTheme } from './ThemeProvider';
|
|
3
|
+
import { progressTrackStyle, progressFillStyle } from '../styles';
|
|
4
|
+
|
|
5
|
+
export interface ProgressProps {
|
|
6
|
+
/** 0–100. */
|
|
7
|
+
value: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Determinate progress bar. */
|
|
11
|
+
export function Progress({ value }: ProgressProps) {
|
|
12
|
+
const theme = useTheme();
|
|
13
|
+
return (
|
|
14
|
+
<View style={progressTrackStyle(theme)} accessibilityRole="progressbar" accessibilityValue={{ now: value, min: 0, max: 100 }}>
|
|
15
|
+
<View style={progressFillStyle(theme, value)} />
|
|
16
|
+
</View>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ScrollView, View, type ViewProps, type ViewStyle } from 'react-native';
|
|
2
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
3
|
+
import { useTheme } from './ThemeProvider';
|
|
4
|
+
|
|
5
|
+
export interface ScreenProps extends ViewProps {
|
|
6
|
+
/** Wrap content in a ScrollView (for screens taller than the viewport). */
|
|
7
|
+
scroll?: boolean;
|
|
8
|
+
contentContainerStyle?: ViewStyle;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Full-bleed page surface: theme background, standard padding, and safe-area
|
|
13
|
+
* insets so content clears the status bar / notch and home indicator.
|
|
14
|
+
*/
|
|
15
|
+
export function Screen({ scroll = false, style, contentContainerStyle, children, ...rest }: ScreenProps) {
|
|
16
|
+
const theme = useTheme();
|
|
17
|
+
const insets = useSafeAreaInsets();
|
|
18
|
+
|
|
19
|
+
const padding: ViewStyle = {
|
|
20
|
+
paddingTop: insets.top + theme.spacing.sm,
|
|
21
|
+
paddingBottom: insets.bottom + theme.spacing.sm,
|
|
22
|
+
paddingHorizontal: theme.spacing.lg,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (scroll) {
|
|
26
|
+
return (
|
|
27
|
+
<View style={[{ flex: 1, backgroundColor: theme.colors.background }, style]} {...rest}>
|
|
28
|
+
<ScrollView contentContainerStyle={[padding, contentContainerStyle]}>{children}</ScrollView>
|
|
29
|
+
</View>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<View style={[{ flex: 1, backgroundColor: theme.colors.background }, padding, style]} {...rest}>
|
|
35
|
+
{children}
|
|
36
|
+
</View>
|
|
37
|
+
);
|
|
38
|
+
}
|