@vvedantb/native-ui 0.4.0 → 0.5.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/README.md +71 -12
- package/package.json +1 -1
- package/src/CaptureButton.tsx +62 -0
- package/src/Card.tsx +37 -13
- package/src/ColorSwatches.tsx +133 -0
- package/src/DateTimeField.tsx +75 -19
- package/src/HeaderIconButton.tsx +18 -1
- package/src/IconButton.tsx +87 -0
- package/src/ListRow.tsx +104 -30
- package/src/PageIndicator.tsx +87 -0
- package/src/SectionLabel.tsx +48 -0
- package/src/SheetRow.tsx +89 -0
- package/src/StackPageHeader.tsx +100 -0
- package/src/StatGrid.tsx +80 -0
- package/src/StatusDot.tsx +14 -1
- package/src/ThemeToggle.tsx +17 -5
- package/src/brand/AppIcons.tsx +2 -0
- package/src/brand/AppWordmark.tsx +2 -2
- package/src/brand/familyApps.ts +5 -2
- package/src/brand/index.ts +2 -2
- package/src/brand/svg.ts +29 -5
- package/src/brand/vProAppBenefits.ts +1 -1
- package/src/entries/date-time.ts +1 -0
- package/src/entries/layout.ts +6 -2
- package/src/entries/page-title.ts +7 -2
- package/src/entries/primitives.ts +19 -1
- package/src/examples/ParityGallery.tsx +170 -3
- package/src/index.ts +19 -0
- package/src/marketing/PricingModal.tsx +3 -3
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import { Pressable } from "react-native";
|
|
3
|
+
import type { IconProps } from "@tabler/icons-react-native";
|
|
4
|
+
import { cn } from "./cn";
|
|
5
|
+
import { touchSlop } from "./controlStyles";
|
|
6
|
+
import { withAlpha } from "./palette";
|
|
7
|
+
import { useLargerText } from "./theme";
|
|
8
|
+
import { tokens } from "./tokens";
|
|
9
|
+
import { useScheme } from "./useScheme";
|
|
10
|
+
|
|
11
|
+
export type IconButtonVariant = "default" | "overlay";
|
|
12
|
+
export type IconButtonSize = "sm" | "md" | "lg";
|
|
13
|
+
|
|
14
|
+
export type IconButtonProps = {
|
|
15
|
+
icon: ComponentType<IconProps>;
|
|
16
|
+
/** Names the button for VoiceOver and TalkBack; it has no text. */
|
|
17
|
+
label: string;
|
|
18
|
+
onPress?: () => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** `overlay` is for camera chrome over a live preview. Default `default`. */
|
|
21
|
+
variant?: IconButtonVariant;
|
|
22
|
+
/** `sm` 32, `md` 40, `lg` 48 across. Default `md`. */
|
|
23
|
+
size?: IconButtonSize;
|
|
24
|
+
testID?: string;
|
|
25
|
+
/** Default: the foreground in `default`, white in `overlay`. */
|
|
26
|
+
iconColor?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Diameter and icon size per size, a step up with larger text. */
|
|
30
|
+
const METRICS = {
|
|
31
|
+
regular: { sm: [32, 16], md: [40, 20], lg: [48, 24] },
|
|
32
|
+
larger: { sm: [44, 20], md: [48, 24], lg: [56, 28] },
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
/** Fill, border and icon colour per variant. */
|
|
36
|
+
export function iconButtonColors(variant: IconButtonVariant, scheme: "light" | "dark") {
|
|
37
|
+
return variant === "overlay"
|
|
38
|
+
? {
|
|
39
|
+
backgroundColor: withAlpha(tokens.color.black, 0.35),
|
|
40
|
+
borderColor: withAlpha(tokens.color.white, 0.2),
|
|
41
|
+
color: tokens.color.white,
|
|
42
|
+
}
|
|
43
|
+
: {
|
|
44
|
+
backgroundColor: tokens.color.surface[scheme],
|
|
45
|
+
borderColor: tokens.color.border[scheme],
|
|
46
|
+
color: tokens.color.foreground[scheme],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A round icon-only button. `default` is a bordered surface, like
|
|
52
|
+
* `HeaderIconButton` in `tokens` colours. `overlay` is a translucent black
|
|
53
|
+
* circle with a white icon, the same in light and dark, for camera chrome over
|
|
54
|
+
* a live preview (close, flash, flip). Sizes are the web icon buttons (`sm`
|
|
55
|
+
* 32, `md` 40) and 48, each reaching 44 with its hit slop. Not the AI chat
|
|
56
|
+
* `ChatIconButton`.
|
|
57
|
+
*/
|
|
58
|
+
export function IconButton({
|
|
59
|
+
icon: Icon,
|
|
60
|
+
label,
|
|
61
|
+
onPress,
|
|
62
|
+
disabled = false,
|
|
63
|
+
variant = "default",
|
|
64
|
+
size = "md",
|
|
65
|
+
testID,
|
|
66
|
+
iconColor,
|
|
67
|
+
}: IconButtonProps) {
|
|
68
|
+
const scheme = useScheme();
|
|
69
|
+
const largerText = useLargerText();
|
|
70
|
+
const [diameter, iconSize] = METRICS[largerText ? "larger" : "regular"][size];
|
|
71
|
+
const { color, ...frame } = iconButtonColors(variant, scheme);
|
|
72
|
+
return (
|
|
73
|
+
<Pressable
|
|
74
|
+
testID={testID}
|
|
75
|
+
accessibilityRole="button"
|
|
76
|
+
accessibilityLabel={label}
|
|
77
|
+
accessibilityState={{ disabled }}
|
|
78
|
+
hitSlop={touchSlop(diameter)}
|
|
79
|
+
disabled={disabled}
|
|
80
|
+
onPress={onPress}
|
|
81
|
+
className={cn("items-center justify-center active:opacity-70", disabled && "opacity-50")}
|
|
82
|
+
style={{ width: diameter, height: diameter, borderRadius: diameter / 2, borderWidth: 1, ...frame }}
|
|
83
|
+
>
|
|
84
|
+
<Icon size={iconSize} color={iconColor ?? color} />
|
|
85
|
+
</Pressable>
|
|
86
|
+
);
|
|
87
|
+
}
|
package/src/ListRow.tsx
CHANGED
|
@@ -6,45 +6,88 @@ import { cn } from "./cn";
|
|
|
6
6
|
import { textStyle, typeStyle } from "./controlStyles";
|
|
7
7
|
import { FLOATING_ROW_RADIUS } from "./floating";
|
|
8
8
|
import { useLargerText } from "./theme";
|
|
9
|
+
import { tokens } from "./tokens";
|
|
9
10
|
import { useScheme } from "./useScheme";
|
|
10
11
|
|
|
12
|
+
export type ListRowProps = {
|
|
13
|
+
title: string;
|
|
14
|
+
meta?: string;
|
|
15
|
+
detail?: ReactNode;
|
|
16
|
+
icon?: ComponentType<IconProps>;
|
|
17
|
+
iconColor?: string;
|
|
18
|
+
/** Any node at the start, such as an `Avatar` with an unread dot. Shown in place of `icon`. */
|
|
19
|
+
leading?: ReactNode;
|
|
20
|
+
/** At the end of the title line, such as a `Badge` or a time. The title is cut off first. */
|
|
21
|
+
titleEnd?: ReactNode;
|
|
22
|
+
/** At the end of the row, inside the pressable area. */
|
|
23
|
+
right?: ReactNode;
|
|
24
|
+
/**
|
|
25
|
+
* At the end of the row, outside the pressable area, such as a `Switch`:
|
|
26
|
+
* pressing it does not call `onPress`.
|
|
27
|
+
*/
|
|
28
|
+
control?: ReactNode;
|
|
29
|
+
onPress?: () => void;
|
|
30
|
+
onLongPress?: () => void;
|
|
31
|
+
/** Names the pressable row. Default `title`. */
|
|
32
|
+
accessibilityLabel?: string;
|
|
33
|
+
onCanvas?: boolean;
|
|
34
|
+
/** On the pressable area when there is a `control`, so a test can press it. */
|
|
35
|
+
testID?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A list row: an icon or `leading` node, a title with optional `titleEnd`,
|
|
40
|
+
* `meta` and `detail` lines, and `right` content. With `onPress` or
|
|
41
|
+
* `onLongPress` the row is a button named by `title`. A `control` sits beside
|
|
42
|
+
* the pressable area, not in it.
|
|
43
|
+
*/
|
|
11
44
|
export function ListRow({
|
|
12
45
|
title,
|
|
13
46
|
meta,
|
|
14
47
|
detail,
|
|
15
48
|
icon: Icon,
|
|
16
49
|
iconColor = NEUTRAL_500,
|
|
50
|
+
leading,
|
|
51
|
+
titleEnd,
|
|
17
52
|
right,
|
|
53
|
+
control,
|
|
18
54
|
onPress,
|
|
55
|
+
onLongPress,
|
|
56
|
+
accessibilityLabel,
|
|
19
57
|
onCanvas = false,
|
|
20
58
|
testID,
|
|
21
|
-
}: {
|
|
22
|
-
title: string;
|
|
23
|
-
meta?: string;
|
|
24
|
-
detail?: ReactNode;
|
|
25
|
-
icon?: ComponentType<IconProps>;
|
|
26
|
-
iconColor?: string;
|
|
27
|
-
right?: ReactNode;
|
|
28
|
-
onPress?: () => void;
|
|
29
|
-
onCanvas?: boolean;
|
|
30
|
-
testID?: string;
|
|
31
|
-
}) {
|
|
59
|
+
}: ListRowProps) {
|
|
32
60
|
const largerText = useLargerText();
|
|
33
61
|
const scheme = useScheme();
|
|
62
|
+
const titleText = (
|
|
63
|
+
<Text
|
|
64
|
+
numberOfLines={2}
|
|
65
|
+
className={cn(
|
|
66
|
+
"font-instrument-sans-semibold",
|
|
67
|
+
largerText ? "text-xl" : "text-lg",
|
|
68
|
+
titleEnd != null && "min-w-0 flex-1",
|
|
69
|
+
)}
|
|
70
|
+
style={[textStyle("foreground", scheme), typeStyle("semibold")]}
|
|
71
|
+
>
|
|
72
|
+
{title}
|
|
73
|
+
</Text>
|
|
74
|
+
);
|
|
34
75
|
const content = (
|
|
35
76
|
<>
|
|
36
|
-
{
|
|
77
|
+
{leading != null ? (
|
|
78
|
+
<View className="shrink-0">{leading}</View>
|
|
79
|
+
) : Icon ? (
|
|
80
|
+
<Icon size={largerText ? 30 : 26} color={iconColor} />
|
|
81
|
+
) : null}
|
|
37
82
|
<View className="min-w-0 flex-1">
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{title}
|
|
47
|
-
</Text>
|
|
83
|
+
{titleEnd != null ? (
|
|
84
|
+
<View className="flex-row items-center gap-2">
|
|
85
|
+
{titleText}
|
|
86
|
+
<View className="shrink-0">{titleEnd}</View>
|
|
87
|
+
</View>
|
|
88
|
+
) : (
|
|
89
|
+
titleText
|
|
90
|
+
)}
|
|
48
91
|
{meta ? (
|
|
49
92
|
<Text
|
|
50
93
|
className={cn(
|
|
@@ -61,14 +104,44 @@ export function ListRow({
|
|
|
61
104
|
{right}
|
|
62
105
|
</>
|
|
63
106
|
);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const rowStyle = {
|
|
107
|
+
const pressable = Boolean(onPress || onLongPress);
|
|
108
|
+
const height = largerText ? "min-h-[72px]" : "min-h-16";
|
|
109
|
+
const paddingY = largerText ? "py-4" : "py-3";
|
|
110
|
+
// The fill is a style: an app theme that nests `dark` under `surface` keeps
|
|
111
|
+
// `bg-surface` and `bg-surface-secondary` light in dark mode.
|
|
112
|
+
const rowStyle = {
|
|
113
|
+
backgroundColor: tokens.color[onCanvas ? "surface" : "surfaceSecondary"][scheme],
|
|
114
|
+
borderRadius: FLOATING_ROW_RADIUS,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (control != null) {
|
|
118
|
+
// The control is a sibling of the pressable area, so its presses stay its own.
|
|
119
|
+
const bodyClassName = cn("min-w-0 flex-1 flex-row items-center gap-3 self-stretch pl-4 pr-3", paddingY);
|
|
120
|
+
return (
|
|
121
|
+
<View className={cn("flex-row items-center", height)} style={rowStyle}>
|
|
122
|
+
{pressable ? (
|
|
123
|
+
<Pressable
|
|
124
|
+
testID={testID}
|
|
125
|
+
accessibilityRole="button"
|
|
126
|
+
accessibilityLabel={accessibilityLabel ?? title}
|
|
127
|
+
onPress={onPress}
|
|
128
|
+
onLongPress={onLongPress}
|
|
129
|
+
className={cn(bodyClassName, "active:opacity-70")}
|
|
130
|
+
>
|
|
131
|
+
{content}
|
|
132
|
+
</Pressable>
|
|
133
|
+
) : (
|
|
134
|
+
<View testID={testID} className={bodyClassName}>
|
|
135
|
+
{content}
|
|
136
|
+
</View>
|
|
137
|
+
)}
|
|
138
|
+
<View className="shrink-0 pr-4">{control}</View>
|
|
139
|
+
</View>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
70
142
|
|
|
71
|
-
|
|
143
|
+
const className = cn("flex-row items-center gap-3 px-4", height, paddingY);
|
|
144
|
+
if (!pressable) {
|
|
72
145
|
return (
|
|
73
146
|
<View testID={testID} className={className} style={rowStyle}>
|
|
74
147
|
{content}
|
|
@@ -79,8 +152,9 @@ export function ListRow({
|
|
|
79
152
|
<Pressable
|
|
80
153
|
testID={testID}
|
|
81
154
|
accessibilityRole="button"
|
|
82
|
-
accessibilityLabel={title}
|
|
155
|
+
accessibilityLabel={accessibilityLabel ?? title}
|
|
83
156
|
onPress={onPress}
|
|
157
|
+
onLongPress={onLongPress}
|
|
84
158
|
className={cn(className, "active:opacity-70")}
|
|
85
159
|
style={rowStyle}
|
|
86
160
|
>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Pressable, View } from "react-native";
|
|
2
|
+
import { MIN_TOUCH_TARGET } from "./controlStyles";
|
|
3
|
+
import { palette, withAlpha } from "./palette";
|
|
4
|
+
import { useLargerText } from "./theme";
|
|
5
|
+
import { tokens } from "./tokens";
|
|
6
|
+
import { useScheme } from "./useScheme";
|
|
7
|
+
|
|
8
|
+
export type PageIndicatorProps = {
|
|
9
|
+
count: number;
|
|
10
|
+
/** The current page, from 0. */
|
|
11
|
+
index: number;
|
|
12
|
+
/** Makes each dot a button that calls this with its page. */
|
|
13
|
+
onChange?: (index: number) => void;
|
|
14
|
+
testID?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Dot sizes: the web `CarouselDots` (`h-1.5`, active `w-4`, `gap-1.5`), a step up with larger text. */
|
|
18
|
+
export function pageDotSize(largerText: boolean) {
|
|
19
|
+
return largerText
|
|
20
|
+
? { height: 8, active: 20, gap: 8 }
|
|
21
|
+
: { height: 6, active: 16, gap: 6 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Page dots, as the web `CarouselDots`: the current page is a wider dot in
|
|
26
|
+
* `palette.primary`, the others muted at 30%. Nothing for a single page.
|
|
27
|
+
* Without `onChange` it is one element that reads "Page 2 of 5". With it each
|
|
28
|
+
* dot is a 44 button named "Page 2 of 5", `selected` on the current page; the
|
|
29
|
+
* dots then sit 44 apart, so each has a whole target.
|
|
30
|
+
*/
|
|
31
|
+
export function PageIndicator({ count, index, onChange, testID }: PageIndicatorProps) {
|
|
32
|
+
const scheme = useScheme();
|
|
33
|
+
const largerText = useLargerText();
|
|
34
|
+
if (count <= 1) return null;
|
|
35
|
+
const current = Math.min(Math.max(index, 0), count - 1);
|
|
36
|
+
const size = pageDotSize(largerText);
|
|
37
|
+
const pageLabel = (page: number) => `Page ${page + 1} of ${count}`;
|
|
38
|
+
const dot = (page: number) => {
|
|
39
|
+
const selected = page === current;
|
|
40
|
+
return (
|
|
41
|
+
<View
|
|
42
|
+
key={page}
|
|
43
|
+
style={{
|
|
44
|
+
height: size.height,
|
|
45
|
+
width: selected ? size.active : size.height,
|
|
46
|
+
borderRadius: size.height / 2,
|
|
47
|
+
backgroundColor: selected
|
|
48
|
+
? palette.primary[scheme]
|
|
49
|
+
: withAlpha(tokens.color.muted[scheme], 0.3),
|
|
50
|
+
}}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
const pages = Array.from({ length: count }, (_, page) => page);
|
|
55
|
+
|
|
56
|
+
if (!onChange) {
|
|
57
|
+
return (
|
|
58
|
+
<View
|
|
59
|
+
testID={testID}
|
|
60
|
+
accessible
|
|
61
|
+
accessibilityLabel={pageLabel(current)}
|
|
62
|
+
className="flex-row items-center justify-center"
|
|
63
|
+
style={{ gap: size.gap }}
|
|
64
|
+
>
|
|
65
|
+
{pages.map((page) => dot(page))}
|
|
66
|
+
</View>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return (
|
|
70
|
+
<View testID={testID} className="flex-row items-center justify-center">
|
|
71
|
+
{pages.map((page) => (
|
|
72
|
+
<Pressable
|
|
73
|
+
key={page}
|
|
74
|
+
testID={testID ? `${testID}-${page}` : undefined}
|
|
75
|
+
accessibilityRole="button"
|
|
76
|
+
accessibilityLabel={pageLabel(page)}
|
|
77
|
+
accessibilityState={{ selected: page === current }}
|
|
78
|
+
onPress={() => onChange(page)}
|
|
79
|
+
className="items-center justify-center active:opacity-70"
|
|
80
|
+
style={{ minWidth: MIN_TOUCH_TARGET, height: MIN_TOUCH_TARGET }}
|
|
81
|
+
>
|
|
82
|
+
{dot(page)}
|
|
83
|
+
</Pressable>
|
|
84
|
+
))}
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Text, type TextProps } from "react-native";
|
|
3
|
+
import { cn } from "./cn";
|
|
4
|
+
import { sizeStyle, textStyle, typeStyle } from "./controlStyles";
|
|
5
|
+
import { useLargerText } from "./theme";
|
|
6
|
+
import { useScheme } from "./useScheme";
|
|
7
|
+
|
|
8
|
+
export type SectionLabelProps = TextProps & {
|
|
9
|
+
className?: string;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
/** Capitals with the web menu label tracking (`uppercase tracking-widest`). Default `false`. */
|
|
12
|
+
uppercase?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Letter spacing in em: slightly open, or the web `tracking-widest` (0.1em) in capitals. */
|
|
16
|
+
const TRACKING = { regular: 0.02, uppercase: 0.1 } as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The quiet label above a group of rows ("Today", "Library"), in the spirit of
|
|
20
|
+
* the web `sidebarSectionLabelClass`: `text-xs` medium (12 / 500), muted, with
|
|
21
|
+
* slightly open letter spacing. The web label is muted at 55% (2.1:1 on the
|
|
22
|
+
* light page); this one is the full muted colour (4.4:1). A header for
|
|
23
|
+
* VoiceOver and TalkBack. No side padding: pass `className="px-4"` to line it
|
|
24
|
+
* up with `ListRow` text. `SettingsSection` titles are the larger heading.
|
|
25
|
+
*/
|
|
26
|
+
export function SectionLabel({ className, uppercase = false, style, ...props }: SectionLabelProps) {
|
|
27
|
+
const scheme = useScheme();
|
|
28
|
+
const largerText = useLargerText();
|
|
29
|
+
const size = largerText ? "text-base" : "text-xs";
|
|
30
|
+
const { fontSize } = sizeStyle(size);
|
|
31
|
+
return (
|
|
32
|
+
<Text
|
|
33
|
+
accessibilityRole="header"
|
|
34
|
+
className={cn("font-instrument-sans-medium", size, className)}
|
|
35
|
+
style={[
|
|
36
|
+
textStyle("muted", scheme),
|
|
37
|
+
sizeStyle(size),
|
|
38
|
+
typeStyle("medium"),
|
|
39
|
+
{
|
|
40
|
+
letterSpacing: fontSize * TRACKING[uppercase ? "uppercase" : "regular"],
|
|
41
|
+
textTransform: uppercase ? "uppercase" : "none",
|
|
42
|
+
},
|
|
43
|
+
style,
|
|
44
|
+
]}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
package/src/SheetRow.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import { IconCheck, type IconProps } from "@tabler/icons-react-native";
|
|
4
|
+
import { cn } from "./cn";
|
|
5
|
+
import { sizeStyle, textStyle, typeStyle } from "./controlStyles";
|
|
6
|
+
import { palette } from "./palette";
|
|
7
|
+
import { useLargerText } from "./theme";
|
|
8
|
+
import { tokens } from "./tokens";
|
|
9
|
+
import { useScheme } from "./useScheme";
|
|
10
|
+
|
|
11
|
+
export type SheetRowProps = {
|
|
12
|
+
/** A Tabler icon at the start. */
|
|
13
|
+
icon?: ComponentType<IconProps>;
|
|
14
|
+
title: string;
|
|
15
|
+
/** Muted line under the title, also the accessibility hint. */
|
|
16
|
+
detail?: string;
|
|
17
|
+
/** The current choice: a check at the end, and `selected` for assistive tech. */
|
|
18
|
+
active?: boolean;
|
|
19
|
+
/** A destructive action: title and icon in `palette.danger`. */
|
|
20
|
+
danger?: boolean;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
onPress?: () => void;
|
|
23
|
+
testID?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One action in a `Sheet` or bottom sheet, a touch size of the web menu item:
|
|
28
|
+
* icon, title and optional detail, at least 56 high (64 with larger text) with
|
|
29
|
+
* the `ListRow` padding (`px-4 py-3`). While pressed it takes the web menu
|
|
30
|
+
* item highlight (`bg-default`). `danger` draws the title and icon in the
|
|
31
|
+
* danger colour; `active` adds a check; `disabled` dims it to 50%, as the web
|
|
32
|
+
* item, and ignores presses.
|
|
33
|
+
*/
|
|
34
|
+
export function SheetRow({
|
|
35
|
+
icon: Icon,
|
|
36
|
+
title,
|
|
37
|
+
detail,
|
|
38
|
+
active = false,
|
|
39
|
+
danger = false,
|
|
40
|
+
disabled = false,
|
|
41
|
+
onPress,
|
|
42
|
+
testID,
|
|
43
|
+
}: SheetRowProps) {
|
|
44
|
+
const scheme = useScheme();
|
|
45
|
+
const largerText = useLargerText();
|
|
46
|
+
const color = danger ? palette.danger[scheme] : tokens.color.foreground[scheme];
|
|
47
|
+
const titleSize = largerText ? "text-xl" : "text-base";
|
|
48
|
+
const detailSize = largerText ? "text-lg" : "text-sm";
|
|
49
|
+
const iconSize = largerText ? 26 : 22;
|
|
50
|
+
return (
|
|
51
|
+
<Pressable
|
|
52
|
+
testID={testID}
|
|
53
|
+
accessibilityRole="button"
|
|
54
|
+
accessibilityLabel={title}
|
|
55
|
+
accessibilityHint={detail}
|
|
56
|
+
accessibilityState={{ disabled, selected: active }}
|
|
57
|
+
disabled={disabled}
|
|
58
|
+
onPress={onPress}
|
|
59
|
+
className={cn(
|
|
60
|
+
"flex-row items-center gap-3 px-4 py-3",
|
|
61
|
+
largerText ? "min-h-16" : "min-h-14",
|
|
62
|
+
disabled && "opacity-50",
|
|
63
|
+
)}
|
|
64
|
+
style={({ pressed }) => ({
|
|
65
|
+
borderRadius: tokens.radius.sm,
|
|
66
|
+
backgroundColor: pressed && !disabled ? palette.default[scheme] : "transparent",
|
|
67
|
+
})}
|
|
68
|
+
>
|
|
69
|
+
{Icon ? <Icon size={iconSize} color={color} /> : null}
|
|
70
|
+
<View className="min-w-0 flex-1">
|
|
71
|
+
<Text
|
|
72
|
+
className={cn("font-instrument-sans-medium", titleSize)}
|
|
73
|
+
style={[{ color }, sizeStyle(titleSize), typeStyle("medium")]}
|
|
74
|
+
>
|
|
75
|
+
{title}
|
|
76
|
+
</Text>
|
|
77
|
+
{detail ? (
|
|
78
|
+
<Text
|
|
79
|
+
className={cn("font-instrument-sans", detailSize)}
|
|
80
|
+
style={[textStyle("muted", scheme), sizeStyle(detailSize)]}
|
|
81
|
+
>
|
|
82
|
+
{detail}
|
|
83
|
+
</Text>
|
|
84
|
+
) : null}
|
|
85
|
+
</View>
|
|
86
|
+
{active ? <IconCheck size={iconSize} color={color} strokeWidth={2} /> : null}
|
|
87
|
+
</Pressable>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
import { IconChevronLeft } from "@tabler/icons-react-native";
|
|
4
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
5
|
+
import { cn } from "./cn";
|
|
6
|
+
import { PAGE_HEADER_HEIGHT, sizeStyle, textStyle } from "./controlStyles";
|
|
7
|
+
import { HeaderIconButton } from "./HeaderIconButton";
|
|
8
|
+
import { PageTitle } from "./PageTitle";
|
|
9
|
+
import { useLargerText } from "./theme";
|
|
10
|
+
import { tokens } from "./tokens";
|
|
11
|
+
import { useScheme } from "./useScheme";
|
|
12
|
+
|
|
13
|
+
export type StackPageHeaderProps = {
|
|
14
|
+
title: string;
|
|
15
|
+
/** Muted line under the title, up to two lines. */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Shows a back `HeaderIconButton` at the start that calls this. No button without it. */
|
|
18
|
+
onBack?: () => void;
|
|
19
|
+
/** Names the back button. Default "Back". */
|
|
20
|
+
backLabel?: string;
|
|
21
|
+
/** Actions at the end, such as a `HeaderIconButton`. */
|
|
22
|
+
headerRight?: ReactNode;
|
|
23
|
+
backTestID?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Pad the top by the safe-area inset. Default `true`. Pass `false` under a
|
|
26
|
+
* native header or inside a view that already pads the inset.
|
|
27
|
+
*/
|
|
28
|
+
safeTop?: boolean;
|
|
29
|
+
testID?: string;
|
|
30
|
+
className?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A stack screen header with no fill, for a screen over `AmbientBackground`:
|
|
35
|
+
* a back chevron `HeaderIconButton` at the start, `headerRight` at the end and
|
|
36
|
+
* a centred `PageTitle` (Instrument Sans 600 at 20 / 25) with an optional
|
|
37
|
+
* muted description. The two sides are the same width, the wider of the back
|
|
38
|
+
* button and `headerRight`, so the title stays centred on the screen and a
|
|
39
|
+
* long one is cut off before it reaches them. The row is at least 56 high
|
|
40
|
+
* below the top inset, as `PageHeader`, with the `px-4` screen padding.
|
|
41
|
+
*/
|
|
42
|
+
export function StackPageHeader({
|
|
43
|
+
title,
|
|
44
|
+
description,
|
|
45
|
+
onBack,
|
|
46
|
+
backLabel = "Back",
|
|
47
|
+
headerRight,
|
|
48
|
+
backTestID,
|
|
49
|
+
safeTop = true,
|
|
50
|
+
testID,
|
|
51
|
+
className,
|
|
52
|
+
}: StackPageHeaderProps) {
|
|
53
|
+
const insets = useSafeAreaInsets();
|
|
54
|
+
const scheme = useScheme();
|
|
55
|
+
const largerText = useLargerText();
|
|
56
|
+
const [rightWidth, setRightWidth] = useState(0);
|
|
57
|
+
const top = safeTop ? insets.top : 0;
|
|
58
|
+
// `HeaderIconButton` is 48 across (56 with larger text).
|
|
59
|
+
const backWidth = onBack ? (largerText ? 56 : 48) : 0;
|
|
60
|
+
const side = { minWidth: Math.max(backWidth, headerRight != null ? rightWidth : 0) };
|
|
61
|
+
const descriptionSize = largerText ? "text-base" : "text-sm";
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<View
|
|
65
|
+
testID={testID}
|
|
66
|
+
className={cn("flex-row items-center gap-2 px-4", className)}
|
|
67
|
+
style={{ paddingTop: top, minHeight: top + PAGE_HEADER_HEIGHT }}
|
|
68
|
+
>
|
|
69
|
+
<View className="flex-1 flex-row items-center justify-start" style={side}>
|
|
70
|
+
{onBack ? (
|
|
71
|
+
<HeaderIconButton label={backLabel} testID={backTestID} onPress={onBack}>
|
|
72
|
+
<IconChevronLeft size={largerText ? 26 : 22} color={tokens.color.foreground[scheme]} />
|
|
73
|
+
</HeaderIconButton>
|
|
74
|
+
) : null}
|
|
75
|
+
</View>
|
|
76
|
+
<View className="min-w-0 shrink items-center py-2">
|
|
77
|
+
<PageTitle className="text-center">{title}</PageTitle>
|
|
78
|
+
{description ? (
|
|
79
|
+
<Text
|
|
80
|
+
numberOfLines={2}
|
|
81
|
+
className={cn("text-center font-instrument-sans", descriptionSize)}
|
|
82
|
+
style={[textStyle("muted", scheme), sizeStyle(descriptionSize)]}
|
|
83
|
+
>
|
|
84
|
+
{description}
|
|
85
|
+
</Text>
|
|
86
|
+
) : null}
|
|
87
|
+
</View>
|
|
88
|
+
<View className="flex-1 flex-row items-center justify-end" style={side}>
|
|
89
|
+
{headerRight != null ? (
|
|
90
|
+
<View
|
|
91
|
+
className="flex-row items-center gap-2"
|
|
92
|
+
onLayout={(event) => setRightWidth(event.nativeEvent.layout.width)}
|
|
93
|
+
>
|
|
94
|
+
{headerRight}
|
|
95
|
+
</View>
|
|
96
|
+
) : null}
|
|
97
|
+
</View>
|
|
98
|
+
</View>
|
|
99
|
+
);
|
|
100
|
+
}
|
package/src/StatGrid.tsx
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Text, View } from "react-native";
|
|
2
|
+
import { cn } from "./cn";
|
|
3
|
+
import { cardStyle, sizeStyle, textStyle, typeStyle } from "./controlStyles";
|
|
4
|
+
import { useLargerText } from "./theme";
|
|
5
|
+
import { useScheme } from "./useScheme";
|
|
6
|
+
|
|
7
|
+
export type StatGridItem = { label: string; value: string; testID?: string };
|
|
8
|
+
|
|
9
|
+
export type StatGridProps = {
|
|
10
|
+
items: StatGridItem[];
|
|
11
|
+
/** Tiles per row. Default 2. */
|
|
12
|
+
columns?: 2 | 3;
|
|
13
|
+
testID?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** `items` in rows of `columns`. The last row is padded with `null`, so every tile has the same width. */
|
|
17
|
+
export function statGridRows<T>(items: T[], columns: number): (T | null)[][] {
|
|
18
|
+
const rows: (T | null)[][] = [];
|
|
19
|
+
for (let start = 0; start < items.length; start += columns) {
|
|
20
|
+
const row: (T | null)[] = items.slice(start, start + columns);
|
|
21
|
+
while (row.length < columns) row.push(null);
|
|
22
|
+
rows.push(row);
|
|
23
|
+
}
|
|
24
|
+
return rows;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Label and value tiles in rows of 2 or 3, each the flat web card
|
|
29
|
+
* (`cardStyle`: `surfaceCard` fill, radius 16, no shadow) with the web
|
|
30
|
+
* `Surface` padding (`p-4`). The label is muted `text-sm`; the value is
|
|
31
|
+
* semibold `text-xl` in the foreground, with tabular figures. VoiceOver and
|
|
32
|
+
* TalkBack read each tile as one element: "Steps, 8,432".
|
|
33
|
+
*/
|
|
34
|
+
export function StatGrid({ items, columns = 2, testID }: StatGridProps) {
|
|
35
|
+
const scheme = useScheme();
|
|
36
|
+
const largerText = useLargerText();
|
|
37
|
+
if (items.length === 0) return null;
|
|
38
|
+
const labelSize = largerText ? "text-base" : "text-sm";
|
|
39
|
+
const valueSize = largerText ? "text-2xl" : "text-xl";
|
|
40
|
+
return (
|
|
41
|
+
<View testID={testID} className="gap-3">
|
|
42
|
+
{statGridRows(items, columns).map((row, rowIndex) => (
|
|
43
|
+
<View key={rowIndex} className="flex-row gap-3">
|
|
44
|
+
{row.map((item, index) =>
|
|
45
|
+
item ? (
|
|
46
|
+
<View
|
|
47
|
+
key={index}
|
|
48
|
+
testID={item.testID}
|
|
49
|
+
accessible
|
|
50
|
+
accessibilityLabel={`${item.label}, ${item.value}`}
|
|
51
|
+
className="min-w-0 flex-1 gap-1 p-4"
|
|
52
|
+
style={cardStyle(scheme)}
|
|
53
|
+
>
|
|
54
|
+
<Text
|
|
55
|
+
className={cn("font-instrument-sans", labelSize)}
|
|
56
|
+
style={[textStyle("muted", scheme), sizeStyle(labelSize)]}
|
|
57
|
+
>
|
|
58
|
+
{item.label}
|
|
59
|
+
</Text>
|
|
60
|
+
<Text
|
|
61
|
+
className={cn("font-instrument-sans-semibold", valueSize)}
|
|
62
|
+
style={[
|
|
63
|
+
textStyle("foreground", scheme),
|
|
64
|
+
sizeStyle(valueSize, "tight"),
|
|
65
|
+
typeStyle("semibold"),
|
|
66
|
+
{ fontVariant: ["tabular-nums"] },
|
|
67
|
+
]}
|
|
68
|
+
>
|
|
69
|
+
{item.value}
|
|
70
|
+
</Text>
|
|
71
|
+
</View>
|
|
72
|
+
) : (
|
|
73
|
+
<View key={index} className="flex-1" />
|
|
74
|
+
),
|
|
75
|
+
)}
|
|
76
|
+
</View>
|
|
77
|
+
))}
|
|
78
|
+
</View>
|
|
79
|
+
);
|
|
80
|
+
}
|