@vvedantb/native-ui 0.2.1 → 0.3.13

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 (91) hide show
  1. package/README.md +171 -9
  2. package/package.json +84 -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/PageTitle.tsx +81 -0
  25. package/src/PillTabs.tsx +28 -14
  26. package/src/Progress.tsx +67 -0
  27. package/src/SearchInput.tsx +163 -0
  28. package/src/Select.tsx +32 -6
  29. package/src/Separator.tsx +38 -0
  30. package/src/Sheet.tsx +107 -0
  31. package/src/Skeleton.tsx +25 -5
  32. package/src/Spinner.tsx +92 -0
  33. package/src/StatusDot.tsx +66 -0
  34. package/src/Switch.tsx +24 -7
  35. package/src/Tabs.tsx +190 -0
  36. package/src/Textarea.tsx +47 -0
  37. package/src/Toast.tsx +170 -0
  38. package/src/blur.ts +48 -0
  39. package/src/brand/AppIcons.tsx +106 -0
  40. package/src/brand/AppMark.tsx +83 -0
  41. package/src/brand/AppWordmark.tsx +97 -0
  42. package/src/brand/familyApps.ts +24 -0
  43. package/src/brand/index.ts +34 -0
  44. package/src/brand/svg.ts +263 -0
  45. package/src/brand/vProAppBenefits.ts +22 -0
  46. package/src/controlStyles.ts +395 -0
  47. package/src/dialogLayout.ts +20 -0
  48. package/src/entries/ambient.ts +3 -0
  49. package/src/entries/bottom-sheet.ts +2 -0
  50. package/src/entries/brand.ts +2 -0
  51. package/src/entries/button.ts +3 -0
  52. package/src/entries/date-time.ts +3 -0
  53. package/src/entries/floating.ts +24 -0
  54. package/src/entries/layout.ts +10 -0
  55. package/src/entries/marketing.ts +10 -0
  56. package/src/entries/page-title.ts +4 -0
  57. package/src/entries/primitives.ts +99 -0
  58. package/src/entries/search-input.ts +4 -0
  59. package/src/entries/tab-bar.ts +10 -0
  60. package/src/entries/theme.ts +15 -0
  61. package/src/entries/toast.ts +6 -0
  62. package/src/examples/ParityGallery.tsx +513 -0
  63. package/src/floating.ts +5 -1
  64. package/src/glassBlur.tsx +95 -0
  65. package/src/gradient.ts +21 -0
  66. package/src/haptics.ts +22 -2
  67. package/src/index.ts +101 -2
  68. package/src/initials.ts +10 -0
  69. package/src/marketing/PricingModal.tsx +410 -0
  70. package/src/marketing/UpgradeToProCard.tsx +103 -0
  71. package/src/marketing/pricing.ts +76 -0
  72. package/src/palette.ts +43 -0
  73. package/src/primitives/button.tsx +3 -1
  74. package/src/primitives/card.tsx +5 -2
  75. package/src/primitives/input.tsx +9 -2
  76. package/src/primitives/native-only-animated-view.tsx +10 -3
  77. package/src/primitives/overlay.tsx +179 -0
  78. package/src/primitives/select.tsx +25 -12
  79. package/src/primitives/switch.tsx +12 -13
  80. package/src/primitives/text.tsx +25 -8
  81. package/src/primitives/use-controllable-state.ts +24 -0
  82. package/src/settings/SettingsField.tsx +11 -2
  83. package/src/settings/SettingsSection.tsx +20 -6
  84. package/src/settings/SettingsToggleRow.tsx +7 -2
  85. package/src/tabBarRoutes.ts +52 -0
  86. package/src/toastChrome.ts +103 -0
  87. package/src/toastStore.ts +86 -0
  88. package/src/tokens.ts +19 -14
  89. package/src/typography.ts +24 -0
  90. package/src/useReduceMotion.ts +24 -0
  91. package/src/useScheme.ts +6 -0
