@vvedantb/native-ui 0.2.1 → 0.3.12

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 (86) hide show
  1. package/README.md +160 -9
  2. package/package.json +80 -2
  3. package/src/AlertDialog.tsx +73 -0
  4. package/src/AmbientBackground.tsx +26 -11
  5. package/src/Avatar.tsx +141 -0
  6. package/src/Badge.tsx +108 -0
  7. package/src/BottomSheetContainer.tsx +7 -1
  8. package/src/Button.tsx +153 -52
  9. package/src/Card.tsx +111 -36
  10. package/src/Checkbox.tsx +78 -0
  11. package/src/Chip.tsx +5 -3
  12. package/src/ConfirmDialog.tsx +105 -0
  13. package/src/DateTimeField.tsx +6 -2
  14. package/src/Dialog.tsx +253 -0
  15. package/src/EmptyState.tsx +122 -7
  16. package/src/FloatingFAB.tsx +2 -4
  17. package/src/FloatingSurface.tsx +31 -9
  18. package/src/FloatingTabBar.tsx +39 -11
  19. package/src/Input.tsx +15 -4
  20. package/src/Label.tsx +31 -0
  21. package/src/LabeledSwitchRow.tsx +96 -0
  22. package/src/LiquidGlass.tsx +2 -10
  23. package/src/ListRow.tsx +7 -2
  24. package/src/PillTabs.tsx +28 -14
  25. package/src/Progress.tsx +67 -0
  26. package/src/SearchInput.tsx +163 -0
  27. package/src/Select.tsx +32 -6
  28. package/src/Separator.tsx +38 -0
  29. package/src/Sheet.tsx +107 -0
  30. package/src/Skeleton.tsx +25 -5
  31. package/src/Spinner.tsx +92 -0
  32. package/src/StatusDot.tsx +66 -0
  33. package/src/Switch.tsx +24 -7
  34. package/src/Tabs.tsx +190 -0
  35. package/src/Textarea.tsx +47 -0
  36. package/src/Toast.tsx +170 -0
  37. package/src/blur.ts +48 -0
  38. package/src/brand/AppIcons.tsx +106 -0
  39. package/src/brand/AppMark.tsx +83 -0
  40. package/src/brand/AppWordmark.tsx +97 -0
  41. package/src/brand/familyApps.ts +24 -0
  42. package/src/brand/index.ts +34 -0
  43. package/src/brand/svg.ts +263 -0
  44. package/src/brand/vProAppBenefits.ts +22 -0
  45. package/src/controlStyles.ts +360 -0
  46. package/src/dialogLayout.ts +20 -0
  47. package/src/entries/ambient.ts +3 -0
  48. package/src/entries/bottom-sheet.ts +2 -0
  49. package/src/entries/brand.ts +2 -0
  50. package/src/entries/button.ts +3 -0
  51. package/src/entries/date-time.ts +3 -0
  52. package/src/entries/floating.ts +24 -0
  53. package/src/entries/layout.ts +7 -0
  54. package/src/entries/marketing.ts +10 -0
  55. package/src/entries/primitives.ts +99 -0
  56. package/src/entries/search-input.ts +4 -0
  57. package/src/entries/tab-bar.ts +10 -0
  58. package/src/entries/theme.ts +15 -0
  59. package/src/entries/toast.ts +6 -0
  60. package/src/examples/ParityGallery.tsx +496 -0
  61. package/src/floating.ts +5 -1
  62. package/src/glassBlur.tsx +95 -0
  63. package/src/gradient.ts +21 -0
  64. package/src/haptics.ts +22 -2
  65. package/src/index.ts +99 -2
  66. package/src/initials.ts +10 -0
  67. package/src/marketing/PricingModal.tsx +410 -0
  68. package/src/marketing/UpgradeToProCard.tsx +103 -0
  69. package/src/marketing/pricing.ts +76 -0
  70. package/src/palette.ts +43 -0
  71. package/src/primitives/input.tsx +9 -2
  72. package/src/primitives/native-only-animated-view.tsx +10 -3
  73. package/src/primitives/overlay.tsx +179 -0
  74. package/src/primitives/select.tsx +25 -12
  75. package/src/primitives/switch.tsx +12 -13
  76. package/src/primitives/use-controllable-state.ts +24 -0
  77. package/src/settings/SettingsField.tsx +11 -2
  78. package/src/settings/SettingsSection.tsx +20 -6
  79. package/src/settings/SettingsToggleRow.tsx +7 -2
  80. package/src/tabBarRoutes.ts +52 -0
  81. package/src/toastChrome.ts +103 -0
  82. package/src/toastStore.ts +86 -0
  83. package/src/tokens.ts +19 -14
  84. package/src/typography.ts +24 -0
  85. package/src/useReduceMotion.ts +24 -0
  86. package/src/useScheme.ts +6 -0
