@vvedantb/native-ui 0.2.1 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +171 -9
  2. package/package.json +84 -2
  3. package/src/AlertDialog.tsx +73 -0
  4. package/src/AmbientBackground.tsx +26 -11
  5. package/src/Avatar.tsx +141 -0
  6. package/src/Badge.tsx +108 -0
  7. package/src/BottomSheetContainer.tsx +7 -1
  8. package/src/Button.tsx +153 -52
  9. package/src/Card.tsx +111 -36
  10. package/src/Checkbox.tsx +78 -0
  11. package/src/Chip.tsx +5 -3
  12. package/src/ConfirmDialog.tsx +105 -0
  13. package/src/DateTimeField.tsx +6 -2
  14. package/src/Dialog.tsx +253 -0
  15. package/src/EmptyState.tsx +122 -7
  16. package/src/FloatingFAB.tsx +2 -4
  17. package/src/FloatingSurface.tsx +31 -9
  18. package/src/FloatingTabBar.tsx +39 -11
  19. package/src/Input.tsx +15 -4
  20. package/src/Label.tsx +31 -0
  21. package/src/LabeledSwitchRow.tsx +96 -0
  22. package/src/LiquidGlass.tsx +2 -10
  23. package/src/ListRow.tsx +7 -2
  24. package/src/PageTitle.tsx +81 -0
  25. package/src/PillTabs.tsx +28 -14
  26. package/src/Progress.tsx +67 -0
  27. package/src/SearchInput.tsx +163 -0
  28. package/src/Select.tsx +32 -6
  29. package/src/Separator.tsx +38 -0
  30. package/src/Sheet.tsx +107 -0
  31. package/src/Skeleton.tsx +25 -5
  32. package/src/Spinner.tsx +92 -0
  33. package/src/StatusDot.tsx +66 -0
  34. package/src/Switch.tsx +24 -7
  35. package/src/Tabs.tsx +190 -0
  36. package/src/Textarea.tsx +47 -0
  37. package/src/Toast.tsx +170 -0
  38. package/src/blur.ts +48 -0
  39. package/src/brand/AppIcons.tsx +106 -0
  40. package/src/brand/AppMark.tsx +83 -0
  41. package/src/brand/AppWordmark.tsx +97 -0
  42. package/src/brand/familyApps.ts +24 -0
  43. package/src/brand/index.ts +34 -0
  44. package/src/brand/svg.ts +263 -0
  45. package/src/brand/vProAppBenefits.ts +22 -0
  46. package/src/controlStyles.ts +395 -0
  47. package/src/dialogLayout.ts +20 -0
  48. package/src/entries/ambient.ts +3 -0
  49. package/src/entries/bottom-sheet.ts +2 -0
  50. package/src/entries/brand.ts +2 -0
  51. package/src/entries/button.ts +3 -0
  52. package/src/entries/date-time.ts +3 -0
  53. package/src/entries/floating.ts +24 -0
  54. package/src/entries/layout.ts +10 -0
  55. package/src/entries/marketing.ts +10 -0
  56. package/src/entries/page-title.ts +4 -0
  57. package/src/entries/primitives.ts +99 -0
  58. package/src/entries/search-input.ts +4 -0
  59. package/src/entries/tab-bar.ts +10 -0
  60. package/src/entries/theme.ts +15 -0
  61. package/src/entries/toast.ts +6 -0
  62. package/src/examples/ParityGallery.tsx +513 -0
  63. package/src/floating.ts +5 -1
  64. package/src/glassBlur.tsx +95 -0
  65. package/src/gradient.ts +21 -0
  66. package/src/haptics.ts +22 -2
  67. package/src/index.ts +101 -2
  68. package/src/initials.ts +10 -0
  69. package/src/marketing/PricingModal.tsx +410 -0
  70. package/src/marketing/UpgradeToProCard.tsx +103 -0
  71. package/src/marketing/pricing.ts +76 -0
  72. package/src/palette.ts +43 -0
  73. package/src/primitives/button.tsx +3 -1
  74. package/src/primitives/card.tsx +5 -2
  75. package/src/primitives/input.tsx +9 -2
  76. package/src/primitives/native-only-animated-view.tsx +10 -3
  77. package/src/primitives/overlay.tsx +179 -0
  78. package/src/primitives/select.tsx +25 -12
  79. package/src/primitives/switch.tsx +12 -13
  80. package/src/primitives/text.tsx +25 -8
  81. package/src/primitives/use-controllable-state.ts +24 -0
  82. package/src/settings/SettingsField.tsx +11 -2
  83. package/src/settings/SettingsSection.tsx +20 -6
  84. package/src/settings/SettingsToggleRow.tsx +7 -2
  85. package/src/tabBarRoutes.ts +52 -0
  86. package/src/toastChrome.ts +103 -0
  87. package/src/toastStore.ts +86 -0
  88. package/src/tokens.ts +19 -14
  89. package/src/typography.ts +24 -0
  90. package/src/useReduceMotion.ts +24 -0
  91. package/src/useScheme.ts +6 -0