@@ -0,0 +1,76 @@
1
+ // Plan types and pure helpers for `PricingModal`, the same shapes and rules as
2
+ // the web `PricingModal` multi-plan layout. No React Native imports.
3
+ import type { ReactNode } from "react";
4
+ import type { AppName } from "../brand/svg";
5
+ import { V_PRO_APP_BENEFITS, type PricingPlanAppBenefit } from "../brand/vProAppBenefits";
6
+
7
+ export type PricingModalPeriod = "month" | "year";
8
+
9
+ export type PricingPlanBenefit = {
10
+ label: ReactNode;
11
+ /** `"check"` (default) or an app mark. */
12
+ icon?: "check" | AppName;
13
+ /** Dimmed row. */
14
+ muted?: boolean;
15
+ };
16
+
17
+ export type PricingPlan = {
18
+ id: string;
19
+ name: string;
20
+ /** Display strings per month, for example `{ month: "$10", year: "$8" }`. The yearly price is still per month. */
21
+ price: { month: string; year: string };
22
+ /** Default "Get started". */
23
+ ctaLabel?: string;
24
+ /** Line above the highlight, for example "Everything in Free, and:". */
25
+ intro?: ReactNode;
26
+ /** Large title with small text under it. */
27
+ highlight?: { title: string; description: string };
28
+ benefits?: readonly PricingPlanBenefit[];
29
+ /** Grouped per app, after `benefits`. The featured plan defaults to `V_PRO_APP_BENEFITS`. */
30
+ appBenefits?: readonly PricingPlanAppBenefit[];
31
+ /** The plan the user is on: the button reads "Current plan" and is disabled. */
32
+ current?: boolean;
33
+ /** Subtle ring on the card. `highlighted` is an alias. */
34
+ featured?: boolean;
35
+ highlighted?: boolean;
36
+ /** Text after the price. Default "/month". */
37
+ periodSuffix?: string;
38
+ };
39
+
40
+ /** `featured`, else `highlighted` (the web rule: `featured ?? highlighted`). */
41
+ export function isEmphasised(plan: PricingPlan): boolean {
42
+ return Boolean(plan.featured ?? plan.highlighted);
43
+ }
44
+
45
+ /** The plan's app groups: its own, else `V_PRO_APP_BENEFITS` for the emphasised plan, else none. */
46
+ export function planAppBenefits(
47
+ plan: PricingPlan,
48
+ ): readonly PricingPlanAppBenefit[] | undefined {
49
+ return plan.appBenefits ?? (isEmphasised(plan) ? V_PRO_APP_BENEFITS : undefined);
50
+ }
51
+
52
+ /** Stable sort: the current app's group first, the rest in their order. */
53
+ export function sortAppBenefits(
54
+ groups: readonly PricingPlanAppBenefit[],
55
+ currentApp?: AppName,
56
+ ): PricingPlanAppBenefit[] {
57
+ return [...groups].sort(
58
+ (a, b) => Number(b.app === currentApp) - Number(a.app === currentApp),
59
+ );
60
+ }
61
+
62
+ /** Label, variant and state of a plan's button, as on the web. */
63
+ export function planButton(plan: PricingPlan): {
64
+ label: string;
65
+ variant: "default" | "secondary" | "outline";
66
+ disabled: boolean;
67
+ } {
68
+ if (plan.current) {
69
+ return { label: "Current plan", variant: "outline", disabled: true };
70
+ }
71
+ return {
72
+ label: plan.ctaLabel ?? "Get started",
73
+ variant: isEmphasised(plan) ? "default" : "secondary",
74
+ disabled: false,
75
+ };
76
+ }
package/src/palette.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Web semantic colours for React Native, converted from `@vvedantb/ui`
3
+ * `tokens.css` (oklch → sRGB hex, clipped to sRGB). `tokens.ts` holds the
4
+ * neutrals the native app ships; these are the colours it lacks (primary,
5
+ * danger, success, warning, overlay, segment, field placeholder, backdrop,
6
+ * dialog description).
7
+ *
8
+ * A test in this package converts `tokens.css` and fails when a value here
9
+ * drifts, so change `tokens.css` first and then this file.
10
+ */
11
+ export const palette = {
12
+ /** `--accent` (web `primary`): near-black in light, near-white in dark. */
13
+ primary: { light: "#181818", dark: "#fcfcfc" },
14
+ primaryForeground: { light: "#fcfcfc", dark: "#181818" },
15
+ danger: { light: "#ff373c", dark: "#db3b3e" },
16
+ dangerForeground: { light: "#fcfcfc", dark: "#fcfcfc" },
17
+ success: { light: "#15c964", dark: "#15c964" },
18
+ successForeground: { light: "#161917", dark: "#161917" },
19
+ warning: { light: "#f5a523", dark: "#f7b750" },
20
+ warningForeground: { light: "#1a1815", dark: "#1a1815" },
21
+ /** `--default` (web `secondary`): neutral control fill. */
22
+ default: { light: "#ebebeb", dark: "#272727" },
23
+ defaultForeground: { light: "#181818", dark: "#fcfcfc" },
24
+ overlay: { light: "#ffffff", dark: "#181818" },
25
+ overlayForeground: { light: "#181818", dark: "#fcfcfc" },
26
+ segment: { light: "#ffffff", dark: "#474747" },
27
+ segmentForeground: { light: "#181818", dark: "#fcfcfc" },
28
+ surfaceTertiary: { light: "#eaeaea", dark: "#282627" },
29
+ fieldPlaceholder: { light: "#727272", dark: "#a0a0a0" },
30
+ backdrop: { light: "rgba(0, 0, 0, 0.5)", dark: "rgba(0, 0, 0, 0.6)" },
31
+ /** Web `DialogDescription` text: `--overlay-foreground` mixed 72% into `--overlay` (oklch), for 4.5:1 on the overlay. */
32
+ overlayMuted: { light: "#505050", dark: "#b5b5b5" },
33
+ /** Fill of the web destructive `Button` (`oklch(47.2% 0.195 25.7)`), the same in both schemes. */
34
+ destructive: "#af0015",
35
+ } as const;
36
+
37
+ export type Palette = typeof palette;
38
+
39
+ /** `#rrggbb` with an alpha, as `rgba(...)`. For tints such as the badge fills (web `bg-danger/12`). */
40
+ export function withAlpha(hex: string, alpha: number): string {
41
+ const n = Number.parseInt(hex.slice(1, 7), 16);
42
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
43
+ }
@@ -47,7 +47,9 @@ const buttonVariants = cva(
47
47
  },
