@vvedantb/native-ui 0.2.0 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +160 -9
  2. package/package.json +88 -8
  3. package/src/AlertDialog.tsx +73 -0
  4. package/src/AmbientBackground.tsx +26 -11
  5. package/src/Avatar.tsx +141 -0
  6. package/src/Badge.tsx +108 -0
  7. package/src/BottomSheetContainer.tsx +7 -1
  8. package/src/Button.tsx +153 -52
  9. package/src/Card.tsx +111 -36
  10. package/src/Checkbox.tsx +78 -0
  11. package/src/Chip.tsx +5 -3
  12. package/src/ConfirmDialog.tsx +105 -0
  13. package/src/DateTimeField.tsx +6 -2
  14. package/src/Dialog.tsx +253 -0
  15. package/src/EmptyState.tsx +122 -7
  16. package/src/FloatingFAB.tsx +2 -4
  17. package/src/FloatingSurface.tsx +31 -9
  18. package/src/FloatingTabBar.tsx +39 -11
  19. package/src/Input.tsx +15 -4
  20. package/src/Label.tsx +31 -0
  21. package/src/LabeledSwitchRow.tsx +96 -0
  22. package/src/LiquidGlass.tsx +2 -10
  23. package/src/ListRow.tsx +7 -2
  24. package/src/PillTabs.tsx +28 -14
  25. package/src/Progress.tsx +67 -0
  26. package/src/SearchInput.tsx +163 -0
  27. package/src/Select.tsx +32 -6
  28. package/src/Separator.tsx +38 -0
  29. package/src/Sheet.tsx +107 -0
  30. package/src/Skeleton.tsx +25 -5
  31. package/src/Spinner.tsx +92 -0
  32. package/src/StatusDot.tsx +66 -0
  33. package/src/Switch.tsx +24 -7
  34. package/src/Tabs.tsx +190 -0
  35. package/src/Textarea.tsx +47 -0
  36. package/src/Toast.tsx +170 -0
  37. package/src/blur.ts +48 -0
  38. package/src/brand/AppIcons.tsx +106 -0
  39. package/src/brand/AppMark.tsx +83 -0
  40. package/src/brand/AppWordmark.tsx +97 -0
  41. package/src/brand/familyApps.ts +24 -0
  42. package/src/brand/index.ts +34 -0
  43. package/src/brand/svg.ts +263 -0
  44. package/src/brand/vProAppBenefits.ts +22 -0
  45. package/src/controlStyles.ts +360 -0
  46. package/src/dialogLayout.ts +20 -0
  47. package/src/entries/ambient.ts +3 -0
  48. package/src/entries/bottom-sheet.ts +2 -0
  49. package/src/entries/brand.ts +2 -0
  50. package/src/entries/button.ts +3 -0
  51. package/src/entries/date-time.ts +3 -0
  52. package/src/entries/floating.ts +24 -0
  53. package/src/entries/layout.ts +7 -0
  54. package/src/entries/marketing.ts +10 -0
  55. package/src/entries/primitives.ts +99 -0
  56. package/src/entries/search-input.ts +4 -0
  57. package/src/entries/tab-bar.ts +10 -0
  58. package/src/entries/theme.ts +15 -0
  59. package/src/entries/toast.ts +6 -0
  60. package/src/examples/ParityGallery.tsx +496 -0
  61. package/src/floating.ts +5 -1
  62. package/src/glassBlur.tsx +95 -0
  63. package/src/gradient.ts +21 -0
  64. package/src/haptics.ts +22 -2
  65. package/src/index.ts +99 -2
  66. package/src/initials.ts +10 -0
  67. package/src/marketing/PricingModal.tsx +410 -0
  68. package/src/marketing/UpgradeToProCard.tsx +103 -0
  69. package/src/marketing/pricing.ts +76 -0
  70. package/src/palette.ts +43 -0
  71. package/src/primitives/input.tsx +9 -2
  72. package/src/primitives/native-only-animated-view.tsx +10 -3
  73. package/src/primitives/overlay.tsx +179 -0
  74. package/src/primitives/select.tsx +25 -12
  75. package/src/primitives/switch.tsx +12 -13
  76. package/src/primitives/use-controllable-state.ts +24 -0
  77. package/src/settings/SettingsField.tsx +11 -2
  78. package/src/settings/SettingsSection.tsx +20 -6
  79. package/src/settings/SettingsToggleRow.tsx +7 -2
  80. package/src/tabBarRoutes.ts +52 -0
  81. package/src/toastChrome.ts +103 -0
  82. package/src/toastStore.ts +86 -0
  83. package/src/tokens.ts +19 -14
  84. package/src/typography.ts +24 -0
  85. package/src/useReduceMotion.ts +24 -0
  86. package/src/useScheme.ts +6 -0
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # `@vvedantb/native-ui`
2
2
 
3
- React Native + Expo primitives extracted from a React Native app. Floating pill chrome, Android-safe shadows, forms, list rows, and settings sections.
3
+ React Native + Expo primitives extracted from a React Native app. Floating pill chrome, Android-safe shadows, forms, overlays, toasts, list rows, settings sections and the family app marks.
4
4
 
5
- Web apps should keep using `@vvedantb/ui`. This package is native-only.
5
+ Web apps should keep using `@vvedantb/ui`. This package is native-only. Components that exist on both sides use the web names and, where they make sense on touch, the web props. `docs/native-ui-parity.md` at the repository root lists every `@vvedantb/ui` export and its native status.
6
6
 
