@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
package/src/Badge.tsx ADDED
@@ -0,0 +1,108 @@
1
+ import type { ReactNode } from "react";
2
+ import { Text, View, type ViewProps } from "react-native";
3
+ import { cn } from "./cn";
4
+ import { typeStyle } from "./controlStyles";
5
+ import { palette, withAlpha } from "./palette";
6
+ import { tokens } from "./tokens";
7
+ import { useLargerText } from "./theme";
8
+ import { useScheme } from "./useScheme";
9
+
10
+ /** The web `Badge` variants. */
11
+ export type BadgeVariant =
12
+ | "default"
13
+ | "secondary"
14
+ | "destructive"
15
+ | "destructive-soft"
16
+ | "outline"
17
+ | "success"
18
+ | "warning";
19
+
20
+ export type BadgeProps = ViewProps & {
21
+ variant?: BadgeVariant;
22
+ className?: string;
23
+ /** Extra classes for the label when `children` is a string or number. */
24
+ textClassName?: string;
25
+ children?: ReactNode;
26
+ };
27
+
28
+ type Scheme = "light" | "dark";
29
+
30
+ /** Border, fill and text colours per variant, from the web classes (`border-danger/25 bg-danger/12 text-danger` and so on). */
31
+ export function badgeColors(variant: BadgeVariant, scheme: Scheme) {
32
+ const tone = (hex: string, border: number, fill: number) => ({
33
+ borderColor: withAlpha(hex, border),
34
+ backgroundColor: withAlpha(hex, fill),
35
+ color: hex,
36
+ });
37
+ switch (variant) {
38
+ case "secondary":
39
+ return {
40
+ borderColor: tokens.color.separator[scheme],
41
+ backgroundColor: palette.default[scheme],
42
+ color: palette.defaultForeground[scheme],
43
+ };
44
+ case "destructive":
45
+ return tone(palette.danger[scheme], 0.25, 0.12);
46
+ case "destructive-soft":
47
+ return {
48
+ borderColor: tokens.color.border[scheme],
49
+ backgroundColor: withAlpha(palette.danger[scheme], 0.1),
50
+ color: palette.danger[scheme],
51
+ };
52
+ case "outline":
53
+ return {
54
+ borderColor: tokens.color.separator[scheme],
55
+ backgroundColor: "transparent",
56
+ color: tokens.color.muted[scheme],
57
+ };
58
+ case "success":
59
+ return tone(palette.success[scheme], 0.25, 0.12);
60
+ case "warning":
61
+ return tone(palette.warning[scheme], 0.25, 0.12);
62
+ default:
63
+ return {
64
+ borderColor: tokens.color.border[scheme],
65
+ backgroundColor: palette.default[scheme],
66
+ color: tokens.color.foreground[scheme],
67
+ };
68
+ }
69
+ }
70
+
71
+ /** Small rounded status label, with the web `Badge` variant names and colours. */
72
+ export function Badge({
73
+ variant = "default",
74
+ className,
75
+ textClassName,
76
+ children,
77
+ style,
78
+ ...props
79
+ }: BadgeProps) {
80
+ const scheme = useScheme();
81
+ const largerText = useLargerText();
82
+ const { color, ...frame } = badgeColors(variant, scheme);
83
+ return (
84
+ <View
85
+ className={cn(
86
+ "flex-row items-center gap-1 self-start rounded-full border px-2.5 py-1",
87
+ className,
88
+ )}
89
+ style={[frame, style]}
90
+ {...props}
91
+ >
92
+ {typeof children === "string" || typeof children === "number" ? (
93
+ <Text
94
+ className={cn(
95
+ "font-instrument-sans-medium",
96
+ largerText ? "text-base" : "text-sm",
97
+ textClassName,
98
+ )}
99
+ style={[{ color }, typeStyle("medium")]}
100
+ >
101
+ {children}
102
+ </Text>
103
+ ) : (
104
+ children
105
+ )}
106
+ </View>
107
+ );
108
+ }
@@ -7,6 +7,7 @@ import { View, Text, TouchableOpacity } from "react-native";
7
7
  import { useRef, useEffect, useCallback, JSX } from "react";