48
48
  );
49
49
 
50
- const buttonTextVariants = cva("text-sm font-medium text-foreground", {
50
+ // No weight: a class context cannot carry the `typeStyle` that a face class
51
+ // needs, so `Button` sets the medium face and its weight on its label.
52
+ const buttonTextVariants = cva("text-sm text-foreground", {
51
53
  variants: {
52
54
  variant: {
53
55
  default: "text-primary-foreground",
@@ -2,6 +2,7 @@ import * as React from "react";
2
2
  import { View } from "react-native";
3
3
  import { Text, TextClassContext } from "./text";
4
4
  import { cn } from "../cn";
5
+ import { typeStyle } from "../controlStyles";
5
6
 
6
7
  type CardViewProps = React.ComponentPropsWithoutRef<typeof View> & {
7
8
  className?: string;
@@ -40,13 +41,15 @@ const CardHeader = React.forwardRef<
40
41
  const CardTitle = React.forwardRef<
41
42
  React.ElementRef<typeof Text>,
42
43
  React.ComponentPropsWithoutRef<typeof Text> & { className?: string }
43
- >(({ className, ...props }, ref) => {
44
+ >(({ className, style, ...props }, ref) => {
45
+ // The web `font-semibold`: the semibold face, with its weight as a style.
44
46
  return (
45
47
  <Text
46
48
  ref={ref}
47
49
  role="heading"
48
50
  aria-level={3}
49
- className={cn("font-semibold leading-none", className)}
51
+ className={cn("font-instrument-sans-semibold leading-none", className)}
52
+ style={[typeStyle("semibold"), style]}
50
53
  {...props}
51
54
  />
52
55
  );
@@ -1,16 +1,24 @@
1
1
  import * as React from "react";
2
2
  import { Platform, TextInput } from "react-native";
3
3
  import { cn } from "../cn";
4
+ import { palette } from "../palette";
5
+ import { useScheme } from "../useScheme";
4
6
 
5
7
  type InputProps = React.ComponentPropsWithoutRef<typeof TextInput> & {
6
8
  className?: string;
7
9
  };
8
10
 
9
11
  const Input = React.forwardRef<React.ElementRef<typeof TextInput>, InputProps>(
10
- ({ className, ...props }, ref) => {
12
+ ({ className, placeholderTextColor, ...props }, ref) => {
13
+ const scheme = useScheme();
11
14
  return (
12
15
  <TextInput
13
16
  ref={ref}
17
+ // A prop, not a `placeholder:` class: on native NativeWind turns that
18
+ // class into `placeholderTextColor`, which beats the prop. The old
19
+ // `placeholder:text-muted-foreground/50` was the light grey at half
20
+ // opacity in apps that nest `dark` under `muted-foreground`.
21
+ placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
14
22
  className={cn(
15
23
  "flex w-full flex-row items-center rounded-lg border-0 bg-secondary px-3 py-2 text-base text-foreground",
16
24
  props.editable === false &&
@@ -22,7 +30,6 @@ const Input = React.forwardRef<React.ElementRef<typeof TextInput>, InputProps>(
22
30
  ),
23
31
  Platform.select({
24
32
  web: "outline-none transition-[color,box-shadow] md:text-sm",
25
- native: "placeholder:text-muted-foreground/50",
26
33
  }),
27
34
  className,
28
35
  )}
@@ -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
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
3
3
  import * as React from "react";
4
4
  import { Platform, Text as RNText, type Role } from "react-native";
5
5
  import { cn } from "../cn";
6
+ import { typeStyle } from "../controlStyles";
6
7
 
7
8
  const textVariants = cva(
8
9
  cn(
@@ -16,27 +17,27 @@ const textVariants = cva(
16
17
  variant: {
17
18
  default: "",
18
19
  h1: cn(
19
- "text-4xl font-extrabold tracking-tight",
20
+ "text-4xl font-instrument-sans-semibold tracking-tight",
20
21
  Platform.select({ web: "scroll-m-20" }),
21
22
  ),
22
23
  h2: cn(
23
- "text-3xl font-semibold tracking-tight border-border border-b pb-2",
24
+ "text-3xl font-instrument-sans-semibold tracking-tight border-border border-b pb-2",
24
25
  Platform.select({ web: "scroll-m-20" }),
25
26
  ),
26
27
  h3: cn(
27
- "text-2xl font-semibold tracking-tight",
28
+ "text-2xl font-instrument-sans-semibold tracking-tight",
28
29
  Platform.select({ web: "scroll-m-20" }),
29
30
  ),
30
31
  h4: cn(
31
- "text-xl font-semibold tracking-tight",
32
+ "text-xl font-instrument-sans-semibold tracking-tight",
32
33
  Platform.select({ web: "scroll-m-20" }),
33
34
  ),
34
35
  p: "leading-7",
35
36
  blockquote: "border-l-2 border-border pl-4 italic",
36
- code: "bg-muted rounded px-[0.3rem] py-[0.2rem] text-sm font-semibold",
37
+ code: "bg-muted rounded px-[0.3rem] py-[0.2rem] text-sm font-instrument-sans-semibold",
37
38
  lead: "text-xl text-muted-foreground",
38
- large: "text-lg font-semibold",
39
- small: "text-sm font-medium",
39
+ large: "text-lg font-instrument-sans-semibold",
40
+ small: "text-sm font-instrument-sans-medium",
40
41
  muted: "text-sm text-muted-foreground",
41
42
  },
42
43
  },
@@ -65,6 +66,20 @@ const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
65
66
  h4: "4",
66
67
  };
67
68
 
69
+ // The weight of each variant's `font-instrument-sans-*` class, set as a style
70
+ // beside it (`typeStyle`): where an app maps every one of those classes to one
71
+ // family, the class alone draws Regular. Headings are semibold, as the web
72
+ // titles; `h1` was `font-extrabold` before 0.3.13.
73
+ const WEIGHT: Partial<Record<TextVariant, ReturnType<typeof typeStyle>>> = {
74
+ h1: typeStyle("semibold"),
75
+ h2: typeStyle("semibold"),
76
+ h3: typeStyle("semibold"),
77
+ h4: typeStyle("semibold"),
78
+ code: typeStyle("semibold"),
79
+ large: typeStyle("semibold"),
80
+ small: typeStyle("medium"),
81
+ };
82
+
68
83
  const TextClassContext = React.createContext<string | undefined>(undefined);
69
84
 
70
85
  type TextProps = React.ComponentPropsWithoutRef<typeof RNText> &
@@ -74,9 +89,10 @@ type TextProps = React.ComponentPropsWithoutRef<typeof RNText> &
74
89
  };
75
90
 
76
91
  const Text = React.forwardRef<React.ElementRef<typeof RNText>, TextProps>(
77
- ({ className, asChild = false, variant = "default", ...props }, ref) => {
92
+ ({ className, asChild = false, variant = "default", style, ...props }, ref) => {
78
93
  const textClass = React.useContext(TextClassContext);
79
94
  const Component = asChild ? Slot.Text : RNText;
95
+ const weight = variant ? WEIGHT[variant] : undefined;
80
96
 
81
97
  return (
82
98
  <Component
@@ -84,6 +100,7 @@ const Text = React.forwardRef<React.ElementRef<typeof RNText>, TextProps>(
84
100
  className={cn(textVariants({ variant }), textClass, className)}
85
101
  role={variant ? ROLE[variant] : undefined}
86
102
  aria-level={variant ? ARIA_LEVEL[variant] : undefined}
103
+ style={weight ? [weight, style] : style}
87
104
  {...props}
88
105
  />
89
106
  );
@@ -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
+ }