@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
package/src/index.ts CHANGED
@@ -8,6 +8,9 @@ export {
8
8
  DEFAULT_ACCENT,
9
9
  } from "./theme";
10
10
  export type { NativeUiTheme } from "./theme";
11
+ export { palette, withAlpha } from "./palette";
12
+ export type { Palette } from "./palette";
13
+ export { fontClass } from "./typography";
11
14
  export {
12
15
  FLOATING_INSET,
13
16
  FLOATING_TAB_BAR_HEIGHT,
@@ -29,30 +32,126 @@ export {
29
32
  GlassBlurProvider,
30
33
  GlassBlurTarget,
31
34
  } from "./LiquidGlass";
35
+ export type { GlassBlurTargetProps } from "./LiquidGlass";
32
36
  export { AmbientBackground } from "./AmbientBackground";
33
37
  export { FloatingTabBar } from "./FloatingTabBar";
38
+ export type { FloatingTabBarProps } from "./FloatingTabBar";
34
39
  export { FloatingFAB } from "./FloatingFAB";
35
40
  export { Button } from "./Button";
36
- export type { ButtonProps } from "./Button";
41
+ export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button";
37
42
  export { Input } from "./Input";
38
43
  export type { InputProps } from "./Input";
44
+ export { SearchInput } from "./SearchInput";
45
+ export type { SearchInputProps } from "./SearchInput";
46
+ export { Textarea } from "./Textarea";
47
+ export type { TextareaProps } from "./Textarea";
48
+ export { Label } from "./Label";
49
+ export type { LabelProps } from "./Label";
39
50
  export { Select } from "./Select";
40
51
  export type { SelectOption, SelectProps } from "./Select";
41
52
  export { Switch } from "./Switch";
42
53
  export type { SwitchProps } from "./Switch";
54
+ export { LabeledSwitchRow } from "./LabeledSwitchRow";
55
+ export type { LabeledSwitchRowProps } from "./LabeledSwitchRow";
56
+ export { Checkbox } from "./Checkbox";
57
+ export type { CheckboxProps } from "./Checkbox";
58
+ export { Badge } from "./Badge";
59
+ export type { BadgeProps, BadgeVariant } from "./Badge";
60
+ export { StatusDot } from "./StatusDot";
61
+ export type { StatusDotProps, StatusTone } from "./StatusDot";
62
+ export { Separator } from "./Separator";
63
+ export type { SeparatorProps } from "./Separator";
64
+ export { Progress } from "./Progress";
65
+ export type { ProgressProps } from "./Progress";
66
+ export { Spinner, CenteredSpinner } from "./Spinner";
67
+ export type { SpinnerProps } from "./Spinner";
68
+ export { Avatar, AvatarImage, AvatarFallback } from "./Avatar";
69
+ export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from "./Avatar";
70
+ export { initials } from "./initials";
43
71
  export { Chip } from "./Chip";
44
72
  export { PillTabs } from "./PillTabs";
73
+ export { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs";
74
+ export type {
75
+ TabsProps,
76
+ TabsListProps,
77
+ TabsListVariant,
78
+ TabsTriggerProps,
79
+ TabsContentProps,
80
+ } from "./Tabs";
45
81
  export { ListRow } from "./ListRow";
46
82
  export { HeaderIconButton } from "./HeaderIconButton";
47
- export { Card } from "./Card";
83
+ export {
84
+ Card,
85
+ CardHeader,
86
+ CardTitle,
87
+ CardDescription,
88
+ CardContent,
89
+ CardFooter,
90
+ } from "./Card";
48
91
  export { Skeleton, CardSkeleton } from "./Skeleton";
49
92
  export { EmptyState } from "./EmptyState";
93
+ export type { EmptyStateVariant } from "./EmptyState";
94
+ export {
95
+ Dialog,
96
+ DialogTrigger,
97
+ DialogClose,
98
+ DialogContent,
99
+ DialogHeader,
100
+ DialogTitle,
101
+ DialogDescription,
102
+ DialogBody,
103
+ DialogFooter,
104
+ } from "./Dialog";
105
+ export type { DialogProps, DialogContentProps } from "./Dialog";
106
+ export {
107
+ AlertDialog,
108
+ AlertDialogTrigger,
109
+ AlertDialogContent,
110
+ AlertDialogHeader,
111
+ AlertDialogTitle,
112
+ AlertDialogDescription,
113
+ AlertDialogFooter,
114
+ AlertDialogAction,
115
+ AlertDialogCancel,
116
+ } from "./AlertDialog";
117
+ export type { AlertDialogProps, AlertDialogContentProps } from "./AlertDialog";
118
+ export { ConfirmDialog } from "./ConfirmDialog";
119
+ export type { ConfirmDialogProps } from "./ConfirmDialog";
120
+ export {
121
+ Sheet,
122
+ SheetTrigger,
123
+ SheetClose,
124
+ SheetContent,
125
+ SheetHandle,
126
+ SheetHeader,
127
+ SheetTitle,
128
+ SheetDescription,
129
+ SheetBody,
130
+ SheetFooter,
131
+ } from "./Sheet";
132
+ export type { SheetProps, SheetContentProps } from "./Sheet";
133
+ export { Toaster } from "./Toast";
134
+ export { toast } from "./toastStore";
135
+ export type { ToastOptions, ToastVariant } from "./toastStore";
50
136
  export { DateTimeField } from "./DateTimeField";
51
137
  export { BottomSheetContainer } from "./BottomSheetContainer";
52
138
  export { SafeAreaContainer } from "./SafeAreaContainer";
53
139
  export { ScreenContainer } from "./ScreenContainer";
140
+ export { PageTitle, PageHeader } from "./PageTitle";
141
+ export type { PageTitleProps, PageHeaderProps } from "./PageTitle";
54
142
  export { SettingsSection } from "./settings/SettingsSection";
55
143
  export { SettingsField } from "./settings/SettingsField";
56
144
  export { SettingsToggleRow } from "./settings/SettingsToggleRow";
57
145
  export { SettingsStack } from "./settings/SettingsStack";
58
146
  export { PINK_50, PINK_100, PINK_600, PINK_700, NEUTRAL_100, NEUTRAL_500, NEUTRAL_900, Colors } from "./colors";
147
+ // Family app marks and their pure data (copied from `@vvedantb/ui/brand`).
148
+ export * from "./brand";
149
+ export { UpgradeToProCard } from "./marketing/UpgradeToProCard";
150
+ export type { UpgradeToProCardProps } from "./marketing/UpgradeToProCard";
151
+ export { PricingModal } from "./marketing/PricingModal";
152
+ export type { PricingModalProps } from "./marketing/PricingModal";
153
+ export type {
154
+ PricingModalPeriod,
155
+ PricingPlan,
156
+ PricingPlanBenefit,
157
+ } from "./marketing/pricing";
@@ -0,0 +1,10 @@
1
+ /** Up to `max` uppercase initials from a name: "Ada Lovelace" → "AL", " cher " → "C". Empty for a blank name. */
2
+ export function initials(name: string, max = 2): string {
3
+ return name
4
+ .trim()
5
+ .split(/\s+/)
6
+ .filter(Boolean)
7
+ .slice(0, max)
8
+ .map((word) => Array.from(word)[0]!.toUpperCase())
9
+ .join("");
10
+ }
@@ -0,0 +1,410 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+ import {
3
+ Modal,
4
+ Pressable,
5
+ ScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ } from "react-native";
10
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
11
+ import { IconCheck, IconX } from "@tabler/icons-react-native";
12
+ import { APP_ICONS, IconProjectV } from "../brand/AppIcons";
13
+ import type { ProductAppName } from "../brand/AppWordmark";
14
+ import { familyApp } from "../brand/familyApps";
15
+ import type { AppName } from "../brand/svg";
16
+ import { Button } from "../Button";
17
+ import { cn } from "../cn";
18
+ import { typeStyle } from "../controlStyles";
19
+ import { palette, withAlpha } from "../palette";
20
+ import { tokens } from "../tokens";
21
+ import { useLargerText } from "../theme";
22
+ import { useReduceMotion } from "../useReduceMotion";
23
+ import { useScheme } from "../useScheme";
24
+ import { useControllableState } from "../primitives/use-controllable-state";
25
+ import {
26
+ isEmphasised,
27
+ planAppBenefits,
28
+ planButton,
29
+ sortAppBenefits,
30
+ type PricingModalPeriod,
31
+ type PricingPlan,
32
+ } from "./pricing";
33
+
34
+ export type PricingModalProps = {
35
+ open: boolean;
36
+ /** Called by the close button and Android back (and an iOS swipe-down). */
37
+ onClose: () => void;
38
+ /** Accessible title, read first. Not visible. */
39
+ title: string;
40
+ /** Accessible description. Not visible. */
41
+ description?: string;
42
+ /** Cards, top to bottom. */
43
+ plans: readonly PricingPlan[];
44
+ /** App marks at the top left. */
45
+ apps?: readonly AppName[];
46
+ /** App the modal is shown in. Its `appBenefits` group comes first. */
47
+ currentApp?: ProductAppName;
48
+ /** Centred headline, for example "One plan. Every V app." */
49
+ headline?: ReactNode;
50
+ /** Small print under the cards. */
51
+ finePrint?: ReactNode;
52
+ billingPeriod?: PricingModalPeriod;
53
+ onBillingPeriodChange?: (period: PricingModalPeriod) => void;
54
+ /** Default `"month"`. */
55
+ defaultBillingPeriod?: PricingModalPeriod;
56
+ /** Called by a plan's button. Not called for the `current` plan. Route it to the store's purchase flow. */
57
+ onCheckout?: (plan: PricingPlan, period: PricingModalPeriod) => void;
58
+ /** Wraps each plan's button (the web `renderCheckout`). The button already calls `onCheckout`. Not called for the `current` plan. */
59
+ renderCheckout?: (
60
+ button: ReactElement,
61
+ plan: PricingPlan,
62
+ period: PricingModalPeriod,
63
+ ) => ReactNode;
64
+ /** Called when an app mark is pressed. Without it the marks are not pressable. */
65
+ onAppPress?: (app: AppName, url: string) => void;
66
+ testID?: string;
67
+ };
68
+
69
+ /**
70
+ * Upgrade sheet, the native counterpart of the web `PricingModal` multi-plan
71
+ * layout: a page sheet on iOS (full screen on Android) with a Monthly / Annual
72
+ * switch and stacked plan cards. The web single-plan layout (logo URLs and a
73
+ * Michroma wordmark) is not ported: pass one plan instead.
74
+ */
75
+ export function PricingModal({
76
+ open,
77
+ onClose,
78
+ title,
79
+ description,
80
+ plans,
81
+ apps,
82
+ currentApp,
83
+ headline,
84
+ finePrint,
85
+ billingPeriod,
86
+ onBillingPeriodChange,
87
+ defaultBillingPeriod = "month",
88
+ onCheckout,
89
+ renderCheckout,
90
+ onAppPress,
91
+ testID,
92
+ }: PricingModalProps) {
93
+ const scheme = useScheme();
94
+ const largerText = useLargerText();
95
+ const insets = useSafeAreaInsets();
96
+ const reduceMotion = useReduceMotion();
97
+ const [period, setPeriod] = useControllableState({
98
+ value: billingPeriod,
99
+ defaultValue: defaultBillingPeriod,
100
+ onChange: onBillingPeriodChange,
101
+ });
102
+ const fg = tokens.color.foreground[scheme];
103
+ const muted = tokens.color.muted[scheme];
104
+
105
+ return (
106
+ <Modal
107
+ visible={open}
108
+ animationType={reduceMotion ? "none" : "slide"}
109
+ presentationStyle="pageSheet"
110
+ onRequestClose={onClose}
111
+ testID={testID}
112
+ >
113
+ <View
114
+ className="flex-1"
115
+ style={{
116
+ backgroundColor: tokens.color.background[scheme],
117
+ paddingBottom: insets.bottom,
118
+ }}
119
+ >
120
+ {/* The web `sr-only` DialogTitle: a visually hidden header. */}
121
+ <View
122
+ accessible
123
+ accessibilityRole="header"
124
+ accessibilityLabel={title}
125
+ accessibilityHint={description}
126
+ style={styles.srOnly}
127
+ />
128
+ <View className="flex-row items-center justify-between gap-3 px-4 pb-3 pt-4">
129
+ <View className="flex-row items-center gap-3">
130
+ {apps?.map((app) => {
131
+ const Icon = APP_ICONS[app];
132
+ const { name, url } = familyApp(app);
133
+ return onAppPress ? (
134
+ <Pressable
135
+ key={app}
136
+ accessibilityRole="link"
137
+ accessibilityLabel={name}
138
+ hitSlop={8}
139
+ onPress={() => onAppPress(app, url)}
140
+ className="active:opacity-70"
141
+ >
142
+ <Icon size={22} color={fg} />
143
+ </Pressable>
144
+ ) : (
145
+ <Icon key={app} size={22} color={fg} title={name} />
146
+ );
147
+ })}
148
+ </View>
149
+ <View className="flex-row items-center gap-2">
150
+ <View
151
+ accessibilityLabel="Billing period"
152
+ className="flex-row rounded-full p-0.5"
153
+ style={{ backgroundColor: palette.surfaceTertiary[scheme] }}
154
+ >
155
+ {(["month", "year"] as const).map((value) => {
156
+ const selected = period === value;
157
+ return (
158
+ <Pressable
159
+ key={value}
160
+ testID={testID ? `${testID}-period-${value}` : undefined}
161
+ accessibilityRole="button"
162
+ accessibilityState={{ selected }}
163
+ hitSlop={6}
164
+ onPress={() => setPeriod(value)}
165
+ className="rounded-full px-3 py-1.5"
166
+ style={selected ? { backgroundColor: palette.segment[scheme] } : null}
167
+ >
168
+ <Text
169
+ className={cn(
170
+ "font-instrument-sans-medium",
171
+ largerText ? "text-base" : "text-sm",
172
+ )}
173
+ style={[
174
+ { color: selected ? palette.segmentForeground[scheme] : muted },
175
+ typeStyle("medium"),
176
+ ]}
177
+ >
178
+ {value === "month" ? "Monthly" : "Annual"}
179
+ </Text>
180
+ </Pressable>
181
+ );
182
+ })}
183
+ </View>
184
+ <Pressable
185
+ testID={testID ? `${testID}-close` : undefined}
186
+ accessibilityRole="button"
187
+ accessibilityLabel="Close"
188
+ hitSlop={8}
189
+ onPress={onClose}
190
+ className="h-10 w-10 items-center justify-center rounded-full active:opacity-70"
191
+ >
192
+ <IconX size={22} color={muted} />
193
+ </Pressable>
194
+ </View>
195
+ </View>
196
+
197
+ <ScrollView
198
+ className="flex-1"
199
+ contentContainerClassName="gap-3 px-4 pb-6"
200
+ showsVerticalScrollIndicator={false}
201
+ >
202
+ {headline ? (
203
+ <Text
204
+ className={cn(
205
+ "pb-2 text-center font-instrument-sans-medium",
206
+ largerText ? "text-3xl" : "text-2xl",
207
+ )}
208
+ style={[{ color: fg }, typeStyle("medium")]}
209
+ >
210
+ {headline}
211
+ </Text>
212
+ ) : null}
213
+
214
+ {plans.map((plan) => (
215
+ <PlanCard
216
+ key={plan.id}
217
+ plan={plan}
218
+ period={period}
219
+ currentApp={currentApp}
220
+ onCheckout={onCheckout}
221
+ renderCheckout={renderCheckout}
222
+ />
223
+ ))}
224
+
225
+ {finePrint ? (
226
+ <Text className="px-1 pt-1 font-instrument-sans text-sm" style={{ color: muted }}>
227
+ {finePrint}
228
+ </Text>
229
+ ) : null}
230
+ </ScrollView>
231
+ </View>
232
+ </Modal>
233
+ );
234
+ }
235
+
236
+ function PlanCard({
237
+ plan,
238
+ period,
239
+ currentApp,
240
+ onCheckout,
241
+ renderCheckout,
242
+ }: {
243
+ plan: PricingPlan;
244
+ period: PricingModalPeriod;
245
+ currentApp?: ProductAppName;
246
+ onCheckout: PricingModalProps["onCheckout"];
247
+ renderCheckout: PricingModalProps["renderCheckout"];
248
+ }) {
249
+ const scheme = useScheme();
250
+ const largerText = useLargerText();
251
+ const fg = tokens.color.foreground[scheme];
252
+ const muted = tokens.color.muted[scheme];
253
+ const separator = tokens.color.separator[scheme];
254
+ const emphasised = isEmphasised(plan);
255
+ const appBenefits = planAppBenefits(plan);
256
+ const cta = planButton(plan);
257
+ const bodyText = largerText ? "text-lg" : "text-base";
258
+
259
+ const button = (
260
+ <Button
261
+ variant={cta.variant}
262
+ disabled={cta.disabled}
263
+ shadowType="flat"
264
+ onPress={() => onCheckout?.(plan, period)}
265
+ >
266
+ {cta.label}
267
+ </Button>
268
+ );
269
+
270
+ return (
271
+ <View
272
+ className="p-5"
273
+ style={{
274
+ borderRadius: tokens.radius.card,
275
+ borderWidth: 1,
276
+ borderColor: emphasised ? withAlpha(fg, 0.25) : separator,
277
+ backgroundColor: tokens.color.surfaceSecondary[scheme],
278
+ }}
279
+ >
280
+ <View className="flex-row items-center gap-2">
281
+ {emphasised ? <IconProjectV size={16} color={fg} /> : null}
282
+ <Text
283
+ accessibilityRole="header"
284
+ className={cn("font-instrument-sans-medium", bodyText)}
285
+ style={[{ color: fg }, typeStyle("medium")]}
286
+ >
287
+ {plan.name}
288
+ </Text>
289
+ </View>
290
+
291
+ <View className="mt-4 flex-row items-baseline gap-1">
292
+ <Text
293
+ className="font-instrument-sans-medium"
294
+ style={[
295
+ {
296
+ color: fg,
297
+ fontSize: 48,
298
+ lineHeight: 52,
299
+ letterSpacing: -0.5,
300
+ fontVariant: ["tabular-nums"],
301
+ },
302
+ typeStyle("medium"),
303
+ ]}
304
+ >
305
+ {plan.price[period]}
306
+ </Text>
307
+ <Text className={cn("font-instrument-sans", bodyText)} style={{ color: muted }}>
308
+ {plan.periodSuffix ?? "/month"}
309
+ </Text>
310
+ </View>
311
+
312
+ <View className="mt-5">
313
+ {plan.current || !renderCheckout ? button : renderCheckout(button, plan, period)}
314
+ </View>
315
+
316
+ {plan.intro ? (
317
+ <Text className={cn("mt-6 font-instrument-sans", bodyText)} style={{ color: muted }}>
318
+ {plan.intro}
319
+ </Text>
320
+ ) : null}
321
+
322
+ {plan.highlight ? (
323
+ <View className="mt-3">
324
+ <Text
325
+ className="font-instrument-sans-medium text-2xl"
326
+ style={[{ color: fg }, typeStyle("medium")]}
327
+ >
328
+ {plan.highlight.title}
329
+ </Text>
330
+ <Text className={cn("font-instrument-sans", bodyText)} style={{ color: fg }}>
331
+ {plan.highlight.description}
332
+ </Text>
333
+ </View>
334
+ ) : null}
335
+
336
+ {plan.benefits?.length ? (
337
+ <View
338
+ className="mt-5 gap-2.5 pt-4"
339
+ style={{ borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: separator }}
340
+ >
341
+ {plan.benefits.map((benefit, index) => {
342
+ const BenefitIcon =
343
+ benefit.icon && benefit.icon !== "check" ? APP_ICONS[benefit.icon] : null;
344
+ return (
345
+ <View key={index} className="flex-row items-start gap-2.5">
346
+ <View className="mt-1 h-4 w-5 items-center justify-center">
347
+ {BenefitIcon ? (
348
+ <BenefitIcon size={14} color={benefit.muted ? muted : fg} />
349
+ ) : (
350
+ <IconCheck size={16} strokeWidth={2} color={benefit.muted ? muted : fg} />
351
+ )}
352
+ </View>
353
+ <Text
354
+ className={cn("min-w-0 flex-1 font-instrument-sans", bodyText)}
355
+ style={{ color: benefit.muted ? muted : fg }}
356
+ >
357
+ {benefit.label}
358
+ </Text>
359
+ </View>
360
+ );
361
+ })}
362
+ </View>
363
+ ) : null}
364
+
365
+ {appBenefits?.length ? (
366
+ <View
367
+ className="mt-4 gap-3 pt-3"
368
+ style={{ borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: separator }}
369
+ >
370
+ {sortAppBenefits(appBenefits, currentApp).map((group) => {
371
+ const GroupIcon = APP_ICONS[group.app];
372
+ return (
373
+ <View key={group.app}>
374
+ <View className="flex-row items-center gap-2">
375
+ <GroupIcon size={14} color={fg} />
376
+ <Text
377
+ className="font-instrument-sans-medium text-sm"
378
+ style={[{ color: fg }, typeStyle("medium")]}
379
+ >
380
+ {familyApp(group.app).name}
381
+ </Text>
382
+ </View>
383
+ <View className="mt-1 gap-0.5 pl-[22px]">
384
+ {group.items.map((item, index) => (
385
+ <Text
386
+ key={index}
387
+ className={cn("font-instrument-sans", bodyText)}
388
+ style={{ color: fg }}
389
+ >
390
+ {item}
391
+ </Text>
392
+ ))}
393
+ </View>
394
+ </View>
395
+ );
396
+ })}
397
+ </View>
398
+ ) : null}
399
+ </View>
400
+ );
401
+ }
402
+
403
+ const styles = StyleSheet.create({
404
+ srOnly: {
405
+ position: "absolute",
406
+ width: 1,
407
+ height: 1,
408
+ opacity: 0,
409
+ },
410
+ });
@@ -0,0 +1,103 @@
1
+ import { Pressable, Text, View } from "react-native";
2
+ import { cn } from "../cn";
3
+ import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
4
+ import { tokens } from "../tokens";
5
+ import { useLargerText } from "../theme";
6
+ import { useScheme } from "../useScheme";
7
+
8
+ /** Web `rounded-2xl` (16) on the card. */
9
+ const RADIUS = tokens.radius.lg;
10
+
11
+ // The web `Button` `sm` (`h-8 px-3 text-xs`) uses `max-sm:hit-target`, 8 on
12
+ // each side: 32 + 16 = 48, as the native `Button` `small`.
13
+ const HIT_SLOP = 8;
14
+
15
+ export type UpgradeToProCardProps = {
16
+ /** Opens the app's `PricingModal`. */
17
+ onUpgrade: () => void;
18
+ /**
19
+ * A "Pro" pill (labelled with `title`) instead of the full card. Like the
20
+ * web `Button` it stretches across a column and fits its label in a row.
21
+ * Default `false`.
22
+ */
23
+ collapsed?: boolean;
24
+ /** Default "Upgrade to Pro". */
25
+ title?: string;
26
+ /** Default "Upgrade". */
27
+ ctaLabel?: string;
28
+ className?: string;
29
+ testID?: string;
30
+ };
31
+
32
+ /**
33
+ * Upgrade prompt, the native `SidebarUpgradeCard`: the title and an "Upgrade"
34
+ * pill on a secondary surface, with no mark. It does not check the plan; the
35
+ * app hides it when the user is already on Pro.
36
+ */
37
+ export function UpgradeToProCard({
38
+ onUpgrade,
39
+ collapsed = false,
40
+ title = "Upgrade to Pro",
41
+ ctaLabel = "Upgrade",
42
+ className,
43
+ testID,
44
+ }: UpgradeToProCardProps) {
45
+ const scheme = useScheme();
46
+ const largerText = useLargerText();
47
+ // Each class is also set as a style (`sizeStyle`): web `text-sm` is 14 / 20.
48
+ const titleSize = largerText ? "text-lg" : "text-sm";
49
+ const ctaSize = largerText ? "text-base" : "text-xs";
50
+ // The web pill is a `Button` `sm` (`font-medium`) in `bg-foreground text-background`.
51
+ const pill = (label: string, accessibilityLabel?: string) => (
52
+ <Pressable
53
+ testID={testID ? `${testID}-cta` : undefined}
54
+ accessibilityRole="button"
55
+ accessibilityLabel={accessibilityLabel}
56
+ onPress={onUpgrade}
57
+ hitSlop={HIT_SLOP}
58
+ className={cn(
59
+ "shrink-0 items-center justify-center rounded-full active:opacity-80",
60
+ largerText ? "min-h-12 px-4" : "min-h-8 px-3",
61
+ )}
62
+ style={{ backgroundColor: tokens.color.foreground[scheme] }}
63
+ >
64
+ <Text
65
+ className={cn("font-instrument-sans-medium", ctaSize)}
66
+ style={[
67
+ { color: tokens.color.background[scheme] },
68
+ sizeStyle(ctaSize),
69
+ typeStyle("medium"),
70
+ ]}
71
+ >
72
+ {label}
73
+ </Text>
74
+ </Pressable>
75
+ );
76
+
77
+ if (collapsed) {
78
+ return (
79
+ <View testID={testID} className={className}>
80
+ {pill("Pro", title)}
81
+ </View>
82
+ );
83
+ }
84
+ return (
85
+ <View
86
+ testID={testID}
87
+ className={cn(
88
+ "flex-row items-center justify-between gap-3 bg-surface-secondary px-3 py-2.5",
89
+ className,
90
+ )}
91
+ style={{ borderRadius: RADIUS }}
92
+ >
93
+ <Text
94
+ numberOfLines={1}
95
+ className={cn("min-w-0 flex-1 font-instrument-sans-semibold", titleSize)}
96
+ style={[textStyle("foreground", scheme), sizeStyle(titleSize), typeStyle("semibold")]}
97
+ >
98
+ {title}
99
+ </Text>
100
+ {pill(ctaLabel)}
101
+ </View>
102
+ );
103
+ }