@vvedantb/native-ui 0.3.17 → 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.
Files changed (43) hide show
  1. package/README.md +146 -13
  2. package/package.json +10 -2
  3. package/src/CaptureButton.tsx +62 -0
  4. package/src/Card.tsx +37 -13
  5. package/src/ColorSwatches.tsx +133 -0
  6. package/src/DateTimeField.tsx +75 -19
  7. package/src/HeaderIconButton.tsx +18 -1
  8. package/src/IconButton.tsx +87 -0
  9. package/src/ListRow.tsx +104 -30
  10. package/src/PageIndicator.tsx +87 -0
  11. package/src/SectionLabel.tsx +48 -0
  12. package/src/SheetRow.tsx +89 -0
  13. package/src/StackPageHeader.tsx +100 -0
  14. package/src/StatGrid.tsx +80 -0
  15. package/src/StatusDot.tsx +14 -1
  16. package/src/ThemeToggle.tsx +17 -5
  17. package/src/ai/activity-step.tsx +57 -0
  18. package/src/ai/attachments.tsx +153 -0
  19. package/src/ai/chain-of-thought.tsx +143 -0
  20. package/src/ai/chat-text.ts +35 -0
  21. package/src/ai/code-block.tsx +104 -0
  22. package/src/ai/collapsible.tsx +111 -0
  23. package/src/ai/conversation.tsx +245 -0
  24. package/src/ai/file-chip.tsx +66 -0
  25. package/src/ai/icon-button.tsx +51 -0
  26. package/src/ai/index.ts +123 -0
  27. package/src/ai/message.tsx +146 -0
  28. package/src/ai/prompt-input.tsx +186 -0
  29. package/src/ai/reasoning.tsx +200 -0
  30. package/src/ai/response-text.tsx +56 -0
  31. package/src/ai/shimmer.tsx +86 -0
  32. package/src/ai/sources.tsx +100 -0
  33. package/src/ai/suggestion.tsx +93 -0
  34. package/src/ai/trace.tsx +234 -0
  35. package/src/ai/use-streaming-disclosure.ts +50 -0
  36. package/src/entries/ai.ts +4 -0
  37. package/src/entries/date-time.ts +1 -0
  38. package/src/entries/layout.ts +6 -2
  39. package/src/entries/page-title.ts +7 -2
  40. package/src/entries/primitives.ts +19 -1
  41. package/src/examples/AiChatGallery.tsx +233 -0
  42. package/src/examples/ParityGallery.tsx +205 -3
  43. package/src/index.ts +19 -0
@@ -4,26 +4,65 @@ import DateTimePicker, {
4
4
  type DateTimePickerEvent,
5
5
  } from "@react-native-community/datetimepicker";
6
6
  import dayjs from "dayjs";
7
- import { textStyle, typeStyle } from "./controlStyles";
7
+ import { IconX } from "@tabler/icons-react-native";
8
+ import { MIN_TOUCH_TARGET, textStyle, typeStyle } from "./controlStyles";
9
+ import { palette } from "./palette";
8
10
  import { useLargerText } from "./theme";
11
+ import { tokens } from "./tokens";
9
12
  import { useScheme } from "./useScheme";
10
13
 
11
14
  type PickerMode = "date" | "time";
12
15
  type PickerButton = { mode: PickerMode; label: string };
13
16
 