@@ -0,0 +1,513 @@
1
+ // Reference screen for the components that mirror `@vvedantb/ui`. Render it in
2
+ // an Expo route to compare against the web: it needs the same NativeWind setup
3
+ // as the rest of the package and a `SafeAreaProvider` above it. It always
4
+ // renders the default sizes (larger text off), as the web does. Prices below
5
+ // are placeholders, not real plan prices.
6
+ import { useState, type ReactNode } from "react";
7
+ import { ScrollView, Text, View, type TextProps } from "react-native";
8
+ import { IconInbox } from "@tabler/icons-react-native";
9
+ import {
10
+ APP_ICONS,
11
+ APP_NAMES,
12
+ AlertDialog,
13
+ AlertDialogAction,
14
+ AlertDialogCancel,
15
+ AlertDialogContent,
16
+ AlertDialogDescription,
17
+ AlertDialogFooter,
18
+ AlertDialogHeader,
19
+ AlertDialogTitle,
20
+ AlertDialogTrigger,
21
+ AppMark,
22
+ AppWordmark,
23
+ Avatar,
24
+ AvatarFallback,
25
+ Badge,
26
+ Button,
27
+ Card,
28
+ CardContent,
29
+ CardDescription,
30
+ CardFooter,
31
+ CardHeader,
32
+ CardTitle,
33
+ CenteredSpinner,
34
+ Checkbox,
35
+ ConfirmDialog,
36
+ Dialog,
37
+ DialogBody,
38
+ DialogClose,
39
+ DialogContent,
40
+ DialogDescription,
41
+ DialogFooter,
42
+ DialogHeader,
43
+ DialogTitle,
44
+ DialogTrigger,
45
+ EmptyState,
46
+ Input,
47
+ Label,
48
+ LabeledSwitchRow,
49
+ NativeUiProvider,
50
+ PageHeader,
51
+ PageTitle,
52
+ PricingModal,
53
+ Progress,
54
+ SearchInput,
55
+ Select,
56
+ Separator,
57
+ Sheet,
58
+ SheetContent,
59
+ SheetDescription,
60
+ SheetFooter,
61
+ SheetHeader,
62
+ SheetTitle,
63
+ SheetTrigger,
64
+ Skeleton,
65
+ Spinner,
66
+ StatusDot,
67
+ Switch,
68
+ Tabs,
69
+ TabsContent,
70
+ TabsList,
71
+ TabsTrigger,
72
+ Textarea,
73
+ Toaster,
74
+ UpgradeToProCard,
75
+ initials,
76
+ toast,
77
+ tokens,
78
+ useNativeUi,
79
+ type BadgeVariant,
80
+ type ButtonVariant,
81
+ type PricingPlan,
82
+ type StatusTone,
83
+ } from "../index";
84
+ import { typeStyle } from "../controlStyles";
85
+ import { useScheme } from "../useScheme";
86
+
87
+ const BUTTON_VARIANTS: ButtonVariant[] = [
88
+ "default",
89
+ "secondary",
90
+ "outline",
91
+ "destructive",
92
+ "ghost",
93
+ "link",
94
+ ];
95
+
96
+ const BADGE_VARIANTS: BadgeVariant[] = [
97
+ "default",
98
+ "secondary",
99
+ "destructive",
100
+ "destructive-soft",
101
+ "outline",
102
+ "success",
103
+ "warning",
104
+ ];
105
+
106
+ const STATUS_TONES: StatusTone[] = [
107
+ "progress",
108
+ "business-review",
109
+ "code-review",
110
+ "done",
111
+ "cancelled",
112
+ "active",
113
+ "critical",
114
+ "neutral",
115
+ ];
116
+
117
+ // Space after the last section, so Card, Upgrade and Overlays can scroll to the
118
+ // top of an 844-tall phone for screenshot crops.
119
+ const GALLERY_END_SPACE = 720;
120
+
121
+ const PLANS: PricingPlan[] = [
122
+ {
123
+ id: "free",
124
+ name: "Free",
125
+ price: { month: "£0", year: "£0" },
126
+ current: true,
127
+ benefits: [{ label: "Core features" }],
128
+ },
129
+ {
130
+ id: "pro",
131
+ name: "V Pro",
132
+ price: { month: "£X", year: "£Y" },
133
+ featured: true,
134
+ intro: "Everything in Free, and:",
135
+ },
136
+ ];
137
+
138
+ /** Body text in the scheme's foreground (a style, so it reads in dark mode too). */
139
+ function GalleryText({ className, style, ...props }: TextProps & { className?: string }) {
140
+ const scheme = useScheme();
141
+ return (
142
+ <Text
143
+ className={className ?? "font-instrument-sans text-base"}
144
+ style={[{ color: tokens.color.foreground[scheme] }, style]}
145
+ {...props}
146
+ />
147
+ );
148
+ }
149
+
150
+ function Section({ title, children }: { title: string; children: ReactNode }) {
151
+ return (
152
+ <View className="gap-3">
153
+ <GalleryText
154
+ accessibilityRole="header"
155
+ className="font-instrument-sans-semibold text-lg"
156
+ style={typeStyle("semibold")}
157
+ >
158
+ {title}
159
+ </GalleryText>
160
+ {children}
161
+ </View>
162
+ );
163
+ }
164
+
165
+ export function ParityGallery() {
166
+ const { accentColor } = useNativeUi();
167
+ return (
168
+ <NativeUiProvider largerText={false} accentColor={accentColor}>
169
+ <GalleryScreen />
170
+ </NativeUiProvider>
171
+ );
172
+ }
173
+
174
+ function GalleryScreen() {
175
+ const [checked, setChecked] = useState<boolean | "indeterminate">("indeterminate");
176
+ const [switchOn, setSwitchOn] = useState(true);
177
+ const [colour, setColour] = useState<string | null | undefined>(undefined);
178
+ const [emptySearch, setEmptySearch] = useState("");
179
+ const [search, setSearch] = useState("Pink");
180
+ const [progress, setProgress] = useState(40);
181
+ const [pricingOpen, setPricingOpen] = useState(false);
182
+ const [confirmOpen, setConfirmOpen] = useState(false);
183
+
184
+ return (
185
+ <View className="flex-1">
186
+ <ScrollView
187
+ // So the SearchInput clear button takes the first tap while the keyboard is up.
188
+ keyboardShouldPersistTaps="handled"
189
+ contentContainerClassName="gap-8 px-4 pt-4"
190
+ contentContainerStyle={{ paddingBottom: GALLERY_END_SPACE }}
191
+ >
192
+ <Section title="Page title">
193
+ <PageTitle>Updates</PageTitle>
194
+ {/* `safeTop={false}` and `px-0`: here the header is not at the top of the screen, and the scroll view pads the sides. */}
195
+ <PageHeader
196
+ title="Apps"
197
+ safeTop={false}
198
+ className="px-0"
199
+ action={
200
+ <Button variant="ghost" size="sm" fullWidth={false} onPress={() => toast("Updating all apps")}>
201
+ Update all
202
+ </Button>
203
+ }
204
+ />
205
+ </Section>
206
+
207
+ <Section title="Brand">
208
+ {(["vibot", "verve", "velth", "vmem", "vhub"] as const).map((app) => (
209
+ <AppWordmark key={app} app={app} />
210
+ ))}
211
+ <View className="flex-row flex-wrap items-center gap-3">
212
+ {APP_NAMES.map((app) => {
213
+ const Icon = APP_ICONS[app];
214
+ return <Icon key={app} size={28} />;
215
+ })}
216
+ <AppMark app="vibot" variant="tile" size={40} />
217
+ <AppMark app="vibot" variant="tile-light" size={40} />
218
+ </View>
219
+ </Section>
220
+
221
+ <Section title="Button">
222
+ {/* The web gallery: a full-width column of variants, then the sm row. */}
223
+ <View className="gap-2">
224
+ {BUTTON_VARIANTS.map((variant) => (
225
+ <Button key={variant} variant={variant} onPress={() => toast(variant)}>
226
+ {variant}
227
+ </Button>
228
+ ))}
229
+ <View className="flex-row gap-2">
230
+ <Button size="sm" fullWidth={false} loading>
231
+ Saving
232
+ </Button>
233
+ <Button size="sm" fullWidth={false} disabled>
234
+ Disabled
235
+ </Button>
236
+ </View>
237
+ </View>
238
+ <View className="flex-row flex-wrap items-center gap-2">
239
+ <Button size="sm" fullWidth={false}>
240
+ Small
241
+ </Button>
242
+ <Button fullWidth={false}>Default</Button>
243
+ <Button size="lg" fullWidth={false}>
244
+ Large
245
+ </Button>
246
+ </View>
247
+ </Section>
248
+
249
+ <Section title="Badge">
250
+ <View className="flex-row flex-wrap gap-2">
251
+ {BADGE_VARIANTS.map((variant) => (
252
+ <Badge key={variant} variant={variant}>
253
+ {variant}
254
+ </Badge>
255
+ ))}
256
+ </View>
257
+ </Section>
258
+
259
+ <Section title="StatusDot">
260
+ <View className="flex-row flex-wrap items-center gap-3">
261
+ {STATUS_TONES.map((tone) => (
262
+ <StatusDot key={tone} tone={tone} size="md" />
263
+ ))}
264
+ </View>
265
+ </Section>
266
+
267
+ <Section title="Fields">
268
+ <View className="gap-2">
269
+ <Label nativeID="gallery-name">Name</Label>
270
+ <Input placeholder="Your name" accessibilityLabel="Name" />
271
+ </View>
272
+ {/* Empty (`sm`: icon and placeholder) and filled (`md`: the clear button). */}
273
+ <SearchInput value={emptySearch} onChange={setEmptySearch} />
274
+ <SearchInput value={search} onChange={setSearch} size="md" label="Search colours" />
275
+ <Textarea placeholder="Notes" accessibilityLabel="Notes" />
276
+ <Select
277
+ value={colour}
278
+ onValueChange={setColour}
279
+ placeholder="Pick a colour"
280
+ options={[
281
+ { label: "Pink", value: "pink" },
282
+ { label: "None", value: null },
283
+ ]}
284
+ />
285
+ <View className="flex-row items-center gap-3">
286
+ <Checkbox
287
+ checked={checked}
288
+ onCheckedChange={setChecked}
289
+ accessibilityLabel="Remember me"
290
+ />
291
+ <Label nativeID="gallery-remember">Remember me</Label>
292
+ <Switch checked={switchOn} onCheckedChange={setSwitchOn} />
293
+ </View>
294
+ <LabeledSwitchRow
295
+ label="Reminders"
296
+ description="A daily nudge at 9:00."
297
+ checked={switchOn}
298
+ onCheckedChange={setSwitchOn}
299
+ />
300
+ </Section>
301
+
302
+ <Section title="Tabs">
303
+ <Tabs defaultValue="one">
304
+ <TabsList variant="segmented">
305
+ <TabsTrigger value="one">One</TabsTrigger>
306
+ <TabsTrigger value="two">Two</TabsTrigger>
307
+ <TabsTrigger value="three" disabled>
308
+ Three
309
+ </TabsTrigger>
310
+ </TabsList>
311
+ <TabsContent value="one">
312
+ <GalleryText>First panel</GalleryText>
313
+ </TabsContent>
314
+ <TabsContent value="two">
315
+ <GalleryText>Second panel</GalleryText>
316
+ </TabsContent>
317
+ </Tabs>
318
+ </Section>
319
+
320
+ <Section title="Avatar, Progress, Spinner, Skeleton">
321
+ <View className="flex-row items-center gap-3">
322
+ <Avatar>
323
+ <AvatarFallback>{initials("Ada Lovelace")}</AvatarFallback>
324
+ </Avatar>
325
+ <Avatar size={56}>
326
+ <AvatarFallback>{initials("vibot")}</AvatarFallback>
327
+ </Avatar>
328
+ <Spinner />
329
+ <Spinner size="lg" />
330
+ </View>
331
+ <Progress value={progress} />
332
+ <Button
333
+ variant="secondary"
334
+ size="sm"
335
+ onPress={() => setProgress((value) => (value + 20) % 120)}
336
+ >
337
+ Step progress
338
+ </Button>
339
+ <Skeleton className="h-4 w-32" />
340
+ <Separator />
341
+ <View className="h-24">
342
+ <CenteredSpinner label="Loading list" />
343
+ </View>
344
+ </Section>
345
+
346
+ <Section title="Card">
347
+ <Card title="Today" description="Card props, as before.">
348
+ <GalleryText>Body</GalleryText>
349
+ </Card>
350
+ <Card>
351
+ <CardHeader>
352
+ <CardTitle>Composed</CardTitle>
353
+ <CardDescription>Web part names inside the native card.</CardDescription>
354
+ </CardHeader>
355
+ <CardContent>
356
+ <GalleryText>Content</GalleryText>
357
+ </CardContent>
358
+ <CardFooter>
359
+ <Button size="sm" fullWidth={false} variant="secondary">
360
+ Later
361
+ </Button>
362
+ <Button size="sm" fullWidth={false}>
363
+ Save
364
+ </Button>
365
+ </CardFooter>
366
+ </Card>
367
+ </Section>
368
+
369
+ <Section title="Upgrade">
370
+ <UpgradeToProCard onUpgrade={() => setPricingOpen(true)} />
371
+ <UpgradeToProCard collapsed onUpgrade={() => setPricingOpen(true)} />
372
+ </Section>
373
+
374
+ <Section title="Overlays and toast">
375
+ <Dialog>
376
+ <DialogTrigger asChild>
377
+ <Button variant="secondary">Open dialog</Button>
378
+ </DialogTrigger>
379
+ <DialogContent>
380
+ <DialogHeader>
381
+ <DialogTitle>Rename list</DialogTitle>
382
+ <DialogDescription>Pick a short name.</DialogDescription>
383
+ </DialogHeader>
384
+ <DialogBody>
385
+ <Input placeholder="Name" accessibilityLabel="List name" />
386
+ </DialogBody>
387
+ <DialogFooter>
388
+ <DialogClose asChild>
389
+ <Button variant="secondary" shadowType="flat" fullWidth={false}>
390
+ Cancel
391
+ </Button>
392
+ </DialogClose>
393
+ <DialogClose asChild>
394
+ <Button
395
+ shadowType="flat"
396
+ fullWidth={false}
397
+ onPress={() => toast.success("Renamed")}
398
+ >
399
+ Save
400
+ </Button>
401
+ </DialogClose>
402
+ </DialogFooter>
403
+ </DialogContent>
404
+ </Dialog>
405
+
406
+ <AlertDialog>
407
+ <AlertDialogTrigger asChild>
408
+ <Button variant="secondary">Open alert dialog</Button>
409
+ </AlertDialogTrigger>
410
+ <AlertDialogContent>
411
+ <AlertDialogHeader>
412
+ <AlertDialogTitle>Delete this list?</AlertDialogTitle>
413
+ <AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
414
+ </AlertDialogHeader>
415
+ <AlertDialogFooter>
416
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
417
+ <AlertDialogAction variant="destructive" onPress={() => toast.error("Deleted")}>
418
+ Delete
419
+ </AlertDialogAction>
420
+ </AlertDialogFooter>
421
+ </AlertDialogContent>
422
+ </AlertDialog>
423
+
424
+ <Button variant="secondary" onPress={() => setConfirmOpen(true)}>
425
+ Open confirm dialog
426
+ </Button>
427
+ <ConfirmDialog
428
+ open={confirmOpen}
429
+ onOpenChange={setConfirmOpen}
430
+ title="Sign out?"
431
+ description="You can sign back in at any time."
432
+ confirmLabel="Sign out"
433
+ variant="destructive"
434
+ onConfirm={() => toast.success("Signed out")}
435
+ />
436
+
437
+ <Sheet>
438
+ <SheetTrigger asChild>
439
+ <Button variant="secondary">Open sheet</Button>
440
+ </SheetTrigger>
441
+ <SheetContent>
442
+ <SheetHeader>
443
+ <SheetTitle>Filters</SheetTitle>
444
+ <SheetDescription>Narrow the list.</SheetDescription>
445
+ </SheetHeader>
446
+ <LabeledSwitchRow
447
+ label="Done items"
448
+ checked={switchOn}
449
+ onCheckedChange={setSwitchOn}
450
+ />
451
+ <SheetFooter>
452
+ <Button shadowType="flat">Apply</Button>
453
+ </SheetFooter>
454
+ </SheetContent>
455
+ </Sheet>
456
+
457
+ <View className="flex-row flex-wrap gap-2">
458
+ <Button size="sm" fullWidth={false} onPress={() => toast.success("Saved")}>
459
+ Success
460
+ </Button>
461
+ <Button size="sm" fullWidth={false} onPress={() => toast.warning("Offline")}>
462
+ Warning
463
+ </Button>
464
+ <Button
465
+ size="sm"
466
+ fullWidth={false}
467
+ onPress={() => {
468
+ const id = toast.loading("Syncing");
469
+ setTimeout(() => toast.success("Synced", { id }), 1500);
470
+ }}
471
+ >
472
+ Loading
473
+ </Button>
474
+ <Button
475
+ size="sm"
476
+ fullWidth={false}
477
+ onPress={() =>
478
+ toast("Archived", {
479
+ description: "Moved to the archive.",
480
+ action: { label: "Undo", onPress: () => toast.info("Restored") },
481
+ })
482
+ }
483
+ >
484
+ Action
485
+ </Button>
486
+ </View>
487
+ </Section>
488
+
489
+ <Section title="EmptyState">
490
+ <EmptyState title="Nothing yet" description="Items you add show here." compact />
491
+ <EmptyState.Root variant="panel">
492
+ <EmptyState.Icon icon={IconInbox} />
493
+ <EmptyState.Title>No messages</EmptyState.Title>
494
+ <EmptyState.Description>New messages land here.</EmptyState.Description>
495
+ </EmptyState.Root>
496
+ </Section>
497
+ </ScrollView>
498
+
499
+ <PricingModal
500
+ open={pricingOpen}
501
+ onClose={() => setPricingOpen(false)}
502
+ title="Upgrade to V Pro"
503
+ apps={["vibot", "verve", "velth", "vmem", "vhub"]}
504
+ currentApp="vibot"
505
+ headline="One plan. Every V app."
506
+ plans={PLANS}
507
+ finePrint="Placeholder prices for the gallery."
508
+ onCheckout={(plan, period) => toast(`${plan.name} (${period})`)}
509
+ />
510
+ <Toaster />
511
+ </View>
512
+ );
513
+ }
package/src/floating.ts CHANGED
@@ -5,7 +5,11 @@ export const FLOATING_INSET = tokens.spacing.unit;
5
5
  export const FLOATING_TAB_BAR_HEIGHT = 64;