@@ -1,5 +1,6 @@
1
1
  import { Platform, View, type ViewProps } from "react-native";
2
2
  import Animated, { type AnimatedProps } from "react-native-reanimated";
3
+ import { useReduceMotion } from "../useReduceMotion";
3
4
 
4
5
  type NativeOnlyAnimatedViewProps = ViewProps & {
5
6
  entering?: AnimatedProps<ViewProps>["entering"];
@@ -7,6 +8,11 @@ type NativeOnlyAnimatedViewProps = ViewProps & {
7
8
  layout?: AnimatedProps<ViewProps>["layout"];
8
9
  };
9
10
 
11
+ /**
12
+ * `Animated.View` with layout animations on iOS and Android, a plain `View`
13
+ * on web. With Reduce Motion on, it drops the animations: sheets, toasts and
14
+ * the `Select` popover appear and leave in place.
15
+ */
10
16
  function NativeOnlyAnimatedView({
11
17
  children,
12
18
  entering,
@@ -14,15 +20,16 @@ function NativeOnlyAnimatedView({
14
20
  layout,
15
21
  ...props
16
22
  }: NativeOnlyAnimatedViewProps) {
23
+ const reduceMotion = useReduceMotion();
17
24
  if (Platform.OS === "web") {
18
25
  return <View {...props}>{children}</View>;
19
26
  }
20
27
 
21
28
  return (
22
29
  <Animated.View
23
- entering={entering}
24
- exiting={exiting}
25
- layout={layout}
30
+ entering={reduceMotion ? undefined : entering}
31
+ exiting={reduceMotion ? undefined : exiting}
32
+ layout={reduceMotion ? undefined : layout}
26
33
  {...props}
27
34
  >
28
35
  {children}
@@ -0,0 +1,179 @@
1
+ import * as Slot from "@rn-primitives/slot";
2
+ import {
3
+ createContext,
4
+ useContext,
5
+ type ComponentPropsWithoutRef,
6
+ type ReactNode,
7
+ } from "react";
8
+ import {
9
+ KeyboardAvoidingView,
10
+ Modal,
11
+ Pressable,
12
+ StyleSheet,
13
+ View,
14
+ type GestureResponderEvent,
15
+ } from "react-native";
16
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
17
+ import { palette } from "../palette";
18
+ import { useReduceMotion } from "../useReduceMotion";
19
+ import { useScheme } from "../useScheme";
20
+ import { useControllableState } from "./use-controllable-state";
21
+
22
+ type OverlayContextValue = {
23
+ open: boolean;
24
+ setOpen: (open: boolean) => void;
25
+ };
26
+
27
+ const OverlayContext = createContext<OverlayContextValue | null>(null);
28
+
29
+ export function useOverlay(part: string): OverlayContextValue {
30
+ const context = useContext(OverlayContext);
31
+ if (!context) {
32
+ throw new Error(`${part} must be inside its Dialog, AlertDialog or Sheet root.`);
33
+ }
34
+ return context;
35
+ }
36
+
37
+ export type OverlayRootProps = {
38
+ /** Open state when the parent controls it. */
39
+ open?: boolean;
40
+ /** Initial open state when `open` is not set. Default `false`. */
41
+ defaultOpen?: boolean;
42
+ /** Called with the next open state (trigger, close, backdrop, Android back). */
43
+ onOpenChange?: (open: boolean) => void;
44
+ children?: ReactNode;
45
+ };
46
+
47
+ /** Root shared by `Dialog`, `AlertDialog` and `Sheet`: holds the open state, renders no view. */
48
+ export function OverlayRoot({
49
+ open,
50
+ defaultOpen = false,
51
+ onOpenChange,
52
+ children,
53
+ }: OverlayRootProps) {
54
+ const [value, setValue] = useControllableState({
55
+ value: open,
56
+ defaultValue: defaultOpen,
57
+ onChange: onOpenChange,
58
+ });
59
+ return (
60
+ <OverlayContext.Provider value={{ open: value, setOpen: setValue }}>
61
+ {children}
62
+ </OverlayContext.Provider>
63
+ );
64
+ }
65
+
66
+ export type OverlayPressableProps = ComponentPropsWithoutRef<typeof Pressable> & {
67
+ /** Merge the press handler into the single child (for example a `Button`) instead of wrapping it. */
68
+ asChild?: boolean;
69
+ };
70
+
71
+ function OverlayPressable({
72
+ asChild,
73
+ next,
74
+ part,
75
+ onPress,
76
+ ...props
77
+ }: OverlayPressableProps & { next: boolean; part: string }) {
78
+ const { setOpen } = useOverlay(part);
79
+ const Component = asChild ? Slot.Pressable : Pressable;
80
+ return (
81
+ <Component
82
+ accessibilityRole="button"
83
+ {...props}
84
+ onPress={(event: GestureResponderEvent) => {
85
+ onPress?.(event);
86
+ setOpen(next);
87
+ }}
88
+ />
89
+ );
90
+ }
91
+
92
+ export function OverlayTrigger(props: OverlayPressableProps & { part: string }) {
93
+ return <OverlayPressable {...props} next />;
94
+ }
95
+
96
+ export function OverlayClose(props: OverlayPressableProps & { part: string }) {
97
+ return <OverlayPressable {...props} next={false} />;
98
+ }
99
+
100
+ /**
101
+ * RN `Modal` with a tinted backdrop, safe-area padding and keyboard avoidance.
102
+ * `placement="center"` centres the child (dialogs) inside the insets on every
103
+ * side; `"bottom"` pins it to the bottom edge (sheets), which pad their own
104
+ * bottom inset. The backdrop closes the overlay only when `dismissible`;
105
+ * Android back and the VoiceOver escape gesture always close it, as Escape
106
+ * does on the web. With Reduce Motion on, the fade is off.
107
+ */
108
+ export function OverlayModal({
109
+ part,
110
+ placement,
111
+ dismissible,
112
+ children,
113
+ }: {
114
+ part: string;
115
+ placement: "center" | "bottom";
116
+ dismissible: boolean;
117
+ children: ReactNode;
118
+ }) {
119
+ const { open, setOpen } = useOverlay(part);
120
+ const scheme = useScheme();
121
+ const insets = useSafeAreaInsets();
122
+ const reduceMotion = useReduceMotion();
123
+ const close = () => setOpen(false);
124
+
125
+ return (
126
+ <Modal
127
+ visible={open}
128
+ transparent
129
+ animationType={reduceMotion ? "none" : "fade"}
130
+ statusBarTranslucent
131
+ onRequestClose={close}
132
+ >
133
+ {/* Android too: a modal window with `statusBarTranslucent` may not
134
+ resize for the keyboard, which then covers the fields. The padding
135
+ is the measured overlap, so it drops to 0 where the window does
136
+ resize. */}
137
+ <KeyboardAvoidingView behavior="padding" style={styles.fill}>
138
+ <Pressable
139
+ testID={`${part}-backdrop`}
140
+ accessible={dismissible}
141
+ accessibilityRole="button"
142
+ accessibilityLabel="Close"
143
+ disabled={!dismissible}
144
+ onPress={close}
145
+ style={[StyleSheet.absoluteFill, { backgroundColor: palette.backdrop[scheme] }]}
146
+ />
147
+ <View
148
+ pointerEvents="box-none"
149
+ onAccessibilityEscape={close}
150
+ style={[
151
+ styles.fill,
152
+ placement === "bottom"
153
+ ? {
154
+ justifyContent: "flex-end",
155
+ paddingTop: insets.top + 16,
156
+ paddingLeft: insets.left,
157
+ paddingRight: insets.right,
158
+ }
159
+ : {
160
+ justifyContent: "center",
161
+ paddingTop: insets.top + 16,
162
+ paddingBottom: insets.bottom + 16,
163
+ paddingLeft: insets.left + 16,
164
+ paddingRight: insets.right + 16,
165
+ },
166
+ ]}
167
+ >
168
+ {children}
169
+ </View>
170
+ </KeyboardAvoidingView>
171
+ </Modal>
172
+ );
173
+ }
174
+
175
+ const styles = StyleSheet.create({
176
+ fill: {
177
+ flex: 1,
178
+ },
179
+ });
@@ -6,13 +6,16 @@ import {
6
6
  ChevronUpIcon,
7
7
  } from "lucide-react-native";
8
8
  import * as React from "react";
9
- import { Platform, ScrollView, StyleSheet, View } from "react-native";
9
+ import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
10
10
  import { FadeIn, FadeOut } from "react-native-reanimated";
11
11
  import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
12
12
  import { NativeOnlyAnimatedView } from "./native-only-animated-view";
13
13
  import { TextClassContext } from "./text";
14
14
  import { LiquidGlass } from "../LiquidGlass";
15
15
  import { cn } from "../cn";
16
+ import { textStyle, typeStyle } from "../controlStyles";
17
+ import { tokens } from "../tokens";
18
+ import { useScheme } from "../useScheme";
16
19
 
17
20
  type Option = SelectPrimitive.Option;
18
21
 
@@ -30,14 +33,18 @@ type SelectValueProps = React.ComponentPropsWithoutRef<
30
33
  const SelectValue = React.forwardRef<
31
34
  React.ElementRef<typeof SelectPrimitive.Value>,
32
35
  SelectValueProps
33
- >(({ className, ...props }, ref) => {
34
- return (
35
- <SelectPrimitive.Value
36
- ref={ref}
37
- className={cn("line-clamp-1 text-sm text-foreground", className)}
38
- {...props}
39
- />
40
- );
36
+ >(({ className, style, ...props }, ref) => {
37
+ const classes = cn("line-clamp-1 text-sm text-foreground", className);
38
+ if (Platform.OS === "web") {
39
+ // Radix `Select.Value` drops `className` and `style`, so the placeholder
40
+ // took the page colour. Its span inherits them from this Text instead.
41
+ return (
42
+ <Text className={classes} style={style}>
43
+ <SelectPrimitive.Value ref={ref} {...props} />
44
+ </Text>
45
+ );
46
+ }
47
+ return <SelectPrimitive.Value ref={ref} className={classes} style={style} {...props} />;
41
48
  });
42
49
 
43
50
  type SelectTriggerProps = React.ComponentPropsWithoutRef<
@@ -51,6 +58,7 @@ const SelectTrigger = React.forwardRef<
51
58
  React.ElementRef<typeof SelectPrimitive.Trigger>,
52
59
  SelectTriggerProps
53
60
  >(({ className, children, size = "default", disabled, ...props }, ref) => {
61
+ const scheme = useScheme();
54
62
  return (
55
63
  <SelectPrimitive.Trigger
56
64
  ref={ref}
@@ -64,7 +72,7 @@ const SelectTrigger = React.forwardRef<
64
72
  {...props}
65
73
  >
66
74
  <>{children}</>
67
- <ChevronDown size={16} color="#737373" />
75
+ <ChevronDown size={16} color={tokens.color.muted[scheme]} />
68
76
  </SelectPrimitive.Trigger>
69
77
  );
70
78
  });
@@ -155,6 +163,7 @@ const SelectItem = React.forwardRef<
155
163
  React.ElementRef<typeof SelectPrimitive.Item>,
156
164
  SelectItemProps
157
165
  >(({ className, disabled, ...props }, ref) => {
166
+ const scheme = useScheme();
158
167
  return (
159
168
  <SelectPrimitive.Item
160
169
  ref={ref}
@@ -168,10 +177,14 @@ const SelectItem = React.forwardRef<
168
177
  >
169
178
  <View className="absolute right-2 size-3.5 items-center justify-center">
170
179
  <SelectPrimitive.ItemIndicator>
171
- <Check size={16} color="#737373" />
180
+ <Check size={16} color={tokens.color.muted[scheme]} />
172
181
  </SelectPrimitive.ItemIndicator>
173
182
  </View>
174
- <SelectPrimitive.ItemText className="select-none text-sm font-instrument-sans-medium text-foreground" />
183
+ <SelectPrimitive.ItemText
184
+ className="select-none text-sm font-instrument-sans-medium"
185
+ // One object: on web the item text is a Radix slot, which drops arrays.
186
+ style={{ ...textStyle("foreground", scheme), ...typeStyle("medium") }}
187
+ />
175
188
  </SelectPrimitive.Item>
176
189
  );
177
190
  });
@@ -1,6 +1,6 @@
1
1
  import * as SwitchPrimitives from "@rn-primitives/switch";
2
2
  import * as React from "react";
3
- import { Platform } from "react-native";
3
+ import { Platform, type ViewStyle } from "react-native";
4
4
  import { cn } from "../cn";
5
5
 
6
6
  type SwitchProps = React.ComponentPropsWithoutRef<
@@ -8,8 +8,12 @@ type SwitchProps = React.ComponentPropsWithoutRef<
8
8
  > & {
9
9
  className?: string;
10
10
  thumbClassName?: string;
11
- onColor?: string;
12
- offColor?: string;
11
+ /**
12
+ * Track and thumb colours. Flat objects: on web each part is a Radix slot,
13
+ * which spreads `style`, so these are the only styles applied there.
14
+ */
15
+ trackStyle?: ViewStyle;
16
+ thumbStyle?: ViewStyle;
13
17
  };
14
18
 
15
19
  const Switch = React.forwardRef<
@@ -20,8 +24,8 @@ const Switch = React.forwardRef<
20
24
  {
21
25
  className,
22
26
  thumbClassName,
23
- onColor,
24
- offColor,
27
+ trackStyle,
28
+ thumbStyle,
25
29
  checked,
26
30
  style,
27
31
  disabled,
@@ -30,16 +34,10 @@ const Switch = React.forwardRef<
30
34
  ref,
31
35
  ) => {
32
36
  const isChecked = Boolean(checked);
33
- const trackColor =
34
- Platform.OS !== "web" && (onColor || offColor)
35
- ? { backgroundColor: isChecked ? onColor : offColor }
36
- : null;
37
37
  const resolvedStyle =
38
38
  Platform.OS === "web" || typeof style === "function"
39
- ? undefined
40
- : trackColor
41
- ? [trackColor, style]
42
- : style;
39
+ ? trackStyle
40
+ : [trackStyle, style];
43
41
 
44
42
  return (
45
43
  <SwitchPrimitives.Root
@@ -59,6 +57,7 @@ const Switch = React.forwardRef<
59
57
  {...props}
60
58
  >
61
59
  <SwitchPrimitives.Thumb
60
+ style={thumbStyle}
62
61
  className={cn(
63
62
  "h-4 w-4 rounded-full bg-background transition-transform",
64
63
  isChecked
@@ -0,0 +1,24 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ /** `value` when the parent controls it, else local state seeded by `defaultValue`. `onChange` fires either way. */
4
+ export function useControllableState<T>({
5
+ value,
6
+ defaultValue,
7
+ onChange,
8
+ }: {
9
+ value: T | undefined;
10
+ defaultValue: T;
11
+ onChange?: (next: T) => void;
12
+ }) {
13
+ const [local, setLocal] = useState(defaultValue);
14
+ const controlled = value !== undefined;
15
+ const current = controlled ? value : local;
16
+ const setValue = useCallback(
17
+ (next: T) => {
18
+ if (!controlled) setLocal(next);
19
+ onChange?.(next);
20
+ },
21
+ [controlled, onChange],
22
+ );
23
+ return [current, setValue] as const;
24
+ }
@@ -1,4 +1,6 @@
1
1
  import { View, Text } from "react-native";
2
+ import { textStyle, typeStyle } from "../controlStyles";
3
+ import { useScheme } from "../useScheme";
2
4
 
3
5
  export function SettingsField({
4
6
  label,
@@ -9,10 +11,14 @@ export function SettingsField({
9
11
  description?: React.ReactNode;
10
12
  children: React.ReactNode;
11
13
  }) {
14
+ const scheme = useScheme();
12
15
  return (
13
16
  <View>
14
17
  {typeof label === "string" ? (
15
- <Text className="mb-2 text-base font-instrument-sans-semibold text-foreground">
18
+ <Text
19
+ className="mb-2 text-base font-instrument-sans-semibold"
20
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
21
+ >
16
22
  {label}
17
23
  </Text>
18
24
  ) : (
@@ -21,7 +27,10 @@ export function SettingsField({
21
27
  {children}
22
28
  {description != null ? (
23
29
  typeof description === "string" ? (
24
- <Text className="mt-1.5 text-sm font-instrument-sans text-muted-foreground">
30
+ <Text
31
+ className="mt-1.5 text-sm font-instrument-sans"
32
+ style={textStyle("muted", scheme)}
33
+ >
25
34
  {description}
26
35
  </Text>
27
36
  ) : (
@@ -1,6 +1,8 @@
1
1
  import { View, Text } from "react-native";
2
2
  import { cn } from "../cn";
3
- import { FloatingSurface } from "../FloatingSurface";
3
+ import { cardStyle, textStyle, typeStyle } from "../controlStyles";
4
+ import { tokens } from "../tokens";
5
+ import { useScheme } from "../useScheme";
4
6
 
5
7
  type SettingsSectionBodyVariant = "form" | "list" | "compact";
6
8
 
@@ -21,6 +23,7 @@ export function SettingsSection({
21
23
  bodyVariant?: SettingsSectionBodyVariant;
22
24
  className?: string;
23
25
  }) {
26
+ const scheme = useScheme();
24
27
  const hasBody = children != null;
25
28
  const hasCard = hasBody || footer != null;
26
29
 
@@ -29,7 +32,11 @@ export function SettingsSection({
29
32
  <View className="flex-row items-start justify-between gap-4 px-4">
30
33
  <View className="min-w-0 flex-1">
31
34
  {typeof title === "string" ? (
32
- <Text className="text-lg font-instrument-sans-bold text-foreground">
35
+ <Text
36
+ accessibilityRole="header"
37
+ className="text-lg font-instrument-sans-semibold"
38
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
39
+ >
33
40
  {title}
34
41
  </Text>
35
42
  ) : (
@@ -37,7 +44,10 @@ export function SettingsSection({
37
44
  )}
38
45
  {description != null ? (
39
46
  typeof description === "string" ? (
40
- <Text className="mt-0.5 text-base font-instrument-sans text-muted-foreground">
47
+ <Text
48
+ className="mt-0.5 text-base font-instrument-sans"
49
+ style={textStyle("muted", scheme)}
50
+ >
41
51
  {description}
42
52
  </Text>
43
53
  ) : (
@@ -49,7 +59,8 @@ export function SettingsSection({
49
59
  </View>
50
60
 
51
61
  {hasCard ? (
52
- <FloatingSurface>
62
+ // The web section card (`rounded-lg bg-surface-card`): flat, no shadow.
63
+ <View className="overflow-hidden" style={cardStyle(scheme)}>
53
64
  {hasBody ? (
54
65
  <View
55
66
  className={
@@ -64,11 +75,14 @@ export function SettingsSection({
64
75
  </View>
65
76
  ) : null}
66
77
  {footer != null ? (
67
- <View className="flex-row items-center justify-end gap-2 bg-background px-4 py-3">
78
+ <View
79
+ className="flex-row items-center justify-end gap-2 px-4 py-3"
80
+ style={{ backgroundColor: tokens.color.surfaceSecondary[scheme] }}
81
+ >
68
82
  {footer}
69
83
  </View>
70
84
  ) : null}
71
- </FloatingSurface>
85
+ </View>
72
86
  ) : null}
73
87
  </View>
74
88
  );
@@ -1,6 +1,8 @@
1
1
  import { View, Text } from "react-native";
2
2
  import { cn } from "../cn";
3
+ import { textStyle, typeStyle } from "../controlStyles";
3
4
  import { useLargerText } from "../theme";
5
+ import { useScheme } from "../useScheme";
4
6
 
5
7
  export function SettingsToggleRow({
6
8
  title,
@@ -14,6 +16,7 @@ export function SettingsToggleRow({
14
16
  className?: string;
15
17
  }) {
16
18
  const largerText = useLargerText();
19
+ const scheme = useScheme();
17
20
  return (
18
21
  <View
19
22
  className={cn(
@@ -26,9 +29,10 @@ export function SettingsToggleRow({
26
29
  {typeof title === "string" ? (
27
30
  <Text
28
31
  className={cn(
29
- "font-instrument-sans-semibold text-foreground",
32
+ "font-instrument-sans-semibold",
30
33
  largerText ? "text-xl" : "text-lg",
31
34
  )}
35
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
32
36
  >
33
37
  {title}
34
38
  </Text>
@@ -39,9 +43,10 @@ export function SettingsToggleRow({
39
43
  typeof description === "string" ? (
40
44
  <Text
41
45
  className={cn(
42
- "mt-1 font-instrument-sans text-muted-foreground",
46
+ "mt-1 font-instrument-sans",
43
47
  largerText ? "text-lg" : "text-base",
44
48
  )}
49
+ style={textStyle("muted", scheme)}
45
50
  >
46
51
  {description}
47
52
  </Text>
@@ -0,0 +1,52 @@
1
+ // Which routes `FloatingTabBar` draws. No React Native runtime import, so the
2
+ // tests can load this file.
3
+ import type { ReactNode } from "react";
4
+ import type { ColorValue, StyleProp, ViewStyle } from "react-native";
5
+
6
+ /**
7
+ * The fields `FloatingTabBar` reads from a route's options (React
8
+ * Navigation's `BottomTabNavigationOptions`, and Expo Router's `href`).
9
+ */
10
+ export type FloatingTabOptions = {
11
+ title?: string;
12
+ tabBarLabel?: string | ((props: never) => ReactNode);
13
+ tabBarIcon?: (props: {
14
+ focused: boolean;
15
+ color: ColorValue;
16
+ size: number;
17
+ }) => ReactNode;
18
+ tabBarActiveTintColor?: ColorValue;
19
+ tabBarInactiveTintColor?: ColorValue;
20
+ tabBarButtonTestID?: string;
21
+ tabBarAccessibilityLabel?: string;
22
+ /** Expo Router's `href`. `null` hides the tab. */
23
+ href?: string | null;
24
+ /** Only `display: "none"` is read: it hides the tab, as in React Navigation's own bar. */
25
+ tabBarItemStyle?: StyleProp<ViewStyle>;
26
+ };
27
+
28
+ /** The `display` a style resolves to: in an array the last one set wins, as in `StyleSheet.flatten`. */
29
+ function displayOf(style: unknown): unknown {
30
+ if (Array.isArray(style)) {
31
+ let display: unknown;
32
+ for (const item of style) {
33
+ const value = displayOf(item);
34
+ if (value !== undefined) display = value;
35
+ }
36
+ return display;
37
+ }
38
+ return style && typeof style === "object" ? (style as { display?: unknown }).display : undefined;
39
+ }
40
+
41
+ /**
42
+ * Whether `FloatingTabBar` draws a tab for a route with these options. A route
43
+ * without a descriptor has none. Apps hide a tab with Expo Router's
44
+ * `options={{ href: null }}`. Expo Router's `Tabs` can drop `href` and pass
45
+ * `tabBarItemStyle: { display: "none" }` in its place, so both hide the tab.
46
+ * Hiding is display-only: `state.index` still counts every route.
47
+ */
48
+ export function isTabBarRouteVisible(
49
+ options: FloatingTabOptions | undefined,
50
+ ): options is FloatingTabOptions {
51
+ return options !== undefined && options.href !== null && displayOf(options.tabBarItemStyle) !== "none";
52
+ }