@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,163 @@
1
+ import * as React from "react";
2
+ import { Pressable, useWindowDimensions, View } from "react-native";
3
+ import { IconSearch, IconX } from "@tabler/icons-react-native";
4
+ import { Input as PrimitiveInput } from "./primitives/input";
5
+ import { cn } from "./cn";
6
+ import {
7
+ fieldStyle,
8
+ fieldTextStyle,
9
+ scaledFieldHeight,
10
+ searchInputSize,
11
+ sizeStyle,
12
+ touchSlop,
13
+ typeStyle,
14
+ type SearchInputSize,
15
+ } from "./controlStyles";
16
+ import { palette } from "./palette";
17
+ import { tokens } from "./tokens";
18
+ import { useLargerText } from "./theme";
19
+ import { useScheme } from "./useScheme";
20
+
21
+ type SearchInputProps = Omit<
22
+ React.ComponentPropsWithoutRef<typeof PrimitiveInput>,
23
+ "value" | "onChange" | "onChangeText"
24
+ > & {
25
+ value: string;
26
+ /** Called with the new text, as the web `onChange(value)`. */
27
+ onChange: (value: string) => void;
28
+ /** Accessibility label, and the placeholder when `placeholder` is not set. Default `"Search"`. */
29
+ label?: string;
30
+ /** `sm` is 32 high (web `h-8`), `md` is 40 (web `h-10`), border included. Default `"sm"`. */
31
+ size?: SearchInputSize;
32
+ /** Called after `onChange` whenever the value becomes empty (typed away or cleared). */
33
+ onClear?: () => void;
34
+ /** Accessibility label of the clear button. Default `"Clear search"`. */
35
+ clearLabel?: string;
36
+ /** Classes for the field. */
37
+ className?: string;
38
+ /** Classes for the `View` around the field, icon and clear button. */
39
+ wrapperClassName?: string;
40
+ };
41
+
42
+ /** The web change rule: `onChange`, then `onClear` whenever the value becomes empty. */
43
+ export function searchChange(onChange: (value: string) => void, onClear?: () => void) {
44
+ return (next: string) => {
45
+ onChange(next);
46
+ if (onClear && next.length === 0) onClear();
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Search field, the web `SearchInput`: a 16 search icon at the start and,
52
+ * while there is text, a clear button (a 14 cross labelled "Clear search") at
53
+ * the end. Controlled with `value` and `onChange(text)`, as on the web. The
54
+ * field has the `Input` look (`fieldStyle`) at the web sizes from
55
+ * `searchInputSize`. The clear button is painted at the web 24 and reaches 44
56
+ * with its hit slop (`touchSlop`). It clears at once and leaves focus where it
57
+ * was, as the default web clear does after its dissolve. It is hidden when the
58
+ * field is not editable. Inside a `ScrollView`, set
59
+ * `keyboardShouldPersistTaps="handled"` so the first tap clears while the
60
+ * keyboard is up. The web `shortcut` hint and `toolbar` variant are not ported.
61
+ */
62
+ const SearchInput = React.forwardRef<
63
+ React.ElementRef<typeof PrimitiveInput>,
64
+ SearchInputProps
65
+ >(
66
+ (
67
+ {
68
+ value,
69
+ onChange,
70
+ label = "Search",
71
+ placeholder,
72
+ size = "sm",
73
+ onClear,
74
+ clearLabel = "Clear search",
75
+ className,
76
+ wrapperClassName,
77
+ placeholderTextColor,
78
+ style,
79
+ ...props
80
+ },
81
+ ref,
82
+ ) => {
83
+ const largerText = useLargerText();
84
+ const scheme = useScheme();
85
+ const { fontScale } = useWindowDimensions();
86
+ const metrics = searchInputSize(size, largerText);
87
+ const change = searchChange(onChange, onClear);
88
+ const filled = value.length > 0;
89
+ const muted = tokens.color.muted[scheme];
90
+ return (
91
+ <View className={wrapperClassName}>
92
+ <PrimitiveInput
93
+ ref={ref}
94
+ value={value}
95
+ onChangeText={change}
96
+ accessibilityLabel={label}
97
+ placeholder={placeholder ?? label}
98
+ placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
99
+ // The web `enterKeyHint="search"`.
100
+ returnKeyType="search"
101
+ className={cn(
102
+ "border font-instrument-sans",
103
+ metrics.text,
104
+ largerText && "font-instrument-sans-medium",
105
+ className,
106
+ )}
107
+ style={[
108
+ fieldStyle(scheme),
109
+ fieldTextStyle(scheme),
110
+ {
111
+ minHeight: metrics.minHeight,
112
+ // The web border-box height, so the class line height cannot
113
+ // grow the field; it still grows with the system font size.
114
+ height: scaledFieldHeight(metrics.height, fontScale),
115
+ paddingTop: metrics.paddingY,
116
+ paddingBottom: metrics.paddingY,
117
+ paddingLeft: metrics.paddingLeft,
118
+ paddingRight: filled ? metrics.filledPaddingRight : metrics.paddingRight,
119
+ // The size without its line height: on a single-line field a
120
+ // line height can push the text off centre on iOS.
121
+ fontSize: sizeStyle(metrics.text).fontSize,
122
+ },
123
+ largerText ? typeStyle("medium") : null,
124
+ style,
125
+ ]}
126
+ {...props}
127
+ />
128
+ <View
129
+ pointerEvents="none"
130
+ className="absolute bottom-0 top-0 justify-center"
131
+ style={{ left: metrics.iconLeft }}
132
+ >
133
+ <IconSearch size={metrics.icon} color={muted} />
134
+ </View>
135
+ {filled && props.editable !== false ? (
136
+ <View
137
+ pointerEvents="box-none"
138
+ className="absolute bottom-0 top-0 justify-center"
139
+ style={{ right: metrics.clearRight }}
140
+ >
141
+ <Pressable
142
+ testID="search-input-clear"
143
+ accessibilityRole="button"
144
+ accessibilityLabel={clearLabel}
145
+ hitSlop={touchSlop(metrics.clear)}
146
+ onPress={() => change("")}
147
+ className="items-center justify-center rounded-md active:opacity-70"
148
+ style={{ width: metrics.clear, height: metrics.clear }}
149
+ >
150
+ {/* `strokeWidth`, not the web `stroke`: here `stroke` is the colour. */}
151
+ <IconX size={metrics.clearIcon} color={muted} strokeWidth={1.75} />
152
+ </Pressable>
153
+ </View>
154
+ ) : null}
155
+ </View>
156
+ );
157
+ },
158
+ );
159
+
160
+ SearchInput.displayName = "SearchInput";
161
+
162
+ export { SearchInput };
163
+ export type { SearchInputProps };
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
  });