6
6
  export const FLOATING_TAB_BAR_HEIGHT_LARGE = 72;
7
7
  export const FLOATING_RADIUS = tokens.radius.card;
8
- export const FLOATING_ROW_RADIUS = tokens.radius.field;
8
+ /**
9
+ * List rows: 20, the field radius before 0.3.3 (web fields are 10). Toasts
10
+ * used it until 0.3.12 and the upgrade card until 0.3.8; both are 16 now.
11
+ */
12
+ export const FLOATING_ROW_RADIUS = 20;
9
13
  export const FLOATING_PILL_RADIUS = tokens.radius.pill;
10
14
 
11
15
  const FLOATING_BOX_SHADOW = "0px 8px 24px rgba(10, 10, 10, 0.12)";
@@ -0,0 +1,95 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ComponentRef,
9
+ type Dispatch,
10
+ type ReactNode,
11
+ type RefObject,
12
+ type SetStateAction,
13
+ } from "react";
14
+ import { StyleSheet, View, type ViewProps } from "react-native";
15
+ import { loadBlur } from "./blur";
16
+
17
+ type BlurTargetRef = RefObject<ComponentRef<typeof View> | null>;
18
+
19
+ type GlassBlurContextValue = {
20
+ target: BlurTargetRef | null;
21
+ setTarget: Dispatch<SetStateAction<BlurTargetRef | null>> | null;
22
+ };
23
+
24
+ const NO_TARGET: GlassBlurContextValue = { target: null, setTarget: null };
25
+
26
+ const GlassBlurContext = createContext<GlassBlurContextValue>(NO_TARGET);
27
+
28
+ /**
29
+ * The mounted `GlassBlurTarget` that floating glass blurs on Android, or
30
+ * `null` outside a `GlassBlurProvider` and inside the target itself.
31
+ */
32
+ export function useGlassBlurTarget() {
33
+ return useContext(GlassBlurContext).target;
34
+ }
35
+
36
+ export function GlassBlurProvider({ children }: { children: ReactNode }) {
37
+ const [target, setTarget] = useState<BlurTargetRef | null>(null);
38
+ const value = useMemo(() => ({ target, setTarget }), [target]);
39
+
40
+ return (
41
+ <GlassBlurContext.Provider value={value}>{children}</GlassBlurContext.Provider>
42
+ );
43
+ }
44
+
45
+ export type GlassBlurTargetProps = ViewProps & {
46
+ /** Set `false` while the content is hidden, such as an unfocused tab. */
47
+ active?: boolean;
48
+ };
49
+
50
+ /**
51
+ * Content that floating glass blurs on Android (an expo-blur
52
+ * `BlurTargetView`). Render the floating chrome outside it, inside the same
53
+ * `GlassBlurProvider`: a blur view must not sit inside its own target, so
54
+ * surfaces in here get no target. The last active target wins.
55
+ */
56
+ export function GlassBlurTarget({
57
+ active = true,
58
+ style,
59
+ children,
60
+ ...props
61
+ }: GlassBlurTargetProps) {
62
+ const { setTarget } = useContext(GlassBlurContext);
63
+ const ref = useRef<ComponentRef<typeof View>>(null);
64
+ const BlurTargetView = loadBlur()?.BlurTargetView;
65
+
66
+ // Publish the ref only once it is attached: expo-blur compares
67
+ // `blurTarget.current` between renders to pick up a new target.
68
+ useLayoutEffect(() => {
69
+ if (!setTarget || !active) {
70
+ return;
71
+ }
72
+ setTarget(ref);
73
+ return () => setTarget((current) => (current === ref ? null : current));
74
+ }, [setTarget, active]);
75
+
76
+ const content = (
77
+ <GlassBlurContext.Provider value={NO_TARGET}>{children}</GlassBlurContext.Provider>
78
+ );
79
+
80
+ return BlurTargetView ? (
81
+ <BlurTargetView ref={ref} style={[styles.fill, style]} {...props}>
82
+ {content}
83
+ </BlurTargetView>
84
+ ) : (
85
+ <View ref={ref} style={[styles.fill, style]} {...props}>
86
+ {content}
87
+ </View>
88
+ );
89
+ }
90
+
91
+ const styles = StyleSheet.create({
92
+ fill: {
93
+ flex: 1,
94
+ },
95
+ });
@@ -0,0 +1,21 @@
1
+ import type * as ExpoLinearGradient from "expo-linear-gradient";
2
+
3
+ export type ExpoLinearGradientModule = typeof ExpoLinearGradient;
4
+
5
+ let expoLinearGradient: ExpoLinearGradientModule | null | undefined;
6
+
7
+ /**
8
+ * `expo-linear-gradient`, or `null` when the optional peer is not installed.
9
+ * Metro bundles a `require` placed directly in a `try` block as an optional
10
+ * dependency, so a missing peer does not break the bundle.
11
+ */
12
+ export function loadLinearGradient(): ExpoLinearGradientModule | null {
13
+ if (expoLinearGradient === undefined) {
14
+ try {
15
+ expoLinearGradient = require("expo-linear-gradient") as ExpoLinearGradientModule;
16
+ } catch {
17
+ expoLinearGradient = null;
18
+ }
19
+ }
20
+ return expoLinearGradient;
21
+ }
package/src/haptics.ts CHANGED
@@ -1,9 +1,29 @@
1
1
  import { Platform } from "react-native";
2
- import * as Haptics from "expo-haptics";
2
+ import type * as ExpoHaptics from "expo-haptics";
3
+
4
+ export type ExpoHapticsModule = typeof ExpoHaptics;
5
+
6
+ let expoHaptics: ExpoHapticsModule | null | undefined;
7
+
8
+ /**
9
+ * `expo-haptics`, or `null` when the optional peer is not installed. Metro
10
+ * bundles a `require` placed directly in a `try` block as an optional
11
+ * dependency, so a missing peer does not break the bundle.
12
+ */
13
+ export function loadHaptics(): ExpoHapticsModule | null {
14
+ if (expoHaptics === undefined) {
15
+ try {
16
+ expoHaptics = require("expo-haptics") as ExpoHapticsModule;
17
+ } catch {
18
+ expoHaptics = null;
19
+ }
20
+ }
21
+ return expoHaptics;
22
+ }
3
23
 
4
24
  export function selectionHaptic() {
5
25
  if (Platform.OS === "web") {
6
26
  return;
7
27
  }
8
- void Haptics.selectionAsync();
28
+ void loadHaptics()?.selectionAsync();
9
29
  }