@vvedantb/native-ui 0.4.0 → 0.5.0

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.
@@ -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
- {Icon ? <Icon size={largerText ? 30 : 26} color={iconColor} /> : null}
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
- <Text
39
- numberOfLines={2}
40
- className={cn(
41
- "font-instrument-sans-semibold",
42
- largerText ? "text-xl" : "text-lg",
43
- )}
44
- style={[textStyle("foreground", scheme), typeStyle("semibold")]}
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 className = cn(
65
- "flex-row items-center gap-3 px-4",
66
- largerText ? "min-h-[72px] py-4" : "min-h-16 py-3",
67
- onCanvas ? "bg-surface" : "bg-surface-secondary",
68
- );
69
- const rowStyle = { borderRadius: FLOATING_ROW_RADIUS };
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
- if (!onPress) {
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
+ }
@@ -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
+ }
@@ -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
+ }