7
7
  ## Install
8
8
 
@@ -14,7 +14,6 @@ Peers the app must already have (Expo SDK 57 / RN 0.76+):
14
14
 
15
15
  - `react`, `react-native`
16
16
  - `nativewind` `>=4`
17
- - `expo-blur`, `expo-haptics`, `expo-linear-gradient`
18
17
  - `react-native-reanimated`, `react-native-safe-area-context`, `react-native-screens`
19
18
  - `@rn-primitives/slot`, `@rn-primitives/switch`, `@rn-primitives/select`
20
19
  - `@expo/vector-icons`, `@tabler/icons-react-native`, `lucide-react-native`
@@ -22,9 +21,67 @@ Peers the app must already have (Expo SDK 57 / RN 0.76+):
22
21
 
23
22
  Optional, only if you import those components:
24
23
 
25
- - `expo-router` — `FloatingTabBar`
26
24
  - `@gorhom/bottom-sheet` — `BottomSheetContainer`
27
25
  - `@react-native-community/datetimepicker` and `dayjs` — `DateTimeField`
26
+ - `react-native-svg` — the app marks (`APP_ICONS`, `Icon*`, `AppMark`, `AppWordmark`) and `PricingModal`. Apps with the icon peers above already have it: `lucide-react-native` and `@tabler/icons-react-native` both require it.
27
+
28
+ Optional and loaded only when installed, so the package imports and runs without them:
29
+
30
+ - `expo-blur` — glass blur on `FloatingSurface` and the chrome built on it. Without it, those surfaces draw a solid fill.
31
+ - `expo-haptics` — the selection haptic on `FloatingTabBar`, `Tabs` and `Checkbox` presses. Without it, presses are silent.
32
+ - `expo-linear-gradient` — the gradient in `AmbientBackground`. Without it, the background is the solid `tokens.color.background`.
33
+
34
+ The package loads these three with a `require` inside `try`. Metro treats that as an optional dependency when `transformer.allowOptionalDependencies` is on, which is the default in the Expo and React Native Metro configs. `@vvedantb/native-ui/blur` exports the loader (`loadBlur`) and `@vvedantb/native-ui/haptics` exports `loadHaptics` and `selectionHaptic`. TypeScript still resolves their types: an app that type-checks without them reports a missing module in `src/blur.ts`, `src/haptics.ts` or `src/gradient.ts`. Declare the missing ones in a `.d.ts` file in the app:
35
+
36
+ ```ts
37
+ declare module "expo-blur";
38
+ declare module "expo-haptics";
39
+ declare module "expo-linear-gradient";
40
+ ```
41
+
42
+ `expo-router` stays an optional peer, but the package does not import it. `FloatingTabBar` declares the props it reads (`FloatingTabBarProps`) and fits the `tabBar` prop of Expo Router's `Tabs` on Expo SDK 57. Expo SDK 58 passes `emitter` and `navigateToTab` to `tabBar` in place of `navigation`, so the bar does not fit it yet.
43
+
44
+ To hide a tab, give its screen Expo Router's `href: null`. From 0.3.9 `FloatingTabBar` draws no tab for that route. It also hides a route whose `tabBarItemStyle` resolves to `display: "none"`, because Expo Router's `Tabs` can pass `href: null` on in that form. The other tabs keep their focus and navigation, as `state.index` still counts every route:
45
+
46
+ ```tsx
47
+ <Tabs tabBar={(props) => <FloatingTabBar {...props} />}>
48
+ <Tabs.Screen name="index" options={{ title: "Home" }} />
49
+ <Tabs.Screen name="details" options={{ href: null }} />
50
+ </Tabs>
51
+ ```
52
+
53
+ ## Entry points
54
+
55
+ The root entry exports everything. It still imports `BottomSheetContainer` and `DateTimeField`, so Metro resolves `@gorhom/bottom-sheet`, `@react-native-community/datetimepicker` and `dayjs` for any import from it. Apps without those peers, such as V Store, should import from the smaller entries below. Between them, they export every name the root exports.
56
+
57
+ | Entry | Exports | Optional peers it reaches |
58
+ | --- | --- | --- |
59
+ | `@vvedantb/native-ui/tab-bar` | `FloatingTabBar`, `FloatingTabBarProps`, `FLOATING_INSET`, `FLOATING_TAB_BAR_HEIGHT`, `FLOATING_TAB_BAR_HEIGHT_LARGE`, `tabBarReserve` | `expo-blur`, `expo-haptics` (loaders) |
60
+ | `@vvedantb/native-ui/brand` | `AppMark`, `AppWordmark`, `APP_ICONS`, `IconVibot` and the other `Icon*`, the SVG helpers (`APP_MARKS`, `appMarkSvg`, `appTileSvg` and the rest), `FAMILY_APPS`, `familyApp`, `V_PRO_APP_BENEFITS` | none |
61
+ | `@vvedantb/native-ui/button` | `Button`, `ButtonProps`, `ButtonSize`, `ButtonVariant` | none |
62
+ | `@vvedantb/native-ui/primitives` | `Button`, `Input`, `SearchInput`, `Textarea`, `Label`, `Select`, `Switch`, `LabeledSwitchRow`, `Checkbox`, `Card` and its parts, `Badge`, `StatusDot`, `Avatar`, `Separator`, `Progress`, `Spinner`, `Skeleton`, `EmptyState`, `ListRow`, `Chip`, `HeaderIconButton`, `Tabs`, `PillTabs`, `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet`, `toast`, `Toaster` | `expo-blur`, `expo-haptics` (loaders) |
63
+ | `@vvedantb/native-ui/search-input` | `SearchInput`, `SearchInputProps` | none |
64
+ | `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
65
+ | `@vvedantb/native-ui/theme` | `NativeUiProvider`, `useNativeUi`, `useLargerText`, `useAccentColor`, `DEFAULT_ACCENT`, `tokens`, `palette`, `withAlpha`, `fontClass`, `cn`, `Colors` and the colour constants | none |
66
+ | `@vvedantb/native-ui/floating` | `FloatingSurface`, `GlassSheetBackground`, `LiquidGlass`, `GlassBlurProvider`, `GlassBlurTarget`, `FloatingFAB`, `useReduceTransparency`, the floating metrics and styles | `expo-blur` (loader) |
67
+ | `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack` | `expo-blur` (loader) |
68
+ | `@vvedantb/native-ui/marketing` | `UpgradeToProCard`, `PricingModal` and the pricing types | none |
69
+ | `@vvedantb/native-ui/ambient` | `AmbientBackground` | `expo-linear-gradient` (loader) |
70
+ | `@vvedantb/native-ui/blur`, `/haptics` | the loaders | `expo-blur`, `expo-haptics` (loaders) |
71
+ | `@vvedantb/native-ui/bottom-sheet` | `BottomSheetContainer` | `@gorhom/bottom-sheet` (import), `expo-blur` (loader) |
72
+ | `@vvedantb/native-ui/date-time` | `DateTimeField` | `@react-native-community/datetimepicker`, `dayjs` (import) |
73
+
74
+ "Loader" means the `require` inside `try` described above, so the entry bundles and runs without that peer. `@vvedantb/native-ui/gallery` imports the root entry.
75
+
76
+ ```tsx
77
+ import { FloatingTabBar } from "@vvedantb/native-ui/tab-bar";
78
+ import { AppMark } from "@vvedantb/native-ui/brand";
79
+ import { NativeUiProvider } from "@vvedantb/native-ui/theme";
80
+ import { SearchInput } from "@vvedantb/native-ui/search-input";
81
+ import { toast, Toaster } from "@vvedantb/native-ui/toast";
82
+ ```
83
+
84
+ The entries re-export the same source files, so they can be mixed: `NativeUiProvider` from `/theme` sets the context that `FloatingTabBar` from `/tab-bar` reads. `tests/subpaths.test.ts` walks the imports from each entry and fails if one of the basic entries reaches a heavy peer other than through its loader.
28
85
 
29
86
  ## Expo / NativeWind
30
87
 
@@ -50,7 +107,9 @@ config.resolver.nodeModulesPaths = [
50
107
  ];
51
108
  ```
