@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
@@ -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
+ }
@@ -0,0 +1,76 @@
1
+ // Plan types and pure helpers for `PricingModal`, the same shapes and rules as
2
+ // the web `PricingModal` multi-plan layout. No React Native imports.
3
+ import type { ReactNode } from "react";
4
+ import type { AppName } from "../brand/svg";
5
+ import { V_PRO_APP_BENEFITS, type PricingPlanAppBenefit } from "../brand/vProAppBenefits";
6
+
7
+ export type PricingModalPeriod = "month" | "year";
8
+
9
+ export type PricingPlanBenefit = {
10
+ label: ReactNode;
11
+ /** `"check"` (default) or an app mark. */
12
+ icon?: "check" | AppName;
13
+ /** Dimmed row. */
14
+ muted?: boolean;
15
+ };
16
+
17
+ export type PricingPlan = {
18
+ id: string;
19
+ name: string;
20
+ /** Display strings per month, for example `{ month: "$10", year: "$8" }`. The yearly price is still per month. */
21
+ price: { month: string; year: string };
22
+ /** Default "Get started". */
23
+ ctaLabel?: string;
24
+ /** Line above the highlight, for example "Everything in Free, and:". */
25
+ intro?: ReactNode;
26
+ /** Large title with small text under it. */
27
+ highlight?: { title: string; description: string };
28
+ benefits?: readonly PricingPlanBenefit[];
29
+ /** Grouped per app, after `benefits`. The featured plan defaults to `V_PRO_APP_BENEFITS`. */
30
+ appBenefits?: readonly PricingPlanAppBenefit[];
31
+ /** The plan the user is on: the button reads "Current plan" and is disabled. */
32
+ current?: boolean;
33
+ /** Subtle ring on the card. `highlighted` is an alias. */
34
+ featured?: boolean;
35
+ highlighted?: boolean;
36
+ /** Text after the price. Default "/month". */
37
+ periodSuffix?: string;
38
+ };
39
+
40
+ /** `featured`, else `highlighted` (the web rule: `featured ?? highlighted`). */
41
+ export function isEmphasised(plan: PricingPlan): boolean {
42
+ return Boolean(plan.featured ?? plan.highlighted);
43
+ }
44
+
45
+ /** The plan's app groups: its own, else `V_PRO_APP_BENEFITS` for the emphasised plan, else none. */
46
+ export function planAppBenefits(
47
+ plan: PricingPlan,
48
+ ): readonly PricingPlanAppBenefit[] | undefined {
49
+ return plan.appBenefits ?? (isEmphasised(plan) ? V_PRO_APP_BENEFITS : undefined);
50
+ }
51
+
52
+ /** Stable sort: the current app's group first, the rest in their order. */
53
+ export function sortAppBenefits(
54
+ groups: readonly PricingPlanAppBenefit[],
55
+ currentApp?: AppName,
56
+ ): PricingPlanAppBenefit[] {
57
+ return [...groups].sort(
58
+ (a, b) => Number(b.app === currentApp) - Number(a.app === currentApp),
59
+ );
60
+ }
61
+
62
+ /** Label, variant and state of a plan's button, as on the web. */
63
+ export function planButton(plan: PricingPlan): {
64
+ label: string;
65
+ variant: "default" | "secondary" | "outline";
66
+ disabled: boolean;
67
+ } {
68
+ if (plan.current) {
69
+ return { label: "Current plan", variant: "outline", disabled: true };
70
+ }
71
+ return {
72
+ label: plan.ctaLabel ?? "Get started",
73
+ variant: isEmphasised(plan) ? "default" : "secondary",
74
+ disabled: false,
75
+ };
76
+ }
package/src/palette.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Web semantic colours for React Native, converted from `@vvedantb/ui`
3
+ * `tokens.css` (oklch → sRGB hex, clipped to sRGB). `tokens.ts` holds the
4
+ * neutrals the native app ships; these are the colours it lacks (primary,
5
+ * danger, success, warning, overlay, segment, field placeholder, backdrop,
6
+ * dialog description).
7
+ *
8
+ * A test in this package converts `tokens.css` and fails when a value here
9
+ * drifts, so change `tokens.css` first and then this file.
10
+ */
11
+ export const palette = {
12
+ /** `--accent` (web `primary`): near-black in light, near-white in dark. */
13
+ primary: { light: "#181818", dark: "#fcfcfc" },
14
+ primaryForeground: { light: "#fcfcfc", dark: "#181818" },
15
+ danger: { light: "#ff373c", dark: "#db3b3e" },
16
+ dangerForeground: { light: "#fcfcfc", dark: "#fcfcfc" },
17
+ success: { light: "#15c964", dark: "#15c964" },
18
+ successForeground: { light: "#161917", dark: "#161917" },
19
+ warning: { light: "#f5a523", dark: "#f7b750" },
20
+ warningForeground: { light: "#1a1815", dark: "#1a1815" },
21
+ /** `--default` (web `secondary`): neutral control fill. */
22
+ default: { light: "#ebebeb", dark: "#272727" },
23
+ defaultForeground: { light: "#181818", dark: "#fcfcfc" },
24
+ overlay: { light: "#ffffff", dark: "#181818" },
25
+ overlayForeground: { light: "#181818", dark: "#fcfcfc" },
26
+ segment: { light: "#ffffff", dark: "#474747" },
27
+ segmentForeground: { light: "#181818", dark: "#fcfcfc" },
28
+ surfaceTertiary: { light: "#eaeaea", dark: "#282627" },
29
+ fieldPlaceholder: { light: "#727272", dark: "#a0a0a0" },
30
+ backdrop: { light: "rgba(0, 0, 0, 0.5)", dark: "rgba(0, 0, 0, 0.6)" },
31
+ /** Web `DialogDescription` text: `--overlay-foreground` mixed 72% into `--overlay` (oklch), for 4.5:1 on the overlay. */
32
+ overlayMuted: { light: "#505050", dark: "#b5b5b5" },
33
+ /** Fill of the web destructive `Button` (`oklch(47.2% 0.195 25.7)`), the same in both schemes. */
34
+ destructive: "#af0015",
35
+ } as const;
36
+
37
+ export type Palette = typeof palette;
38
+
39
+ /** `#rrggbb` with an alpha, as `rgba(...)`. For tints such as the badge fills (web `bg-danger/12`). */
40
+ export function withAlpha(hex: string, alpha: number): string {
41
+ const n = Number.parseInt(hex.slice(1, 7), 16);
42
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
43
+ }
@@ -1,16 +1,24 @@
1
1
  import * as React from "react";
