@vvedantb/native-ui 0.2.0 → 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 +88 -8
  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
package/src/Select.tsx CHANGED
@@ -9,7 +9,16 @@ import {
9
9
  SelectValue,
10
10
  } from "./primitives/select";
11
11
  import { cn } from "./cn";
12
+ import {
13
+ MIN_TOUCH_TARGET,
14
+ fieldStyle,
15
+ fieldTextStyle,
16
+ touchSlop,
17
+ typeStyle,
18
+ } from "./controlStyles";
19
+ import { palette } from "./palette";
12
20
  import { useLargerText } from "./theme";
21
+ import { useScheme } from "./useScheme";
13
22
 
14
23
  interface SelectOption<TValue = string | number | null> {
15
24
  label: string;
@@ -27,6 +36,8 @@ interface SelectProps<TValue = string | number | null> {
27
36
  triggerClassName?: string;
28
37
  contentClassName?: string;
29
38
  testID?: string;
39
+ /** Names the field for VoiceOver and TalkBack, which otherwise read only the value or placeholder. */
40
+ accessibilityLabel?: string;
30
41
  }
31
42
 
32
43
  const NULL_SENTINEL = "__null__";
@@ -62,8 +73,10 @@ function Select<TValue = string | number | null>({
62
73
  triggerClassName,
63
74
  contentClassName,
64
75
  testID,
76
+ accessibilityLabel,
65
77
  }: SelectProps<TValue>) {
66
78
  const largerText = useLargerText();
79
+ const scheme = useScheme();
67
80
  const primitiveValue = toPrimitiveValue(value, options);
68
81
 
69
82
  return (
@@ -89,19 +102,32 @@ function Select<TValue = string | number | null>({
89
102
  >
90
103
  <SelectTrigger
91
104
  testID={testID}
105
+ accessibilityRole="combobox"
106
+ accessibilityLabel={accessibilityLabel}
107
+ accessibilityState={{ disabled: Boolean(disabled) }}
108
+ // 40 high on screen, as on the web; a 44 target to touch.
109
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, largerText ? 64 : 40)}
92
110
  className={cn(
93
- "h-14 w-full rounded-lg border-0 bg-secondary px-4 py-0 shadow-none",
94
- largerText && "h-16",
111
+ // The web trigger: h-10 px-3.5, 16 px type (its phone size). A
112
+ // minimum height, so a large Dynamic Type size does not clip the value.
113
+ "h-auto min-h-10 w-full border px-3.5 py-0 shadow-none",
114
+ largerText && "min-h-16 px-4",
95
115
  triggerClassName,
96
116
  className,
97
117
  )}
118
+ style={fieldStyle(scheme)}
98
119
  >
99
120
  <SelectValue
100
121
  placeholder={placeholder ?? "Select option"}
101
- className={cn(
102
- "font-instrument-sans-medium text-foreground",
103
- largerText ? "text-xl" : "text-lg",
104
- )}
122
+ className={
123
+ largerText ? "font-instrument-sans-medium text-xl" : "font-instrument-sans text-base"
124
+ }
125
+ style={[
126
+ primitiveValue
127
+ ? fieldTextStyle(scheme)
128
+ : { color: palette.fieldPlaceholder[scheme] },
129
+ largerText ? typeStyle("medium") : null,
130
+ ]}
105
131
  />
106
132
  </SelectTrigger>
107
133
  <SelectContent className={cn("w-full", contentClassName)}>
@@ -0,0 +1,38 @@
1
+ import { StyleSheet, View, type ViewProps } from "react-native";
2
+ import { cn } from "./cn";
3
+ import { tokens } from "./tokens";
4
+ import { useScheme } from "./useScheme";
5
+
6
+ export type SeparatorProps = ViewProps & {
7
+ /** Default `"horizontal"`. */
8
+ orientation?: "horizontal" | "vertical";
9
+ /** Hidden from assistive tech. Default `true`, as on the web. */
10
+ decorative?: boolean;
11
+ className?: string;
12
+ };
13
+
14
+ /** Hairline in `tokens.color.separator`. Vertical separators stretch to the row height. */
15
+ export function Separator({
16
+ orientation = "horizontal",
17
+ decorative = true,
18
+ className,
19
+ style,
20
+ ...props
21
+ }: SeparatorProps) {
22
+ const scheme = useScheme();
23
+ return (
24
+ <View
25
+ accessibilityElementsHidden={decorative}
26
+ importantForAccessibility={decorative ? "no-hide-descendants" : "auto"}
27
+ className={cn("shrink-0", className)}
28
+ style={[
29
+ orientation === "horizontal"
30
+ ? { height: StyleSheet.hairlineWidth, width: "100%" }
31
+ : { width: StyleSheet.hairlineWidth, alignSelf: "stretch" },
32
+ { backgroundColor: tokens.color.separator[scheme] },
33
+ style,
34
+ ]}
35
+ {...props}
36
+ />
37
+ );
38
+ }
package/src/Sheet.tsx ADDED
@@ -0,0 +1,107 @@
1
+ import { View, type ViewProps } from "react-native";
2
+ import { SlideInDown } from "react-native-reanimated";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { FloatingSurface } from "./FloatingSurface";
5
+ import { cn } from "./cn";
6
+ import { withAlpha } from "./palette";
7
+ import { tokens } from "./tokens";
8
+ import { useScheme } from "./useScheme";
9
+ import { DialogBody, DialogDescription, DialogTitle } from "./Dialog";
10
+ import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
11
+ import {
12
+ OverlayClose,
13
+ OverlayModal,
14
+ OverlayRoot,
15
+ OverlayTrigger,
16
+ type OverlayPressableProps,
17
+ type OverlayRootProps,
18
+ } from "./primitives/overlay";
19
+
20
+ const SHEET_RADIUS = tokens.radius.card;
21
+
22
+ export type SheetProps = OverlayRootProps;
23
+
24
+ /**
25
+ * Bottom sheet in an RN `Modal`, with no `@gorhom/bottom-sheet`. Same open
26
+ * state API as `Dialog`. Use `BottomSheetContainer` for drag-to-dismiss.
27
+ */
28
+ export function Sheet(props: SheetProps) {
29
+ return <OverlayRoot {...props} />;
30
+ }
31
+
32
+ export function SheetTrigger(props: OverlayPressableProps) {
33
+ return <OverlayTrigger part="SheetTrigger" {...props} />;
34
+ }
35
+
36
+ export function SheetClose(props: OverlayPressableProps) {
37
+ return <OverlayClose part="SheetClose" {...props} />;
38
+ }
39
+
40
+ export type SheetContentProps = ViewProps & {
41
+ className?: string;
42
+ /** Close on a backdrop tap. Default `true`. */
43
+ dismissible?: boolean;
44
+ /** Grab handle above the content. Default `true`. */
45
+ showHandle?: boolean;
46
+ };
47
+
48
+ /**
49
+ * Panel pinned to the bottom edge: opaque surface, top corners at the card
50
+ * radius, safe-area bottom padding, slides in. Its bottom corners sit below the
51
+ * screen edge, so only the top corners show.
52
+ */
53
+ export function SheetContent({
54
+ children,
55
+ className,
56
+ dismissible = true,
57
+ showHandle = true,
58
+ style,
59
+ ...props
60
+ }: SheetContentProps) {
61
+ const insets = useSafeAreaInsets();
62
+ return (
63
+ <OverlayModal part="SheetContent" placement="bottom" dismissible={dismissible}>
64
+ <NativeOnlyAnimatedView
65
+ entering={SlideInDown.duration(240)}
66
+ style={{ marginBottom: -SHEET_RADIUS }}
67
+ >
68
+ <FloatingSurface blur={false} radius={SHEET_RADIUS}>
69
+ <View
70
+ accessibilityViewIsModal
71
+ className={cn("gap-4 px-5 pt-3", className)}
72
+ {...props}
73
+ style={[{ paddingBottom: insets.bottom + 20 + SHEET_RADIUS }, style]}
74
+ >
75
+ {showHandle ? <SheetHandle /> : null}
76
+ {children}
77
+ </View>
78
+ </FloatingSurface>
79
+ </NativeOnlyAnimatedView>
80
+ </OverlayModal>
81
+ );
82
+ }
83
+
84
+ export function SheetHandle({ className, style, ...props }: ViewProps & { className?: string }) {
85
+ const scheme = useScheme();
86
+ return (
87
+ <View
88
+ accessibilityElementsHidden
89
+ importantForAccessibility="no-hide-descendants"
90
+ className={cn("h-1.5 w-10 self-center rounded-full", className)}
91
+ style={[{ backgroundColor: withAlpha(tokens.color.muted[scheme], 0.4) }, style]}
92
+ {...props}
93
+ />
94
+ );
95
+ }
96
+
97
+ export function SheetHeader({ className, ...props }: ViewProps & { className?: string }) {
98
+ return <View className={cn("gap-1.5", className)} {...props} />;
99
+ }
100
+
101
+ export const SheetTitle = DialogTitle;
102
+ export const SheetDescription = DialogDescription;
103
+ export const SheetBody = DialogBody;
104
+ /** Buttons stack full width, the last child on top: the web footer on a phone. */
105
+ export function SheetFooter({ className, ...props }: ViewProps & { className?: string }) {
106
+ return <View className={cn("flex-col-reverse gap-2", className)} {...props} />;
107
+ }
package/src/Skeleton.tsx CHANGED
@@ -1,5 +1,6 @@
1
- import { View } from "react-native";
1
+ import { View, type ViewProps } from "react-native";
2
2
  import Animated, {
3
+ cancelAnimation,
3
4
  useAnimatedStyle,
4
5
  useSharedValue,
5
6
  withRepeat,
@@ -7,13 +8,27 @@ import Animated, {
7
8
  } from "react-native-reanimated";
8
9
  import { useEffect } from "react";
9
10
  import { cn } from "./cn";
11
+ import { cardStyle } from "./controlStyles";
12
+ import { useReduceMotion } from "./useReduceMotion";
13
+ import { useScheme } from "./useScheme";
10
14
 
11
- export function Skeleton({ className }: { className?: string }) {
15
+ /** Pulsing placeholder block. Holds still with Reduce Motion. Size it with `className` (for example `h-4 w-32`). */
16
+ export function Skeleton({
17
+ className,
18
+ style,
19
+ ...props
20
+ }: ViewProps & { className?: string }) {
21
+ const reduceMotion = useReduceMotion();
12
22
  const opacity = useSharedValue(0.4);
13
23
 
14
24
  useEffect(() => {
25
+ if (reduceMotion) {
26
+ opacity.value = 0.7;
27
+ return;
28
+ }
15
29
  opacity.value = withRepeat(withTiming(1, { duration: 800 }), -1, true);
16
- }, [opacity]);
30
+ return () => cancelAnimation(opacity);
31
+ }, [opacity, reduceMotion]);
17
32
 
18
33
  const animatedStyle = useAnimatedStyle(() => ({
19
34
  opacity: opacity.value,
@@ -21,15 +36,20 @@ export function Skeleton({ className }: { className?: string }) {
21
36
 
22
37
  return (
23
38
  <Animated.View
24
- style={animatedStyle}
39
+ accessibilityElementsHidden
40
+ importantForAccessibility="no-hide-descendants"
41
+ {...props}
42
+ style={[animatedStyle, style]}
25
43
  className={cn("bg-neutral-200 dark:bg-neutral-700 rounded-lg", className)}
26
44
  />
27
45
  );
28
46
  }
29
47
 
48
+ /** A `Card`-shaped placeholder: the flat card fill and radius. */
30
49
  export function CardSkeleton() {
50
+ const scheme = useScheme();
31
51
  return (
32
- <View className="rounded-lg bg-surface p-5 gap-3">
52
+ <View className="gap-3 p-5" style={cardStyle(scheme)}>
33
53
  <Skeleton className="h-4 w-32" />
34
54
  <Skeleton className="h-16 w-full" />
35
55
  </View>
@@ -0,0 +1,92 @@
1
+ import {
2
+ ActivityIndicator,
3
+ Platform,
4
+ View,
5
+ type ActivityIndicatorProps,
6
+ type ViewProps,
7
+ } from "react-native";
8
+ import { cn } from "./cn";
9
+ import { tokens } from "./tokens";
10
+ import { useScheme } from "./useScheme";
11
+
12
+ export type SpinnerProps = Omit<ActivityIndicatorProps, "size" | "color"> & {
13
+ /** `sm` is 16, the web `sm` and the `Button` loading ring. `md` is the small system spinner (20), `lg` the large one. A number sets the size on Android; iOS picks small below 30. Default `"md"`. */
14
+ size?: "sm" | "md" | "lg" | number;
15
+ /** `"muted"` (default) uses the muted text colour, `"inherit"` the foreground. Native has no `currentColor`. */
16
+ tone?: "muted" | "inherit";
17
+ /** Overrides `tone`. */
18
+ color?: string;
19
+ /** Hide from assistive tech, for example inside a labelled button. Default `false`. */
20
+ decorative?: boolean;
21
+ };
22
+
23
+ /** The system `"small"` spinner box on iOS, Android and React Native Web. */
24
+ const SYSTEM_SMALL = 20;
25
+ /** Web `size-4`. No platform has a 16 spinner, so `sm` is the small one scaled down in a 16 box. */
26
+ const SM = 16;
27
+
28
+ function systemSize(size: NonNullable<SpinnerProps["size"]>) {
29
+ if (typeof size === "number") {
30
+ return Platform.OS === "android" ? size : size >= 30 ? "large" : "small";
31
+ }
32
+ return size === "lg" ? "large" : "small";
33
+ }
34
+
35
+ /**
36
+ * The platform `ActivityIndicator` with the web `Spinner` size and tone names.
37
+ * The web `mark`, `circle` and `ring` variants (animated SVG strokes) are not ported.
38
+ */
39
+ export function Spinner({
40
+ size = "md",
41
+ tone = "muted",
42
+ color,
43
+ decorative = false,
44
+ accessibilityLabel,
45
+ style,
46
+ ...props
47
+ }: SpinnerProps) {
48
+ const scheme = useScheme();
49
+ const small = size === "sm";
50
+ const indicator = (
51
+ <ActivityIndicator
52
+ size={systemSize(size)}
53
+ color={
54
+ color ??
55
+ (tone === "muted"
56
+ ? tokens.color.muted[scheme]
57
+ : tokens.color.foreground[scheme])
58
+ }
59
+ accessibilityElementsHidden={decorative}
60
+ importantForAccessibility={decorative ? "no-hide-descendants" : "auto"}
61
+ accessibilityRole={decorative ? undefined : "progressbar"}
62
+ accessibilityLabel={decorative ? undefined : (accessibilityLabel ?? "Loading")}
63
+ style={small ? { transform: [{ scale: SM / SYSTEM_SMALL }] } : style}
64
+ {...props}
65
+ />
66
+ );
67
+ if (!small) return indicator;
68
+ return (
69
+ <View
70
+ style={[{ width: SM, height: SM, alignItems: "center", justifyContent: "center" }, style]}
71
+ >
72
+ {indicator}
73
+ </View>
74
+ );
75
+ }
76
+
77
+ /** A `Spinner` centred in whatever box it is given. `label` names it for assistive tech. */
78
+ export function CenteredSpinner({
79
+ label,
80
+ className,
81
+ ...props
82
+ }: ViewProps & { label: string; className?: string }) {
83
+ return (
84
+ <View
85
+ accessibilityState={{ busy: true }}
86
+ className={cn("min-h-0 w-full flex-1 items-center justify-center p-6", className)}
87
+ {...props}
88
+ >
89
+ <Spinner accessibilityLabel={label} />
90
+ </View>
91
+ );
92
+ }
@@ -0,0 +1,66 @@
1
+ import { View, type ViewProps } from "react-native";
2
+ import { cn } from "./cn";
3
+ import { palette, withAlpha } from "./palette";
4
+ import { tokens } from "./tokens";
5
+ import { useScheme } from "./useScheme";
6
+
7
+ /** The web `StatusTone`s. */
8
+ export type StatusTone =
9
+ | "progress"
10
+ | "business-review"
11
+ | "code-review"
12
+ | "done"
13
+ | "cancelled"
14
+ | "active"
15
+ | "critical"
16
+ | "neutral";
17
+
18
+ export type StatusDotProps = ViewProps & {
19
+ tone: StatusTone;
20
+ /** `"sm"` (6) or `"md"` (8). Default `"sm"`. */
21
+ size?: "sm" | "md";
22
+ className?: string;
23
+ };
24
+
25
+ function toneColor(tone: StatusTone, scheme: "light" | "dark") {
26
+ switch (tone) {
27
+ case "progress":
28
+ return palette.warning[scheme];
29
+ case "business-review":
30
+ return palette.primary[scheme];
31
+ case "code-review":
32
+ return withAlpha(palette.primary[scheme], 0.7);
33
+ case "done":
34
+ case "active":
35
+ return palette.success[scheme];
36
+ case "cancelled":
37
+ return withAlpha(tokens.color.muted[scheme], 0.4);
38
+ case "critical":
39
+ return palette.danger[scheme];
40
+ default:
41
+ return withAlpha(tokens.color.muted[scheme], 0.6);
42
+ }
43
+ }
44
+
45
+ /** Small coloured dot that carries a status, so the label beside it stays neutral text. Hidden from assistive tech. */
46
+ export function StatusDot({ tone, size = "sm", className, style, ...props }: StatusDotProps) {
47
+ const scheme = useScheme();
48
+ const diameter = size === "md" ? 8 : 6;
49
+ return (
50
+ <View
51
+ accessibilityElementsHidden
52
+ importantForAccessibility="no-hide-descendants"
53
+ className={cn("shrink-0", className)}
54
+ style={[
55
+ {
56
+ width: diameter,
57
+ height: diameter,
58
+ borderRadius: diameter / 2,
59
+ backgroundColor: toneColor(tone, scheme),
60
+ },
61
+ style,
62
+ ]}
63
+ {...props}
64
+ />
65
+ );
66
+ }
package/src/Switch.tsx CHANGED
@@ -1,6 +1,9 @@
1
1
  import * as React from "react";
2
2
  import { Switch as PrimitiveSwitch } from "./primitives/switch";
3
3
  import { cn } from "./cn";
4
+ import { switchColors, switchSize, touchSlop } from "./controlStyles";
5
+ import { useLargerText } from "./theme";
6
+ import { useScheme } from "./useScheme";
4
7
 
5
8
  type SwitchProps = Omit<
6
9
  React.ComponentPropsWithoutRef<typeof PrimitiveSwitch>,
@@ -10,13 +13,19 @@ type SwitchProps = Omit<
10
13
  checked?: boolean;
11
14
  onValueChange?: (value: boolean) => void;
12
15
  onCheckedChange?: (value: boolean) => void;
16
+ /** Track fill when on or off, in place of the `palette` colours. */
17
+ onColor?: string;
18
+ offColor?: string;
13
19
  };
14
20
 
15
21
  const Switch = React.forwardRef<
16
22
  React.ElementRef<typeof PrimitiveSwitch>,
17
23
  SwitchProps
18
- >(({ value, checked, onValueChange, onCheckedChange, ...props }, ref) => {
24
+ >(({ value, checked, onValueChange, onCheckedChange, onColor, offColor, ...props }, ref) => {
19
25
  const resolvedValue = checked ?? value ?? false;
26
+ const colors = switchColors(resolvedValue, useScheme());
27
+ const trackColor = resolvedValue ? onColor : offColor;
28
+ const size = switchSize(useLargerText());
20
29
 
21
30
  const handleCheckedChange = (nextValue: boolean) => {
22
31
  onCheckedChange?.(nextValue);
@@ -28,13 +37,21 @@ const Switch = React.forwardRef<
28
37
  ref={ref}
29
38
  checked={resolvedValue}
30
39
  onCheckedChange={handleCheckedChange}
31
- hitSlop={12}
32
- className="h-8 w-14"
33
- thumbClassName={cn(
34
- "h-7 w-7",
35
- resolvedValue ? "translate-x-6" : "translate-x-0",
36
- )}
40
+ // The web size on screen, a 44 target to touch.
41
+ hitSlop={touchSlop(size.width, size.height)}
42
+ accessibilityRole="switch"
43
+ className={cn(size.track, "justify-center")}
44
+ trackStyle={
45
+ trackColor ? { backgroundColor: trackColor, borderColor: trackColor } : colors.track
46
+ }
47
+ thumbStyle={colors.thumb}
48
+ thumbClassName={cn(size.thumb, resolvedValue ? size.on : "translate-x-0")}
37
49
  {...props}
50
+ accessibilityState={{
51
+ ...props.accessibilityState,
52
+ checked: resolvedValue,
53
+ disabled: Boolean(props.disabled),
54
+ }}
38
55
  />
39
56
  );
40
57
  });
package/src/Tabs.tsx ADDED
@@ -0,0 +1,190 @@
1
+ import { createContext, useContext, type ReactNode } from "react";
2
+ import { Pressable, Text, View, type ViewProps } from "react-native";
3
+ import { cn } from "./cn";
4
+ import {
5
+ MIN_TOUCH_TARGET,
6
+ tabColors,
7
+ tabTrackStyle,
8
+ tabTriggerSize,
9
+ touchSlop,
10
+ typeStyle,
11
+ type TabsSize,
12
+ type TabsVariant,
13
+ } from "./controlStyles";
14
+ import { selectionHaptic } from "./haptics";
15
+ import { useControllableState } from "./primitives/use-controllable-state";
16
+ import { useLargerText } from "./theme";
17
+ import { useScheme } from "./useScheme";
18
+
19
+ /** `pills` (default): separate pills, the `PillTabs` look. `segmented`: one track with the active segment raised. `soft`: wrapping pills, the active one filled. `pills` and `segmented` are as wide as their tabs, at the start, as on the web. */
20
+ export type TabsListVariant = TabsVariant;
21
+
22
+ const TabsContext = createContext<{
23
+ value: string;
24
+ setValue: (value: string) => void;
25
+ } | null>(null);
26
+
27
+ const TabsListContext = createContext<{
28
+ variant: TabsListVariant;
29
+ size: TabsSize;
30
+ }>({ variant: "pills", size: "default" });
31
+
32
+ function useTabs(part: string) {
33
+ const context = useContext(TabsContext);
34
+ if (!context) throw new Error(`${part} must be inside Tabs.`);
35
+ return context;
36
+ }
37
+
38
+ export type TabsProps = ViewProps & {
39
+ /** Active tab when the parent controls it. */
40
+ value?: string;
41
+ /** Initial tab when `value` is not set. */
42
+ defaultValue?: string;
43
+ onValueChange?: (value: string) => void;
44
+ className?: string;
45
+ };
46
+
47
+ /** Composable tabs with the web part names (`Tabs`, `TabsList`, `TabsTrigger`, `TabsContent`). For a flat options array use `PillTabs`. */
48
+ export function Tabs({
49
+ value,
50
+ defaultValue = "",
51
+ onValueChange,
52
+ className,
53
+ ...props
54
+ }: TabsProps) {
55
+ const [current, setValue] = useControllableState({
56
+ value,
57
+ defaultValue,
58
+ onChange: onValueChange,
59
+ });
60
+ return (
61
+ <TabsContext.Provider value={{ value: current, setValue }}>
62
+ <View className={cn("gap-3", className)} {...props} />
63
+ </TabsContext.Provider>
64
+ );
65
+ }
66
+
67
+ export type TabsListProps = ViewProps & {
68
+ variant?: TabsListVariant;
69
+ /** `sm` makes the triggers smaller. */
70
+ size?: TabsSize;
71
+ className?: string;
72
+ };
73
+
74
+ export function TabsList({
75
+ variant = "pills",
76
+ size = "default",
77
+ className,
78
+ style,
79
+ ...props
80
+ }: TabsListProps) {
81
+ const scheme = useScheme();
82
+ return (
83
+ <TabsListContext.Provider value={{ variant, size }}>
84
+ <View
85
+ accessibilityRole="tablist"
86
+ className={cn(
87
+ "flex-row",
88
+ // The web lists: `segmented` and `pills` are `inline-flex` (as wide
89
+ // as their tabs), `soft` a full-width row of centred, wrapping pills.
90
+ // Pills wrap rather than run off a phone screen.
91
+ variant === "segmented"
92
+ ? "max-w-full gap-[3px] self-start rounded-full p-[3px]"
93
+ : variant === "soft"
94
+ ? "flex-wrap items-center justify-center gap-1.5"
95
+ : "max-w-full flex-wrap gap-1.5 self-start",
96
+ className,
97
+ )}
98
+ style={variant === "segmented" ? [tabTrackStyle(scheme), style] : style}
99
+ {...props}
100
+ />
101
+ </TabsListContext.Provider>
102
+ );
103
+ }
104
+
105
+ export type TabsTriggerProps = {
106
+ value: string;
107
+ children: ReactNode;
108
+ disabled?: boolean;
109
+ className?: string;
110
+ testID?: string;
111
+ accessibilityLabel?: string;
112
+ };
113
+
114
+ export function TabsTrigger({
115
+ value,
116
+ children,
117
+ disabled,
118
+ className,
119
+ testID,
120
+ accessibilityLabel,
121
+ }: TabsTriggerProps) {
122
+ const { value: current, setValue } = useTabs("TabsTrigger");
123
+ const { variant, size } = useContext(TabsListContext);
124
+ const largerText = useLargerText();
125
+ const scheme = useScheme();
126
+ const selected = current === value;
127
+ // Fill and label are styles from `tabColors`: apps' `bg-secondary` and
128
+ // `bg-surface` can stay light in dark mode, under a light label.
129
+ const colors = tabColors(variant, selected, scheme);
130
+ const trigger = tabTriggerSize(variant, size, largerText);
131
+ // The web `.t-tab` is `font-medium`; the soft trigger is regular.
132
+ const medium = variant !== "soft";
133
+
134
+ return (
135
+ <Pressable
136
+ testID={testID}
137
+ accessibilityRole="tab"
138
+ accessibilityLabel={
139
+ accessibilityLabel ?? (typeof children === "string" ? children : undefined)
140
+ }
141
+ accessibilityState={{ selected, disabled: Boolean(disabled) }}
142
+ // The web height (30) on screen, a 44 target to touch.
143
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, trigger.minHeight)}
144
+ disabled={disabled}
145
+ onPress={() => {
146
+ if (selected) return;
147
+ selectionHaptic();
148
+ setValue(value);
149
+ }}
150
+ className={cn(
151
+ "shrink items-center justify-center rounded-full",
152
+ trigger.className,
153
+ variant === "pills" && "border border-white/40 dark:border-white/10",
154
+ disabled ? "opacity-50" : "active:opacity-70",
155
+ className,
156
+ )}
157
+ // A plain object: NativeWind puts the class styles and `style` in an
158
+ // array, where a `({ pressed }) => ...` style is dropped.
159
+ style={{ backgroundColor: colors.backgroundColor, minHeight: trigger.minHeight }}
160
+ >
161
+ {typeof children === "string" || typeof children === "number" ? (
162
+ <Text
163
+ numberOfLines={1}
164
+ className={cn(
165
+ "text-center",
166
+ medium ? "font-instrument-sans-medium" : "font-instrument-sans",
167
+ trigger.textClassName,
168
+ )}
169
+ style={[{ color: colors.color }, medium ? typeStyle("medium") : null]}
170
+ >
171
+ {children}
172
+ </Text>
173
+ ) : (
174
+ children
175
+ )}
176
+ </Pressable>
177
+ );
178
+ }
179
+
180
+ export type TabsContentProps = ViewProps & {
181
+ value: string;
182
+ className?: string;
183
+ };
184
+
185
+ /** Renders only while its tab is active. */
186
+ export function TabsContent({ value, className, ...props }: TabsContentProps) {
187
+ const { value: current } = useTabs("TabsContent");
188
+ if (current !== value) return null;
189
+ return <View className={className} {...props} />;
190
+ }