52
109
 
53
- The app must define NativeWind tokens (`bg-surface`, `bg-secondary`, `text-foreground`, pink utilities) and Instrument Sans font classes (`font-instrument-sans-*`). Accent pink stays in the app stylesheet.
110
+ The app must define NativeWind tokens (`bg-surface`, `bg-secondary`, pink utilities) and Instrument Sans font classes (`font-instrument-sans-*`). Accent pink stays in the app stylesheet. `Button`, `Input`, `Textarea`, the `Select` trigger, `Switch`, `Checkbox`, `Badge`, `Tabs` and `PillTabs` take their colours from `tokens` and `palette`, so they do not need `primary`, `input` or other theme keys. From 0.3.3 so does component text (titles, descriptions, labels, rows, toasts): it no longer uses `text-foreground` or `text-muted-foreground`, which stay light-mode colours in dark mode when an app defines `foreground` with a nested `dark` key. Override those colours with `style`, not `className`. App text needs the same care: `tokens.color.foreground[scheme]` reads in both schemes.
111
+
112
+ From 0.3.5 text that uses `font-instrument-sans-medium` or `-semibold` also sets `fontWeight` as a style (`typeStyle` in `src/controlStyles.ts`). Where an app maps every `font-instrument-sans-*` class to one family with no weight, as Expo web does, the class alone draws Regular.
54
113
 
55
114
  ## Theme
56
115
 
@@ -66,17 +125,109 @@ import { NativeUiProvider, Button, Card } from "@vvedantb/native-ui";
66
125
  </NativeUiProvider>