2
2
  import { Platform, TextInput } from "react-native";
3
3
  import { cn } from "../cn";
4
+ import { palette } from "../palette";
5
+ import { useScheme } from "../useScheme";
4
6
 
5
7
  type InputProps = React.ComponentPropsWithoutRef<typeof TextInput> & {
6
8
  className?: string;
7
9
  };
8
10
 
9
11
  const Input = React.forwardRef<React.ElementRef<typeof TextInput>, InputProps>(
10
- ({ className, ...props }, ref) => {
12
+ ({ className, placeholderTextColor, ...props }, ref) => {
13
+ const scheme = useScheme();
11
14
  return (
12
15
  <TextInput
13
16
  ref={ref}
17
+ // A prop, not a `placeholder:` class: on native NativeWind turns that
18
+ // class into `placeholderTextColor`, which beats the prop. The old
19
+ // `placeholder:text-muted-foreground/50` was the light grey at half
20
+ // opacity in apps that nest `dark` under `muted-foreground`.
21
+ placeholderTextColor={placeholderTextColor ?? palette.fieldPlaceholder[scheme]}
14
22
  className={cn(
15
23
  "flex w-full flex-row items-center rounded-lg border-0 bg-secondary px-3 py-2 text-base text-foreground",
16
24
  props.editable === false &&
@@ -22,7 +30,6 @@ const Input = React.forwardRef<React.ElementRef<typeof TextInput>, InputProps>(
22
30
  ),
23
31
  Platform.select({
24
32
  web: "outline-none transition-[color,box-shadow] md:text-sm",
25
- native: "placeholder:text-muted-foreground/50",
26
33
  }),
27
34
  className,
28
35
  )}