17
+ export type DateTimeFieldProps = {
18
+ /** `null` shows `placeholder` until a date is picked (from 0.5.0). */
19
+ value: Date | null;
20
+ onChange: (value: Date) => void;
21
+ /** Date and time buttons (default), the time button only, or the date button only. */
22
+ mode?: "datetime" | "time" | "date";
23
+ maximumDate?: Date;
24
+ /** Shown on each button while `value` is `null`. Default "Select date" or "Select time". */
25
+ placeholder?: string;
26
+ /** Shows a clear button while `value` is set. It calls this; `onChange` is not called. */
27
+ onClear?: () => void;
28
+ /** Names the clear button. Default "Clear time" in `time` mode, "Clear date" otherwise. */
29
+ clearLabel?: string;
30
+ testID?: string;
31
+ };
32
+
33
+ const DEFAULT_PLACEHOLDER: Record<PickerMode, string> = {
34
+ date: "Select date",
35
+ time: "Select time",
36
+ };
37
+
38
+ /**
39
+ * The date the picker opens on: `value`, or with no value `now`, held back to
40
+ * `maximumDate` when that is earlier.
41
+ */
42
+ export function pickerSeed(value: Date | null, maximumDate?: Date, now: Date = new Date()): Date {
43
+ if (value) return value;
44
+ return maximumDate && maximumDate.getTime() < now.getTime() ? maximumDate : now;
45
+ }
46
+
47
+ /**
48
+ * Date and time buttons that open the system picker below them. `mode="date"`
49
+ * shows the date button only. A `null` value shows `placeholder` in the field
50
+ * placeholder colour, and the picker opens on today (or `maximumDate`, if
51
+ * earlier); picking calls `onChange`. With `onClear`, a 44 clear button
52
+ * follows the buttons while there is a value. On iOS the spinner sets the
53
+ * value when it is turned. The buttons have the web `bg-secondary` fill
54
+ * (`palette.default`) as a style, so it follows the scheme.
55
+ */
14
56
  export function DateTimeField({
15
57
  value,
16
58
  onChange,
17
59
  mode = "datetime",
18
60
  maximumDate,
61
+ placeholder,
62
+ onClear,
63
+ clearLabel,
19
64
  testID,
20
- }: {
21
- value: Date;
22
- onChange: (value: Date) => void;
23
- mode?: "datetime" | "time";
24
- maximumDate?: Date;
25
- testID?: string;
26
- }) {
65
+ }: DateTimeFieldProps) {
27
66
  const largerText = useLargerText();
28
67
  const scheme = useScheme();
29
68
  const [picker, setPicker] = useState<PickerMode | null>(null);
@@ -33,18 +72,19 @@ export function DateTimeField({
33
72
  if (event.type === "set" && next) onChange(next);
34
73
  };
35
74
 
36
- const timeButton: PickerButton = {
37
- mode: "time",
38
- label: dayjs(value).format("HH:mm"),
39
- };
75
+ const label = (button: PickerMode, format: string) =>
76
+ value ? dayjs(value).format(format) : (placeholder ?? DEFAULT_PLACEHOLDER[button]);
77
+ const dateButton: PickerButton = { mode: "date", label: label("date", "D MMM YYYY") };
78
+ const timeButton: PickerButton = { mode: "time", label: label("time", "HH:mm") };
40
79
  const buttons: PickerButton[] =
41
- mode === "datetime"
42
- ? [{ mode: "date", label: dayjs(value).format("D MMM YYYY") }, timeButton]
43
- : [timeButton];
80
+ mode === "datetime" ? [dateButton, timeButton] : mode === "date" ? [dateButton] : [timeButton];
81
+ const textColor = value
82
+ ? textStyle("foreground", scheme)
83
+ : { color: palette.fieldPlaceholder[scheme] };
44
84
 
45
85
  return (
46
86
  <View className="gap-2">
47
- <View className="flex-row gap-3">
87
+ <View className="flex-row items-center gap-3">
48
88
  {buttons.map((button) => (
49
89
  <Pressable
50
90
  key={button.mode}
@@ -56,20 +96,36 @@ export function DateTimeField({
56
96
  current === button.mode ? null : button.mode,
57
97
  )
58
98
  }
59
- className={`${largerText ? "min-h-16" : "min-h-14"} flex-1 justify-center rounded-lg bg-secondary px-4 py-2 active:opacity-70`}
99
+ className={`${largerText ? "min-h-16" : "min-h-14"} flex-1 justify-center rounded-lg px-4 py-2 active:opacity-70`}
100
+ style={{ backgroundColor: palette.default[scheme] }}
60
101
  >
61
102
  <Text
62
103
  className={`${largerText ? "text-xl" : "text-lg"} font-instrument-sans-semibold`}
63
- style={[textStyle("foreground", scheme), typeStyle("semibold")]}
104
+ style={[textColor, typeStyle("semibold")]}
64
105
  >
65
106
  {button.label}
66
107
  </Text>
67
108
  </Pressable>
68
109
  ))}
110
+ {onClear && value ? (
111
+ <Pressable
112
+ testID={testID ? `${testID}-clear` : undefined}
113
+ accessibilityRole="button"
114
+ accessibilityLabel={clearLabel ?? (mode === "time" ? "Clear time" : "Clear date")}
115
+ onPress={() => {
116
+ setPicker(null);
117
+ onClear();
118
+ }}
119
+ className="items-center justify-center rounded-full active:opacity-70"
120
+ style={{ width: MIN_TOUCH_TARGET, height: MIN_TOUCH_TARGET }}
121
+ >
122
+ <IconX size={largerText ? 24 : 20} color={tokens.color.muted[scheme]} />
123
+ </Pressable>
124
+ ) : null}
69
125
  </View>
70
126
  {picker ? (
71
127
  <DateTimePicker
72
- value={value}
128
+ value={pickerSeed(value, maximumDate)}
73
129
  mode={picker}
74
130
  is24Hour
75
131
  maximumDate={picker === "date" ? maximumDate : undefined}
@@ -1,5 +1,20 @@
1
1
  import { TouchableOpacity } from "react-native";
2
+ import { withAlpha } from "./palette";
2
3
  import { useLargerText } from "./theme";
4
+ import { tokens } from "./tokens";
5
+ import { useScheme } from "./useScheme";
6
+
7
+ /**
8
+ * Fill and border of `HeaderIconButton`: the surface with a white hairline
9
+ * (40% in light, 10% in dark). Styles, not `bg-surface`, which an app theme
10
+ * that nests `dark` under `surface` keeps white in dark mode.
11
+ */
12
+ export function headerIconButtonColors(scheme: "light" | "dark") {
13
+ return {
14
+ backgroundColor: tokens.color.surface[scheme],
15
+ borderColor: withAlpha(tokens.color.white, scheme === "dark" ? 0.1 : 0.4),
16
+ };
17
+ }
3
18
 
4
19
  export function HeaderIconButton({
5
20
  onPress,
@@ -13,10 +28,12 @@ export function HeaderIconButton({
13
28
  children: React.ReactNode;
14
29
  }) {
15
30
  const largerText = useLargerText();
31
+ const scheme = useScheme();
16
32
  return (
17
33
  <TouchableOpacity
18
34
  testID={testID}
19
- className={`${largerText ? "h-14 w-14" : "h-12 w-12"} items-center justify-center rounded-full border border-white/40 bg-surface dark:border-white/10`}
35
+ className={`${largerText ? "h-14 w-14" : "h-12 w-12"} items-center justify-center rounded-full border`}
36
+ style={headerIconButtonColors(scheme)}
20
37
  onPress={onPress}
21
38
  accessibilityRole="button"
22
39
  accessibilityLabel={label}
@@ -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
+ }