67
126
  ```
68
127
 
69
- `useLargerText()` steps type and tap targets up. `accentColor` tints FABs and card icons. Surfaces use the tokens in `src/tokens.ts` for fill, radius (card 24, row 20, pill 999) and spacing (16).
128
+ `useLargerText()` steps type and tap targets up. Without it, `Button`, `Input`, `Textarea` and the `Select` trigger have the web sizes (`Button` 32 / 40 / 44 high, fields 40); with it they keep the larger 0.3.2 sizes. `accentColor` tints FABs, card icons and progress bars (checked checkboxes use the palette primary, as on the web). Surfaces use the tokens in `src/tokens.ts` for fill, radius (card 24, button 16, field 10, pill 999; rows 20) and spacing (16).
129
+
130
+ ## Components (0.3.0)
131
+
132
+ | Area | Exports |
133
+ | --- | --- |
134
+ | Actions and fields | `Button` (`default`/`primary`, `secondary`, `outline`, `destructive`, `ghost`, `link`, `tinted`; `sm`/`small`, `default`/`medium`, `lg`/`large`, `icon`), `Input`, `SearchInput` (0.3.9), `Textarea`, `Label`, `Select`, `Switch`, `Checkbox`, `LabeledSwitchRow`, `DateTimeField` |
135
+ | Display | `Card` (+ `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`), `Badge`, `StatusDot`, `Avatar` (+ `AvatarImage`, `AvatarFallback`, `initials()`), `Separator`, `Progress`, `Spinner`, `CenteredSpinner`, `Skeleton`, `EmptyState` (+ `EmptyState.Root` and parts), `ListRow`, `Chip` |
136
+ | Navigation | `Tabs` (+ `TabsList`, `TabsTrigger`, `TabsContent`), `PillTabs`, `FloatingTabBar`, `FloatingFAB`, `HeaderIconButton` |
137
+ | Overlays | `Dialog`, `AlertDialog`, `ConfirmDialog`, `Sheet` (RN `Modal`, each with the web part names), `BottomSheetContainer` (`@gorhom/bottom-sheet`) |
138
+ | Feedback | `toast()` and `<Toaster />` |
139
+ | Brand and upgrade | `APP_ICONS`, `IconVibot` and the other `Icon*`, `AppMark`, `AppWordmark`, `FAMILY_APPS`, `V_PRO_APP_BENEFITS`, `UpgradeToProCard`, `PricingModal` |
140
+ | Settings and layout | `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `ScreenContainer`, `SafeAreaContainer`, `FloatingSurface`, `AmbientBackground` |
141
+
142
+ Overlays hold their own open state, or take `open` and `onOpenChange`:
143
+
144
+ ```tsx
145
+ <AlertDialog>
146
+ <AlertDialogTrigger asChild>
147
+ <Button variant="destructive">Delete</Button>
148
+ </AlertDialogTrigger>
149
+ <AlertDialogContent>
150
+ <AlertDialogHeader>
151
+ <AlertDialogTitle>Delete this list?</AlertDialogTitle>
152
+ <AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
153
+ </AlertDialogHeader>
154
+ <AlertDialogFooter>
155
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
156
+ <AlertDialogAction variant="destructive" onPress={remove}>Delete</AlertDialogAction>
157
+ </AlertDialogFooter>
158
+ </AlertDialogContent>
159
+ </AlertDialog>
160
+ ```
161
+
162
+ As on the web, `AlertDialogAction` does not close the dialog; `ConfirmDialog` closes on either button. `DialogFooter` and `AlertDialogFooter` lay their buttons out in a row at the end, as the web does from `sm` up. Pass `fullWidth={false}` to a plain `Button` there (`AlertDialogAction` and `AlertDialogCancel` already default to it). `SheetFooter` stacks full width.
163
+
164
+ From 0.3.7, in a compact window (639 wide or less, or 500 high or less: the web `COMPACT_OVERLAY_QUERY`) `DialogContent`, and so `AlertDialogContent` and `ConfirmDialog`, is the web phone bottom sheet: full width, top corners 16, a handle, the bottom safe-area inset. Its footer stacks the buttons full width and at least 44 high, the last child on top, whatever `fullWidth` says. Pass `viewportAware={false}` to keep the centred dialog at every size.
165
+
166
+ From 0.3.8 `UpgradeToProCard` has the web `SidebarUpgradeCard` sizes: a 14 / 600 title, a 32-high pill with a 12 / 500 label (the web `Button` `sm`), `px-3 py-2.5` padding and radius 16. With larger text the title and pill keep their larger sizes.
167
+
168
+ From 0.3.9 `SearchInput` is the web search field: a search icon, and a clear button while there is text. It is controlled with `value` and `onChange(text)`; `onClear` runs whenever the value becomes empty. `size="sm"` (default) is 32 high and `md` 40, border included, as on the web. From 0.3.12 that height is locked, so the line height of the text class cannot grow the field; it still grows with the system font size. The clear button is painted at 24 and reaches 44 with its hit slop. Inside a `ScrollView`, set `keyboardShouldPersistTaps="handled"` so the first tap on it clears while the keyboard is up. The web `shortcut` hint and `toolbar` variant are not ported.
169
+
170
+ ```tsx
171
+ const [query, setQuery] = useState("");
172
+ <SearchInput value={query} onChange={setQuery} label="Search apps" />
173
+ ```
174
+
175
+ Toasts need one `<Toaster />` inside `SafeAreaProvider`, after the navigator. Then call `toast("Saved")`, `toast.error("Failed")` or `toast.loading("Syncing")` from anywhere. Pass the returned id to replace a toast (`toast.success("Synced", { id })`). On iOS toasts show over modals; on Android a `Modal` covers them.
176
+
177
+ From 0.3.12 a toast looks like the web `SonnerToaster` at its default density with `toastIcons="filled24"`: the filled 24 success, info, warning and error icons (the web paths, in `palette` colours), a `text-sm` medium title, `px-4 py-3`, radius 16, and the flat overlay fill with a 1 px ring and a light shadow (`smooth-shadow-ring-md`). Toasts no longer blur.
178
+
179
+ `Tabs` shares its name with Expo Router's `Tabs` layout. In a file that needs both, import one under another name.
180
+
181
+ `@vvedantb/native-ui/gallery` exports `ParityGallery`, a screen that renders these components. Put it in a dev-only route to check them on a device.
182
+
183
+ ## Phones and accessibility
184
+
185
+ The components keep the web sizes on screen and reach a 44 pt touch target with `hitSlop` (`touchSlop` in `src/controlStyles.ts`): `Checkbox` (16), `Switch` (36 × 20), tab triggers (30 high), the `Select` trigger (40 high), the `SearchInput` clear button (24), the `UpgradeToProCard` pill (32 high, slop 8) and `Button` (the slop brings each size to at least 48). Controls set their role and state for VoiceOver and TalkBack: `Button` (`disabled`, `busy` while loading), `Checkbox` (`checked`, `mixed`), `Switch` (`checked`), `Tabs` and `PillTabs` (`tablist`, `tab`, `selected`) and the `Select` trigger (`combobox`, with an `accessibilityLabel` prop). Give a `Button` with no text child an `accessibilityLabel`.
186
+
187
+ Text scales with the system font size. Labelled controls have minimum heights, so a large Dynamic Type size grows them instead of clipping the label (the `SearchInput` lock grows with the font scale); only the `FloatingTabBar` labels are capped (1.5×), to fit the fixed bar. With Reduce Motion on, dialogs, sheets, toasts, the `Select` popover, `PricingModal`, `Progress` and `Skeleton` do not animate. Dialogs, sheets, toasts, `FloatingTabBar` and `FloatingFAB` stay inside the safe-area insets, and dialog and sheet fields stay above the keyboard on iOS and Android.
70
188
 