8
8
  import { useColorScheme } from "nativewind";
9
9
  import { Colors } from "./colors";
10
+ import { textStyle, typeStyle } from "./controlStyles";
10
11
  import { IconX } from "@tabler/icons-react-native";
11
12
  import { BottomSheetDefaultBackdropProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetBackdrop/types";
12
13
  import { GlassSheetBackground } from "./LiquidGlass";
@@ -66,7 +67,12 @@ export function BottomSheetContainer({
66
67
  <BottomSheetView className="p-5 pb-12">
67
68
  <View className="mb-4 flex-row items-center justify-between gap-3">
68
69
  <Text
69
- className={`${largerText ? "text-2xl" : "text-xl"} flex-1 font-instrument-sans-bold text-foreground`}
70
+ accessibilityRole="header"
71
+ className={`${largerText ? "text-2xl" : "text-xl"} flex-1 font-instrument-sans-semibold`}
72
+ style={[
73
+ textStyle("foreground", colorScheme === "dark" ? "dark" : "light"),
74
+ typeStyle("semibold"),
75
+ ]}
70
76
  >
71
77
  {title}
72
78
  </Text>
package/src/Button.tsx CHANGED
@@ -1,110 +1,211 @@
1
1
  import { MaterialIcons } from "@expo/vector-icons";
2
2
  import React from "react";
3
3
  import { PINK_600 } from "./colors";
4
- import { ActivityIndicator, View } from "react-native";
4
+ import { View } from "react-native";
5
5
  import { Button as PrimitiveButton } from "./primitives/button";
6
6
  import { Text as PrimitiveText } from "./primitives/text";
7
7
  import { cn } from "./cn";
8
+ import { buttonColors, sizeStyle, typeStyle, type ButtonVariant } from "./controlStyles";
9
+ import { useDialogLayout } from "./dialogLayout";
8
10
  import { floatingShadow } from "./floating";
11
+ import { Spinner } from "./Spinner";
9
12
  import { useLargerText } from "./theme";
13
+ import { tokens } from "./tokens";
14
+ import { useScheme } from "./useScheme";
15
+
16
+ /** `sm`, `default` and `lg` are the web names for `small`, `medium` and `large`. `icon` is a square button. */
17
+ type ButtonSize = "small" | "medium" | "large" | "sm" | "default" | "lg" | "icon";
10
18
 
11
19
  interface ButtonProps extends Omit<
12
20
  React.ComponentPropsWithoutRef<typeof PrimitiveButton>,
13
21
  "variant" | "size" | "children" | "onPress"
14
22
  > {
15
23
  onPress?: () => void;
16
- variant?: "primary" | "secondary" | "outline";
24
+ variant?: ButtonVariant;
17
25
  icon?: keyof typeof MaterialIcons.glyphMap;
18
26
  leftIcon?: React.ReactNode;
27
+ /** Web name for `leftIcon`. `leftIcon` wins when both are set. */
28
+ startContent?: React.ReactNode;
29
+ /** Rendered after the label. */
30
+ endContent?: React.ReactNode;
19
31
  loading?: boolean;
20
- size?: "small" | "medium" | "large";
32
+ size?: ButtonSize;
33
+ /**
34
+ * Default `true`. In the stacked footer of a compact dialog the button fills
35
+ * the width whatever this says, as on the web.
36
+ */
21
37
  fullWidth?: boolean;
22
- children: React.ReactNode;
38
+ /** Label, or other content. Give a button with no text child an `accessibilityLabel`. */
39
+ children?: React.ReactNode;
40
+ /** `"shadow"` adds the floating shadow to `primary` and `default`. Default `"flat"`: the web button has none. */
23
41
  shadowType?: "flat" | "shadow";
24
42
  className?: string;
25
43
  }
26
44
 
45
+ const SIZE_ALIAS = {
46
+ small: "small",
47
+ medium: "medium",
48
+ large: "large",
49
+ sm: "small",
50
+ default: "medium",
51
+ lg: "large",
52
+ icon: "icon",
53
+ } as const;
54
+
55
+ // `regular` is the web `Button` (`sm` h-8 px-3, `default` h-10 px-4, `lg`
56
+ // h-11 px-6, `icon` h-10 w-10). `larger` is the larger-text step-up from
57
+ // 0.3.2. Labelled sizes are minimum heights (`h-auto` drops the primitive's
58
+ // `h-10`), so a large Dynamic Type size grows the button instead of clipping
59
+ // the label.
27
60
  const HEIGHT = {
28
- regular: { small: "h-12 px-5", medium: "h-14 px-6", large: "h-16 px-6" },
29
- larger: { small: "h-14 px-5", medium: "h-16 px-6", large: "h-[72px] px-6" },
61
+ regular: {
62
+ small: "h-auto min-h-8 px-3",
63
+ medium: "h-auto min-h-10 px-4",
64
+ large: "h-auto min-h-11 px-6",
65
+ icon: "h-10 w-10 px-0",
66
+ },
67
+ larger: {
68
+ small: "h-auto min-h-14 px-5",
69
+ medium: "h-auto min-h-16 px-6",
70
+ large: "h-auto min-h-[72px] px-6",
71
+ icon: "h-14 w-14 px-0",
72
+ },
30
73
  };
31
74
 
75
+ // Each class is also set as a style (`sizeStyle`): web `text-sm` is 14 / 20.
32
76
  const TEXT = {
33
- regular: { small: "text-sm", medium: "text-lg", large: "text-xl" },
34
- larger: { small: "text-base", medium: "text-xl", large: "text-2xl" },
77
+ regular: { small: "text-xs", medium: "text-sm", large: "text-sm", icon: "text-sm" },
78
+ larger: { small: "text-base", medium: "text-xl", large: "text-2xl", icon: "text-base" },
79
+ } as const;
80
+
81
+ // The web sizes are under the 44 pt touch target; the slop brings each to 48.
82
+ const HIT_SLOP = {
83
+ regular: { small: 8, medium: 4, large: 2, icon: 4 },
84
+ larger: { small: 8, medium: undefined, large: undefined, icon: 8 },
35
85
  };
36
86
 
37
- export type { ButtonProps };
87
+ /** Web `rounded-lg` (`--radius`, 16). A style, because apps' NativeWind themes set their own `rounded-lg`. */
88
+ const RADIUS = tokens.radius.lg;
89
+
90
+ // Colours come from `buttonColors` as styles, except `tinted`, which keeps
91
+ // the pink classes that `outline` used before 0.3.0.
92
+ const VARIANT_CLASS: Partial<Record<ButtonVariant, string>> = {
93
+ link: "h-auto border-0 px-0",
94
+ tinted: "border-0 bg-pink-50 dark:bg-pink-900/20",
95
+ };
96
+
97
+ const TEXT_CLASS: Partial<Record<ButtonVariant, string>> = {
98
+ link: "underline",
99
+ tinted: "text-pink-700 dark:text-pink-300",
100
+ };
101
+
102
+ const PRIMITIVE_VARIANT = {
103
+ primary: "default",
104
+ default: "default",
105
+ outline: "outline",
106
+ secondary: "secondary",
107
+ destructive: "destructive",
108
+ ghost: "ghost",
109
+ link: "link",
110
+ tinted: "secondary",
111
+ } as const;
112
+
113
+ export type { ButtonProps, ButtonSize, ButtonVariant };
38
114
 
39
115
  export function Button({
40
116
  onPress,
41
117
  variant = "primary",
42
118
  icon,
43
119
  leftIcon,
120
+ startContent,
121
+ endContent,
44
122
  loading,
45
123
  size = "medium",
46
124
  fullWidth = true,
47
125
  children,
48
- shadowType = "shadow",
126
+ shadowType = "flat",
49
127
  className,
50
128
  ...props
51
129
  }: ButtonProps) {
52
130
  const scale = useLargerText() ? "larger" : "regular";
53
- const isTinted = variant === "outline" || variant === "secondary";
54
- const contentColor = isTinted ? PINK_600 : "#fff";
55
- const elevate = shadowType === "shadow" && variant === "primary";
56
-
57
- const variantClassName =
58
- variant === "outline"
59
- ? "border-0 bg-pink-50 dark:bg-pink-900/20"
60
- : variant === "secondary"
61
- ? "bg-neutral-100 dark:bg-neutral-800"
62
- : "";
131
+ const scheme = useScheme();
132
+ const dialog = useDialogLayout();
133
+ const resolvedSize = SIZE_ALIAS[size];
134
+ const colors = buttonColors(variant, scheme);
135
+ const contentColor = colors?.color ?? PINK_600;
136
+ const elevate =
137
+ shadowType === "shadow" && (variant === "primary" || variant === "default");
138
+ const baseStyle = [
139
+ { borderRadius: RADIUS },
140
+ elevate ? floatingShadow : null,
141
+ colors
142
+ ? { backgroundColor: colors.backgroundColor, borderColor: colors.borderColor }
143
+ : null,
144
+ ];
145
+ const callerStyle = props.style;
146
+ // As on the web, `loading` swaps the start content for the 16 ring (`h-4
147
+ // w-4`, Spinner `sm`) and keeps the label.
148
+ const start = loading ? (
149
+ <Spinner size="sm" color={contentColor} decorative />
150
+ ) : (
151
+ leftIcon ??
152
+ startContent ??
153
+ (icon ? (
154
+ <MaterialIcons name={icon} size={scale === "larger" ? 26 : 16} color={contentColor} />
155
+ ) : null)
156
+ );
63
157
 
64
158
  return (
65
159
  <PrimitiveButton
66
160
  onPress={onPress}
67
- variant={variant === "primary" ? "default" : "secondary"}
68
- hitSlop={size === "small" ? 8 : undefined}
161
+ variant={PRIMITIVE_VARIANT[variant]}
162
+ hitSlop={HIT_SLOP[scale][resolvedSize]}
163
+ accessibilityRole="button"
69
164
  className={cn(
70
- "relative rounded-full border border-white/40 dark:border-white/10",
71
- HEIGHT[scale][size],
72
- fullWidth && "w-full",
73
- variantClassName,
165
+ "relative border",
166
+ HEIGHT[scale][resolvedSize],
167
+ // Column flex children stretch by default (React Native Web too), so
168
+ // a button that is not full width must opt out with `self-start`.
169
+ resolvedSize !== "icon" && (fullWidth || dialog.stacked ? "w-full" : "self-start"),
170
+ // The web compact dialog: labelled buttons at least 44 high (`min-h-11`).
171
+ dialog.compact && scale === "regular" && resolvedSize !== "icon" && "min-h-11",
172
+ VARIANT_CLASS[variant],
74
173
  className,
75
174
  )}
76
175
  {...props}
77
176
  disabled={loading || props.disabled}
78
- style={elevate ? floatingShadow : props.style}
177
+ accessibilityState={{
178
+ ...props.accessibilityState,
179
+ disabled: Boolean(loading || props.disabled),
180
+ busy: Boolean(loading),
181
+ }}
182
+ style={
183
+ typeof callerStyle === "function"
184
+ ? (state) => [baseStyle, callerStyle(state)]
185
+ : [baseStyle, callerStyle]
186
+ }
79
187
  >
80
188
  <View className="flex-row items-center justify-center gap-2">
81
- {loading ? (
82
- <ActivityIndicator size="small" color={contentColor} />
83
- ) : (
84
- <>
85
- {leftIcon ??
86
- (icon ? (
87
- <MaterialIcons
88
- name={icon}
89
- size={scale === "larger" ? 26 : 22}
90
- color={contentColor}
91
- />
92
- ) : null)}
93
- {typeof children === "string" || typeof children === "number" ? (
94
- <PrimitiveText
95
- className={cn(
96
- TEXT[scale][size],
97
- "font-instrument-sans-semibold",
98
- isTinted ? "text-pink-700 dark:text-pink-300" : "text-white",
99
- )}
100
- >
101
- {children}
102
- </PrimitiveText>
103
- ) : (
104
- children
189
+ {start}
190
+ {typeof children === "string" || typeof children === "number" ? (
191
+ <PrimitiveText
192
+ className={cn(
193
+ TEXT[scale][resolvedSize],
194
+ "font-instrument-sans-medium",
195
+ TEXT_CLASS[variant],
105
196
  )}
106
- </>
197
+ style={[
198
+ colors ? { color: colors.color } : null,
199
+ sizeStyle(TEXT[scale][resolvedSize]),
200
+ typeStyle("medium"),
201
+ ]}
202
+ >
203
+ {children}
204
+ </PrimitiveText>
205
+ ) : (
206
+ children
107
207
  )}
208
+ {endContent}
108
209
  </View>
109
210
  </PrimitiveButton>
110
211
  );
package/src/Card.tsx CHANGED
@@ -1,64 +1,139 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
  import type { IconProps } from "@tabler/icons-react-native";
3
- import { View } from "react-native";
3
+ import { Text, View, type TextProps, type ViewProps } from "react-native";
4
4
  import {
5
5
  Card as PrimitiveCard,
6
- CardContent,
7
- CardHeader,
8
- CardTitle,
6
+ CardContent as PrimitiveCardContent,
7
+ CardHeader as PrimitiveCardHeader,
8
+ CardTitle as PrimitiveCardTitle,
9
9
  } from "./primitives/card";
10
- import { FloatingSurface } from "./FloatingSurface";
11
10
  import { cn } from "./cn";
11
+ import { cardStyle, sizeStyle, textStyle, typeStyle } from "./controlStyles";
12
12
  import { useAccentColor, useLargerText } from "./theme";
13
+ import { useScheme } from "./useScheme";
13
14
 
14
15
  export function Card({
15
16
  children,
16
17
  title,
18
+ description,
17
19
  action,
18
20
  leftIcon: Icon,
21
+ className,
19
22
  testID,
20
23
  }: {
21
24
  children: ReactNode;
22
25
  title?: string;
26
+ /** Muted line under the title (the web `CardDescription`). */
27
+ description?: string;
23
28
  action?: ReactNode;
24
29
  leftIcon?: ComponentType<IconProps>;
30
+ /** Classes on the card surface. */
31
+ className?: string;
25
32
  testID?: string;
26
33
  }) {
27
34
  const largerText = useLargerText();
28
35
  const accentColor = useAccentColor();
36
+ const scheme = useScheme();
29
37
  const hasHeader = Boolean(title || action);
38
+ const titleSize = largerText ? "text-2xl" : "text-base";
39
+ // The web card: a flat `surfaceCard` fill at radius 16, with no border, no
40
+ // shadow and no blur. Dialogs, sheets and toasts float, as on the web.
30
41
  return (
31
- <FloatingSurface>
32
- <PrimitiveCard
33
- testID={testID}
34
- className="gap-4 rounded-none border-0 bg-transparent py-4"
35
- >
36
- {hasHeader && (
37
- <CardHeader className="px-4 py-0">
38
- <View className="flex-row items-center justify-between">
39
- <View className="flex-row items-center gap-2">
40
- {Icon && (
41
- <Icon size={largerText ? 28 : 24} color={accentColor} />
42
- )}
43
- {title ? (
44
- <CardTitle
45
- className={cn(
46
- "font-instrument-sans-bold text-foreground",
47
- largerText ? "text-2xl" : "text-xl",
48
- )}
49
- >
50
- {title}
51
- </CardTitle>
52
- ) : null}
53
- </View>
54
- {action}
42
+ <PrimitiveCard
43
+ testID={testID}
44
+ className={cn("gap-4 overflow-hidden border-0 py-4", className)}
45
+ style={cardStyle(scheme)}
46
+ >
47
+ {hasHeader && (
48
+ <PrimitiveCardHeader className="px-4 py-0">
49
+ <View className="flex-row items-center justify-between">
50
+ <View className="flex-row items-center gap-2">
51
+ {Icon && (
52
+ <Icon size={largerText ? 28 : 24} color={accentColor} />
53
+ )}
54
+ {title ? (
55
+ <PrimitiveCardTitle
56
+ className={cn("font-instrument-sans-semibold leading-tight", titleSize)}
57
+ style={[
58
+ textStyle("foreground", scheme),
59
+ sizeStyle(titleSize, "tight"),
60
+ typeStyle("semibold"),
61
+ ]}
62
+ >
63
+ {title}
64
+ </PrimitiveCardTitle>
65
+ ) : null}
55
66
  </View>
56
- </CardHeader>
57
- )}
58
- <CardContent className={cn(hasHeader && "pt-0", "px-4")}>
59
- {children}
60
- </CardContent>
61
- </PrimitiveCard>
62
- </FloatingSurface>
67
+ {action}
68
+ </View>
69
+ {description ? <CardDescription>{description}</CardDescription> : null}
70
+ </PrimitiveCardHeader>
71
+ )}
72
+ <PrimitiveCardContent className={cn(hasHeader && "pt-0", "px-4")}>
73
+ {children}
74
+ </PrimitiveCardContent>
75
+ </PrimitiveCard>
76
+ );
77
+ }
78
+
79
+ // Parts for composing a card body with the web part names. They sit inside
80
+ // `Card`'s padded content, so they add no side padding of their own. Text
81
+ // colours are styles that follow the colour scheme; recolour with `style`.
82
+
83
+ type PartProps = ViewProps & { className?: string };
84
+ type TextPartProps = TextProps & { className?: string };
85
+
86
+ export function CardHeader({ className, ...props }: PartProps) {
87
+ return <View className={cn("gap-1 pb-3", className)} {...props} />;
88
+ }
89
+
90
+ /**
91
+ * Web `font-semibold leading-none` at the inherited 16. The line height is
92
+ * `leading-tight` (20), not 16: below the face's ascent and descent, iOS and
93
+ * Android can clip the glyphs.
94
+ */
95
+ export function CardTitle({ className, style, ...props }: TextPartProps) {
96
+ const largerText = useLargerText();
97
+ const scheme = useScheme();
98
+ const size = largerText ? "text-2xl" : "text-base";
99
+ return (
100
+ <Text
101
+ accessibilityRole="header"
102
+ className={cn("font-instrument-sans-semibold leading-tight", size, className)}
103
+ style={[
104
+ textStyle("foreground", scheme),
105
+ sizeStyle(size, "tight"),
106
+ typeStyle("semibold"),
107
+ style,
108
+ ]}
109
+ {...props}
110
+ />
111
+ );
112
+ }
113
+
114
+ /** Web `text-sm` (14 / 20) in the muted colour. */
115
+ export function CardDescription({ className, style, ...props }: TextPartProps) {
116
+ const largerText = useLargerText();
117
+ const scheme = useScheme();
118
+ const size = largerText ? "text-lg" : "text-sm";
119
+ return (
120
+ <Text
121
+ className={cn("font-instrument-sans", size, className)}
122
+ style={[textStyle("muted", scheme), sizeStyle(size), style]}
123
+ {...props}
124
+ />
125
+ );
126
+ }
127
+
128
+ export function CardContent({ className, ...props }: PartProps) {
129
+ return <View className={cn("gap-3", className)} {...props} />;
130
+ }
131
+
132
+ export function CardFooter({ className, ...props }: PartProps) {
133
+ return (
134
+ <View
135
+ className={cn("flex-row items-center justify-end gap-2 pt-4", className)}
136
+ {...props}
137
+ />
63
138
  );
64
139
  }
@@ -0,0 +1,78 @@
1
+ import type { ReactNode } from "react";
2
+ import { Pressable, type PressableProps } from "react-native";
3
+ import { IconCheck, IconMinus } from "@tabler/icons-react-native";
4
+ import { cn } from "./cn";
5
+ import { checkboxColors, touchSlop } from "./controlStyles";
6
+ import { selectionHaptic } from "./haptics";
7
+ import { useLargerText } from "./theme";
8
+ import { useScheme } from "./useScheme";
9
+
10
+ export type CheckboxProps = Omit<PressableProps, "onPress" | "children"> & {
11
+ /** `"indeterminate"` shows a dash; a press then checks it. */
12
+ checked: boolean | "indeterminate";
13
+ onCheckedChange?: (checked: boolean) => void;
14
+ /** Shown while checked. Default a check icon (a dash when indeterminate). */
15
+ icon?: ReactNode;
16
+ className?: string;
17
+ };
18
+
19
+ /**
20
+ * Controlled checkbox with a Pressable and a Tabler check, as the web
21
+ * `Checkbox` (`checked` / `onCheckedChange`). Colours from `checkboxColors`:
22
+ * the palette primary when checked, as on the web (not the theme accent).
23
+ * 16 square with a 14 check, as on the web (24 and 18 with larger text), plus
24
+ * a hit slop (`touchSlop`) that makes the target 44.
25
+ */
26
+ export function Checkbox({
27
+ checked,
28
+ onCheckedChange,
29
+ icon,
30
+ disabled,
31
+ className,
32
+ style,
33
+ ...props
34
+ }: CheckboxProps) {
35
+ const scheme = useScheme();
36
+ const largerText = useLargerText();
37
+ const on = checked !== false;
38
+ const { color, ...fill } = checkboxColors(on, scheme);
39
+ const boxStyle = { borderRadius: largerText ? 6 : 4, ...fill };
40
+ const box = largerText ? 24 : 16;
41
+ const iconSize = largerText ? 18 : 14;
42
+ return (
43
+ <Pressable
44
+ accessibilityRole="checkbox"
45
+ accessibilityState={{
46
+ checked: checked === "indeterminate" ? "mixed" : checked,
47
+ disabled: Boolean(disabled),
48
+ }}
49
+ hitSlop={touchSlop(box)}
50
+ disabled={disabled}
51
+ onPress={() => {
52
+ selectionHaptic();
53
+ onCheckedChange?.(checked !== true);
54
+ }}
55
+ className={cn(
56
+ "items-center justify-center border active:opacity-70",
57
+ largerText ? "h-6 w-6" : "h-4 w-4",
58
+ disabled && "opacity-50",
59
+ className,
60
+ )}
61
+ style={
62
+ typeof style === "function"
63
+ ? (state) => [boxStyle, style(state)]
64
+ : [boxStyle, style]
65
+ }
66
+ {...props}
67
+ >
68
+ {on
69
+ ? (icon ??
70
+ (checked === "indeterminate" ? (
71
+ <IconMinus size={iconSize} color={color} strokeWidth={2} />
72
+ ) : (
73
+ <IconCheck size={iconSize} color={color} strokeWidth={2} />
74
+ )))
75
+ : null}
76
+ </Pressable>
77
+ );
78
+ }
package/src/Chip.tsx CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Pressable, Text } from "react-native";
2
+ import { textStyle, typeStyle } from "./controlStyles";
2
3
  import { useLargerText } from "./theme";
4
+ import { useScheme } from "./useScheme";
3
5
 
4
6
  export function Chip({
5
7
  label,
@@ -15,6 +17,7 @@ export function Chip({
15
17
  round?: boolean;
16
18
  }) {
17
19
  const largerText = useLargerText();
20
+ const scheme = useScheme();
18
21
  const size = round
19
22
  ? largerText
20
23
  ? "h-14 w-14"
@@ -37,10 +40,9 @@ export function Chip({
37
40
  className={`font-instrument-sans-semibold ${
38
41
  largerText ? "text-xl" : "text-lg"
39
42
  } ${
40
- selected
41
- ? "text-pink-700 dark:text-pink-300"
42
- : "text-muted-foreground"
43
+ selected ? "text-pink-700 dark:text-pink-300" : ""
43
44
  }`}
45
+ style={[selected ? null : textStyle("muted", scheme), typeStyle("semibold")]}
44
46
  >
45
47
  {label}
46
48
  </Text>