71
189
  ## Floating UI / Android shadows
72
190
 
73
- Do not put `elevation` or NativeWind `shadow-*` on nested or translucent chrome. `floatingShadow` is iOS `shadow*` and Android/web `boxShadow`. `FloatingSurface` paints an opaque fill on Android and clips on an inner view. Blur is iOS-only.
191
+ Do not put `elevation` or NativeWind `shadow-*` on nested or translucent chrome. `floatingShadow` is iOS `shadow*` and Android/web `boxShadow`. `FloatingSurface` clips on an inner view.
192
+
193
+ Only chrome that floats on the web too has a shadow: dialogs, sheets and toasts, plus the native `FloatingTabBar` and `FloatingFAB`. From 0.3.5 `Card` and `SettingsSection` are flat (`cardStyle`: the web `rounded-lg bg-surface-card`), and `Button` is flat unless you pass `shadowType="shadow"`.
194
+
195
+ ## Glass blur (iOS and Android)
196
+
197
+ With `expo-blur` installed, `FloatingSurface` blurs on iOS and Android. So do the components built on it: `FloatingTabBar`, the `Select` popover and `GlassSheetBackground`. `Card` and `SettingsSection` do not blur from 0.3.5, nor toasts from 0.3.12. Web uses a CSS backdrop blur. A surface draws the solid fill when `expo-blur` is not installed, with `blur={false}`, or when Reduce Transparency is on.
198
+
199
+ Since expo-blur 55, Android blur needs a target: the content to blur. Wrap the screen content in `GlassBlurTarget` and render the floating chrome outside it, with both inside one `GlassBlurProvider`:
200
+
201
+ ```tsx
202
+ import { GlassBlurProvider, GlassBlurTarget } from "@vvedantb/native-ui";
203
+
204
+ <GlassBlurProvider>
205
+ <GlassBlurTarget>
206
+ <Stack />
207
+ </GlassBlurTarget>
208
+ <Toaster />
209
+ </GlassBlurProvider>
210
+ ```
211
+
212
+ With `FloatingTabBar`, put `GlassBlurProvider` around `Tabs` and wrap each tab screen's content in `GlassBlurTarget`. Pass `active={isFocused}` (for example from React Navigation's `useIsFocused()`) so the bar blurs the visible tab. The last active target wins.
213
+
214
+ `GlassBlurTarget` is a view with `flex: 1`; pass `style` to change that. A blur view must not sit inside its own target, so surfaces inside a target (such as cards on the screen) get none. Without a target, expo-blur logs a warning on Android and draws only its tint, which under the glass fill looks almost solid.
215
+
216
+ On Android the `BlurView` uses the dimezis method (`experimentalBlurMethod` and `blurMethod` set to `"dimezisBlurView"`) with `blurReductionFactor={3}`, and the same `tint` and `intensity` as iOS. The translucent glass fill is laid over the Android blur, because on iOS the blur samples that fill and dimezis does not. expo-blur 55 and later log a deprecation warning for `experimentalBlurMethod` when each Android `BlurView` mounts.
74
217
 
75
218
  ## Tokens
76
219
 
77
- The JS tokens (colours, radii, spacing) live in `src/tokens.ts`. It is a copy of `@vvedantb/ui/tokens/native`, kept inside this package so React Native apps do not install `@vvedantb/ui` (Radix, react-dom and the rest of the web stack) for colour values. A test in `@vvedantb/ui` fails when the two files differ, so change both together.
220
+ The JS tokens (colours, radii, spacing) live in `src/tokens.ts`. It is a copy of `packages/ui/tokens/src/native.ts` (the `tokens` export of `@vvedantb/ui`), kept inside this package so React Native apps do not install `@vvedantb/ui` (Radix, react-dom and the rest of the web stack) for colour values. A test in each package fails when the two files differ, so change both together. From 0.3.3 the neutrals and radii match the web `tokens.css` (oklch converted to sRGB hex), and a test converts `tokens.css` and fails when they drift. `fill`, `hairline` and `radius.card` are native-only.
221
+
222
+ `palette` holds the web semantic colours that `tokens` lacks (`primary`, `danger`, `success`, `warning`, `default`, `overlay`, `segment` and others), each `{ light, dark }`. A test converts `tokens.css` and fails when a value drifts. `withAlpha("#rrggbb", 0.12)` gives tints such as the web `bg-danger/12`.
223
+
224
+ The brand files `src/brand/svg.ts`, `familyApps.ts` and `vProAppBenefits.ts` are byte-identical copies of `packages/ui/src/brand/`. A test fails when they differ.
225
+
226
+ `fontClass` lists the Instrument Sans classes. `AppWordmark` uses `font-instrument-sans-medium` (weight 500) with -0.02em letter spacing, as on the web.
227
+
228
+ ## Type checking
78
229
 
79
- Apps that already depend on `@vvedantb/ui` can import the same values from `@vvedantb/ui/tokens/native`.
230
+ `pnpm --filter @vvedantb/native-ui typecheck` runs `tsc` against `types/peers.d.ts`, local declarations of the React Native peers, because they are not installed in this repository. The declarations are looser than the real types. Apps type-check the source against the real packages.
80
231
 
81
232
  ## Publish
82
233
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vvedantb/native-ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.12",
4
4
  "license": "MIT",
5
- "description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, settings chrome, and tab bar.",
5
+ "description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar and the family app marks.",
6
6
  "type": "module",
7
7
  "publishConfig": {
8
8
  "access": "public"
@@ -16,7 +16,72 @@
16
16
  ".": {
17
17
  "types": "./src/index.ts",
18
18
  "default": "./src/index.ts"
19
- }
19
+ },
20
+ "./tab-bar": {
21
+ "types": "./src/entries/tab-bar.ts",
22
+ "default": "./src/entries/tab-bar.ts"
23
+ },
24
+ "./brand": {
25
+ "types": "./src/entries/brand.ts",
26
+ "default": "./src/entries/brand.ts"
27
+ },
28
+ "./button": {
29
+ "types": "./src/entries/button.ts",
30
+ "default": "./src/entries/button.ts"
31
+ },
32
+ "./primitives": {
33
+ "types": "./src/entries/primitives.ts",
34
+ "default": "./src/entries/primitives.ts"
35
+ },
36
+ "./search-input": {
37
+ "types": "./src/entries/search-input.ts",
38
+ "default": "./src/entries/search-input.ts"
39
+ },
40
+ "./toast": {
41
+ "types": "./src/entries/toast.ts",
42
+ "default": "./src/entries/toast.ts"
43
+ },
44
+ "./theme": {
45
+ "types": "./src/entries/theme.ts",
46
+ "default": "./src/entries/theme.ts"
47
+ },
48
+ "./floating": {
49
+ "types": "./src/entries/floating.ts",
50
+ "default": "./src/entries/floating.ts"
51
+ },
52
+ "./layout": {
53
+ "types": "./src/entries/layout.ts",
54
+ "default": "./src/entries/layout.ts"
55
+ },
56
+ "./marketing": {
57
+ "types": "./src/entries/marketing.ts",
58
+ "default": "./src/entries/marketing.ts"
59
+ },
60
+ "./ambient": {
61
+ "types": "./src/entries/ambient.ts",
62
+ "default": "./src/entries/ambient.ts"
63
+ },
64
+ "./bottom-sheet": {
65
+ "types": "./src/entries/bottom-sheet.ts",
66
+ "default": "./src/entries/bottom-sheet.ts"
67
+ },
68
+ "./date-time": {
69
+ "types": "./src/entries/date-time.ts",
70
+ "default": "./src/entries/date-time.ts"
71
+ },
72
+ "./gallery": {
73
+ "types": "./src/examples/ParityGallery.tsx",
74
+ "default": "./src/examples/ParityGallery.tsx"
75
+ },
76
+ "./blur": {
77
+ "types": "./src/blur.ts",
78
+ "default": "./src/blur.ts"
79
+ },
80
+ "./haptics": {
81
+ "types": "./src/haptics.ts",
82
+ "default": "./src/haptics.ts"
83
+ },
84
+ "./package.json": "./package.json"
20
85
  },
21
86
  "types": "./src/index.ts",
22
87
  "files": [
@@ -24,6 +89,12 @@
24
89
  "LICENSE"
25
90
  ],
26
91
  "sideEffects": false,
92
+ "scripts": {
93
+ "build": "node -e \"require('node:fs').accessSync('src/index.ts')\"",
94
+ "test": "vitest run",
95
+ "typecheck": "tsc -p tsconfig.typecheck.json",
96
+ "prepack": "node -e \"require('node:fs').accessSync('src/index.ts')\""
97
+ },
27
98
  "peerDependencies": {
28
99
  "@expo/vector-icons": "*",
29
100
  "@gorhom/bottom-sheet": ">=5",
@@ -46,6 +117,7 @@
46
117
  "react-native-reanimated": "*",
47
118
  "react-native-safe-area-context": "*",
48
119
  "react-native-screens": "*",
120
+ "react-native-svg": "*",
49
121
  "tailwind-merge": ">=2.0.0"
50
122
  },
51
123
  "peerDependenciesMeta": {
@@ -58,8 +130,20 @@
58
130
  "dayjs": {
59
131
  "optional": true
60
132
  },
133
+ "expo-blur": {
134
+ "optional": true
135
+ },
136
+ "expo-haptics": {
137
+ "optional": true
138
+ },
139
+ "expo-linear-gradient": {
140
+ "optional": true
141
+ },
61
142
  "expo-router": {
62
143
  "optional": true
144
+ },
145
+ "react-native-svg": {
146
+ "optional": true
63
147
  }
64
148
  },
65
149
  "devDependencies": {
@@ -67,9 +151,5 @@
67
151
  "react": "^19.1.0",
68
152
  "typescript": "^5.9.0",
69
153
  "vitest": "^3.2.0"
70
- },
71
- "scripts": {
72
- "build": "node -e \"require('node:fs').accessSync('src/index.ts')\"",
73
- "test": "vitest run"
74
154
  }
75
- }
155
+ }
@@ -0,0 +1,73 @@
1
+ import { Button, type ButtonProps } from "./Button";
2
+ import {
3
+ Dialog,
4
+ DialogContent,
5
+ DialogDescription,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ DialogTrigger,
10
+ type DialogContentProps,
11
+ type DialogProps,
12
+ } from "./Dialog";
13
+ import { useOverlay } from "./primitives/overlay";
14
+
15
+ export type AlertDialogProps = DialogProps;
16
+
17
+ /** A `Dialog` whose content blocks backdrop taps. Android back still closes it, as Escape does on the web. */
18
+ export function AlertDialog(props: AlertDialogProps) {
19
+ return <Dialog {...props} />;
20
+ }
21
+
22
+ export const AlertDialogTrigger = DialogTrigger;
23
+
24
+ export type AlertDialogContentProps = Omit<DialogContentProps, "dismissible">;
25
+
26
+ /** `DialogContent` with no backdrop dismiss and, by default, no close button. */
27
+ export function AlertDialogContent({
28
+ hideCloseButton = true,
29
+ ...props
30
+ }: AlertDialogContentProps) {
31
+ return (
32
+ <DialogContent hideCloseButton={hideCloseButton} dismissible={false} {...props} />
33
+ );
34
+ }
35
+
36
+ export const AlertDialogHeader = DialogHeader;
37
+ export const AlertDialogFooter = DialogFooter;
38
+ export const AlertDialogTitle = DialogTitle;
39
+ export const AlertDialogDescription = DialogDescription;
40
+
41
+ /**
42
+ * Confirm button. As on the web it does not close the dialog: close it in
43
+ * `onPress` (set `open` to false). Not full width by default, for the row
44
+ * footer; full width in the stacked footer of the compact sheet.
45
+ */
46
+ export function AlertDialogAction(props: ButtonProps) {
47
+ return <Button shadowType="flat" fullWidth={false} {...props} />;
48
+ }
49
+
50
+ /**
51
+ * Cancel button: runs `onPress`, then closes the dialog. Default variant
52
+ * `outline`, as on the web. Not full width, except in the stacked footer of
53
+ * the compact sheet.
54
+ */
55
+ export function AlertDialogCancel({
56
+ variant = "outline",
57
+ onPress,
58
+ ...props
59
+ }: ButtonProps) {
60
+ const { setOpen } = useOverlay("AlertDialogCancel");
61
+ return (
62
+ <Button
63
+ variant={variant}
64
+ shadowType="flat"
65
+ fullWidth={false}
66
+ {...props}
67
+ onPress={() => {
68
+ onPress?.();
69
+ setOpen(false);
70
+ }}
71
+ />
72
+ );
73
+ }
@@ -1,28 +1,43 @@
1
1
  import { StyleSheet, View, type ViewProps } from "react-native";
2
- import { LinearGradient } from "expo-linear-gradient";
3
- import { useColorScheme } from "nativewind";
4
2
  import { cn } from "./cn";
3
+ import { loadLinearGradient } from "./gradient";
4
+ import { tokens } from "./tokens";
5
+ import { useScheme } from "./useScheme";
5
6
 
6
7
  const LIGHT = ["#f6f6f7", "#fde8f0", "#f4f5f7"] as const;
7
8
  const DARK = ["#1c1c1e", "#2a1820", "#1a1a1c"] as const;
8
9
 
10
+ /**
11
+ * A soft gradient behind the screen. Without the optional
12
+ * `expo-linear-gradient` peer it draws the solid `tokens.color.background`.
13
+ */
9
14
  export function AmbientBackground({
10
15
  children,
11
16
  className,
12
17
  ...props
13
18
  }: ViewProps & { className?: string }) {
14
- const { colorScheme } = useColorScheme();
15
- const colors = colorScheme === "dark" ? DARK : LIGHT;
19
+ const scheme = useScheme();
20
+ const LinearGradient = loadLinearGradient()?.LinearGradient;
16
21
 
17
22
  return (
18
23
  <View className={cn("flex-1", className)} {...props}>
19
- <LinearGradient
20
- pointerEvents="none"
21
- colors={[...colors]}
22
- start={{ x: 0.05, y: 0 }}
23
- end={{ x: 0.95, y: 1 }}
24
- style={StyleSheet.absoluteFill}
25
- />
24
+ {LinearGradient ? (
25
+ <LinearGradient
26
+ pointerEvents="none"
27
+ colors={[...(scheme === "dark" ? DARK : LIGHT)]}
28
+ start={{ x: 0.05, y: 0 }}
29
+ end={{ x: 0.95, y: 1 }}
30
+ style={StyleSheet.absoluteFill}
31
+ />
32
+ ) : (
33
+ <View
34
+ pointerEvents="none"
35
+ style={{
36
+ ...StyleSheet.absoluteFillObject,
37
+ backgroundColor: tokens.color.background[scheme],
38
+ }}
39
+ />
40
+ )}
26
41
  {children}
27
42
  </View>
28
43
  );
package/src/Avatar.tsx ADDED
@@ -0,0 +1,141 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from "react";
9
+ import {
10
+ Image,
11
+ StyleSheet,
12
+ Text,
13
+ View,
14
+ type ImageProps,
15
+ type ViewProps,
16
+ } from "react-native";
17
+ import { cn } from "./cn";
18
+ import { typeStyle } from "./controlStyles";
19
+ import { palette } from "./palette";
20
+ import { tokens } from "./tokens";
21
+ import { useScheme } from "./useScheme";
22
+
23
+ type ImageStatus = "idle" | "loaded" | "error";
24
+
25
+ const AvatarContext = createContext<{
26
+ size: number;
27
+ status: ImageStatus;
28
+ setStatus: (status: ImageStatus) => void;
29
+ } | null>(null);
30
+
31
+ function useAvatar(part: string) {
32
+ const context = useContext(AvatarContext);
33
+ if (!context) throw new Error(`${part} must be inside an Avatar.`);
34
+ return context;
35
+ }
36
+
37
+ export type AvatarProps = ViewProps & {
38
+ /** Diameter in points. Default 40 (the web `h-10 w-10`). */
39
+ size?: number;
40
+ className?: string;
41
+ };
42
+
43
+ /** Round frame for `AvatarImage` and `AvatarFallback`, as the web `Avatar`. The fallback shows until the image loads, and stays if it fails. */
44
+ export function Avatar({ size = 40, className, style, children, ...props }: AvatarProps) {
45
+ const scheme = useScheme();
46
+ const [status, setStatus] = useState<ImageStatus>("idle");
47
+ return (
48
+ <AvatarContext.Provider value={{ size, status, setStatus }}>
49
+ <View
50
+ className={cn("relative shrink-0 overflow-hidden border", className)}
51
+ style={[
52
+ {
53
+ width: size,
54
+ height: size,
55
+ borderRadius: size / 2,
56
+ borderColor: tokens.color.border[scheme],
57
+ backgroundColor: tokens.color.surfaceSecondary[scheme],
58
+ },
59
+ style,
60
+ ]}
61
+ {...props}
62
+ >
63
+ {children}
64
+ </View>
65
+ </AvatarContext.Provider>
66
+ );
67
+ }
68
+
69
+ export type AvatarImageProps = Omit<ImageProps, "style"> & {
70
+ className?: string;
71
+ };
72
+
73
+ export function AvatarImage({ onLoad, onError, className, ...props }: AvatarImageProps) {
74
+ const { status, setStatus } = useAvatar("AvatarImage");
75
+ // A new source gets a fresh try: back to the fallback until it loads.
76
+ const sourceKey = JSON.stringify(props.source);
77
+ const previousSource = useRef(sourceKey);
78
+ useEffect(() => {
79
+ if (previousSource.current === sourceKey) return;
80
+ previousSource.current = sourceKey;
81
+ setStatus("idle");
82
+ }, [sourceKey, setStatus]);
83
+ if (status === "error") return null;
84
+ return (
85
+ <Image
86
+ resizeMode="cover"
87
+ className={className}
88
+ style={StyleSheet.absoluteFill}
89
+ onLoad={(event) => {
90
+ setStatus("loaded");
91
+ onLoad?.(event);
92
+ }}
93
+ onError={(event) => {
94
+ setStatus("error");
95
+ onError?.(event);
96
+ }}
97
+ {...props}
98
+ />
99
+ );
100
+ }
101
+
102
+ export type AvatarFallbackProps = ViewProps & {
103
+ /** Usually initials (see `initials()`). A string is centred in Instrument Sans at 40% of the avatar size. */
104
+ children?: ReactNode;
105
+ className?: string;
106
+ textClassName?: string;
107
+ };
108
+
109
+ export function AvatarFallback({
110
+ children,
111
+ className,
112
+ textClassName,
113
+ style,
114
+ ...props
115
+ }: AvatarFallbackProps) {
116
+ const { size, status } = useAvatar("AvatarFallback");
117
+ const scheme = useScheme();
118
+ if (status === "loaded") return null;
119
+ return (
120
+ <View
121
+ className={cn("absolute inset-0 items-center justify-center", className)}
122
+ style={[{ backgroundColor: palette.default[scheme] }, style]}
123
+ {...props}
124
+ >
125
+ {typeof children === "string" ? (
126
+ <Text
127
+ numberOfLines={1}
128
+ className={cn("font-instrument-sans-semibold", textClassName)}
129
+ style={[
130
+ { fontSize: Math.round(size * 0.4), color: palette.defaultForeground[scheme] },
131
+ typeStyle("semibold"),
132
+ ]}
133
+ >
134
+ {children}
135
+ </Text>
136
+ ) : (
137
+ children
138
+ )}
139
+ </View>
140
+ );
141
+ }