@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,105 @@
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ AlertDialog,
4
+ AlertDialogAction,
5
+ AlertDialogCancel,
6
+ AlertDialogContent,
7
+ AlertDialogDescription,
8
+ AlertDialogFooter,
9
+ AlertDialogHeader,
10
+ AlertDialogTitle,
11
+ } from "./AlertDialog";
12
+ import { useOverlay } from "./primitives/overlay";
13
+
14
+ export type ConfirmDialogProps = {
15
+ open?: boolean;
16
+ defaultOpen?: boolean;
17
+ onOpenChange?: (open: boolean) => void;
18
+ title: ReactNode;
19
+ description?: ReactNode;
20
+ /** Default "Cancel". */
21
+ cancelLabel?: ReactNode;
22
+ /** Default "Confirm". */
23
+ confirmLabel?: ReactNode;
24
+ onCancel?: () => void;
25
+ onConfirm?: () => void;
26
+ /** `"destructive"` paints the confirm button red. Default `"default"`. */
27
+ variant?: "default" | "destructive";
28
+ confirmDisabled?: boolean;
29
+ /** Usually an `AlertDialogTrigger`. */
30
+ children?: ReactNode;
31
+ testID?: string;
32
+ };
33
+
34
+ /**
35
+ * Blocking confirm dialog that closes on either button: the web `ConfirmDialog`
36
+ * legacy props with `size="default"`. The `sm` / `md` sizes and the composable
37
+ * `ConfirmDialog.*` parts are web-only; compose `AlertDialog` parts instead.
38
+ */
39
+ export function ConfirmDialog({
40
+ open,
41
+ defaultOpen,
42
+ onOpenChange,
43
+ title,
44
+ description,
45
+ cancelLabel = "Cancel",
46
+ confirmLabel = "Confirm",
47
+ onCancel,
48
+ onConfirm,
49
+ variant = "default",
50
+ confirmDisabled,
51
+ children,
52
+ testID,
53
+ }: ConfirmDialogProps) {
54
+ return (
55
+ <AlertDialog open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
56
+ {children}
57
+ <AlertDialogContent testID={testID}>
58
+ <AlertDialogHeader>
59
+ <AlertDialogTitle>{title}</AlertDialogTitle>
60
+ {description != null ? (
61
+ <AlertDialogDescription>{description}</AlertDialogDescription>
62
+ ) : null}
63
+ </AlertDialogHeader>
64
+ <AlertDialogFooter>
65
+ <AlertDialogCancel
66
+ testID={testID ? `${testID}-cancel` : undefined}
67
+ onPress={onCancel}
68
+ >
69
+ {cancelLabel}
70
+ </AlertDialogCancel>
71
+ <ConfirmAction
72
+ testID={testID ? `${testID}-confirm` : undefined}
73
+ variant={variant}
74
+ disabled={confirmDisabled}
75
+ onConfirm={onConfirm}
76
+ >
77
+ {confirmLabel}
78
+ </ConfirmAction>
79
+ </AlertDialogFooter>
80
+ </AlertDialogContent>
81
+ </AlertDialog>
82
+ );
83
+ }
84
+
85
+ function ConfirmAction({
86
+ onConfirm,
87
+ ...props
88
+ }: {
89
+ onConfirm?: () => void;
90
+ variant: "default" | "destructive";
91
+ disabled?: boolean;
92
+ testID?: string;
93
+ children: ReactNode;
94
+ }) {
95
+ const { setOpen } = useOverlay("ConfirmDialog");
96
+ return (
97
+ <AlertDialogAction
98
+ {...props}
99
+ onPress={() => {
100
+ onConfirm?.();
101
+ setOpen(false);
102
+ }}
103
+ />
104
+ );
105
+ }
@@ -4,7 +4,9 @@ import DateTimePicker, {
4
4
  type DateTimePickerEvent,
5
5
  } from "@react-native-community/datetimepicker";
6
6
  import dayjs from "dayjs";
7
+ import { textStyle, typeStyle } from "./controlStyles";
7
8
  import { useLargerText } from "./theme";
9
+ import { useScheme } from "./useScheme";
8
10
 
9
11
  type PickerMode = "date" | "time";
10
12
  type PickerButton = { mode: PickerMode; label: string };
@@ -23,6 +25,7 @@ export function DateTimeField({
23
25
  testID?: string;
24
26
  }) {
25
27
  const largerText = useLargerText();
28
+ const scheme = useScheme();
26
29
  const [picker, setPicker] = useState<PickerMode | null>(null);
27
30
 
28
31
  const handleChange = (event: DateTimePickerEvent, next?: Date) => {
@@ -53,10 +56,11 @@ export function DateTimeField({
53
56
  current === button.mode ? null : button.mode,
54
57
  )
55
58
  }
56
- className={`${largerText ? "h-16" : "h-14"} flex-1 justify-center rounded-lg bg-secondary px-4 active:opacity-70`}
59
+ className={`${largerText ? "min-h-16" : "min-h-14"} flex-1 justify-center rounded-lg bg-secondary px-4 py-2 active:opacity-70`}
57
60
  >
58
61
  <Text
59
- className={`${largerText ? "text-xl" : "text-lg"} font-instrument-sans-semibold text-foreground`}
62
+ className={`${largerText ? "text-xl" : "text-lg"} font-instrument-sans-semibold`}
63
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
60
64
  >
61
65
  {button.label}
62
66
  </Text>
package/src/Dialog.tsx ADDED
@@ -0,0 +1,253 @@
1
+ import {
2
+ ScrollView,
3
+ Text,
4
+ View,
5
+ useWindowDimensions,
6
+ type ScrollViewProps,
7
+ type TextProps,
8
+ type ViewProps,
9
+ } from "react-native";
10
+ import { SlideInDown } from "react-native-reanimated";
11
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
12
+ import { IconX } from "@tabler/icons-react-native";
13
+ import { FloatingSurface } from "./FloatingSurface";
14
+ import { cn } from "./cn";
15
+ import { isCompactOverlay, textStyle, typeStyle } from "./controlStyles";
16
+ import { DialogLayoutContext, useDialogLayout, type DialogLayout } from "./dialogLayout";
17
+ import { palette } from "./palette";
18
+ import { tokens } from "./tokens";
19
+ import { useLargerText } from "./theme";
20
+ import { useScheme } from "./useScheme";
21
+ import { NativeOnlyAnimatedView } from "./primitives/native-only-animated-view";
22
+ import {
23
+ OverlayClose,
24
+ OverlayModal,
25
+ OverlayRoot,
26
+ OverlayTrigger,
27
+ type OverlayPressableProps,
28
+ type OverlayRootProps,
29
+ } from "./primitives/overlay";
30
+
31
+ export type DialogProps = OverlayRootProps;
32
+
33
+ /** Holds the open state (controlled with `open` / `onOpenChange`, or `defaultOpen`). Renders no view. */
34
+ export function Dialog(props: DialogProps) {
35
+ return <OverlayRoot {...props} />;
36
+ }
37
+
38
+ /** Opens the dialog. `asChild` merges the press into its child, for example a `Button`. */
39
+ export function DialogTrigger(props: OverlayPressableProps) {
40
+ return <OverlayTrigger part="DialogTrigger" {...props} />;
41
+ }
42
+
43
+ /** Closes the dialog. `asChild` merges the press into its child. */
44
+ export function DialogClose(props: OverlayPressableProps) {
45
+ return <OverlayClose part="DialogClose" {...props} />;
46
+ }
47
+
48
+ export type DialogContentProps = ViewProps & {
49
+ className?: string;
50
+ /** Hide the close button in the top-right corner. Default `false`. */
51
+ hideCloseButton?: boolean;
52
+ /** Close on a backdrop tap. Default `true`. `AlertDialogContent` sets `false`. */
53
+ dismissible?: boolean;
54
+ /**
55
+ * A bottom sheet in a compact window (639 wide or less, or 500 high or less),
56
+ * as the web `viewportAware`. Default `true`. `false` keeps the centred panel
57
+ * at every size.
58
+ */
59
+ viewportAware?: boolean;
60
+ };
61
+
62
+ /** Web `rounded-t-2xl` (16) on the compact sheet. */
63
+ const SHEET_RADIUS = tokens.radius.lg;
64
+
65
+ /** The centred panel: at most 480 wide. */
66
+ const PANEL = { width: "100%", maxWidth: 480, alignSelf: "center" } as const;
67
+ /** The compact sheet: its bottom corners sit below the screen edge. */
68
+ const SHEET = { marginBottom: -SHEET_RADIUS };
69
+
70
+ const CENTRED: DialogLayout = { compact: false, stacked: false };
71
+ const COMPACT: DialogLayout = { compact: true, stacked: false };
72
+ const STACKED: DialogLayout = { compact: true, stacked: true };
73
+
74
+ /**
75
+ * Centred panel in an RN `Modal`: opaque surface (card radius 24, floating
76
+ * shadow, no blur), at most 480 wide, padding 24 as on the web. Android back
77
+ * closes it.
78
+ *
79
+ * In a compact window (the web `COMPACT_OVERLAY_QUERY`, from the window size)
80
+ * it is the web phone bottom sheet: full width, top corners 16, a handle,
81
+ * `pt-3 px-6`, the bottom safe-area inset (at least 24) below, and it slides
82
+ * up unless Reduce Motion is on. Its bottom corners sit below the screen edge,
83
+ * as `SheetContent`'s do. `DialogFooter` stacks its buttons there.
84
+ */
85
+ export function DialogContent({
86
+ children,
87
+ className,
88
+ hideCloseButton = false,
89
+ dismissible = true,
90
+ viewportAware = true,
91
+ style,
92
+ ...props
93
+ }: DialogContentProps) {
94
+ const { width, height } = useWindowDimensions();
95
+ const insets = useSafeAreaInsets();
96
+ const compact = viewportAware && isCompactOverlay(width, height);
97
+
98
+ // One tree for both layouts, so a window that crosses the compact size while
99
+ // open (split view, a foldable) keeps the content and its state.
100
+ return (
101
+ <DialogLayoutContext.Provider value={compact ? COMPACT : CENTRED}>
102
+ <OverlayModal
103
+ part="DialogContent"
104
+ placement={compact ? "bottom" : "center"}
105
+ dismissible={dismissible}
106
+ >
107
+ <NativeOnlyAnimatedView
108
+ entering={compact ? SlideInDown.duration(240) : undefined}
109
+ style={compact ? SHEET : PANEL}
110
+ >
111
+ <FloatingSurface blur={false} radius={compact ? SHEET_RADIUS : tokens.radius.card}>
112
+ <View
113
+ accessibilityViewIsModal
114
+ className={cn(compact ? "gap-4 px-6 pt-3" : "gap-4 p-6", className)}
115
+ {...props}
116
+ style={[
117
+ compact ? { paddingBottom: Math.max(24, insets.bottom) + SHEET_RADIUS } : null,
118
+ style,
119
+ ]}
120
+ >
121
+ {compact ? <DialogHandle /> : null}
122
+ {children}
123
+ </View>
124
+ {hideCloseButton ? null : <DialogCloseButton />}
125
+ </FloatingSurface>
126
+ </NativeOnlyAnimatedView>
127
+ </OverlayModal>
128
+ </DialogLayoutContext.Provider>
129
+ );
130
+ }
131
+
132
+ /**
133
+ * The web compact handle: an `h-1 w-12` pill in `surface-tertiary`, in a
134
+ * 44-high row. It is not a drag target, on the web either.
135
+ */
136
+ function DialogHandle() {
137
+ const scheme = useScheme();
138
+ return (
139
+ <View
140
+ accessibilityElementsHidden
141
+ importantForAccessibility="no-hide-descendants"
142
+ className="mb-1 min-h-11 items-center justify-center"
143
+ >
144
+ <View
145
+ className="h-1 w-12 rounded-full"
146
+ style={{ backgroundColor: palette.surfaceTertiary[scheme] }}
147
+ />
148
+ </View>
149
+ );
150
+ }
151
+
152
+ function DialogCloseButton() {
153
+ const largerText = useLargerText();
154
+ const scheme = useScheme();
155
+ return (
156
+ <DialogClose
157
+ testID="dialog-close"
158
+ accessibilityLabel="Close"
159
+ hitSlop={8}
160
+ className="absolute right-2 top-2 h-11 w-11 items-center justify-center rounded-lg active:opacity-70"
161
+ >
162
+ <IconX size={largerText ? 26 : 16} color={tokens.color.muted[scheme]} />
163
+ </DialogClose>
164
+ );
165
+ }
166
+
167
+ type PartProps = ViewProps & { className?: string };
168
+ type TextPartProps = TextProps & { className?: string };
169
+
170
+ export function DialogHeader({ className, ...props }: PartProps) {
171
+ return <View className={cn("gap-1.5 pr-10", className)} {...props} />;
172
+ }
173
+
174
+ // Text colours are styles (`textStyle`), so they follow the colour scheme in
175
+ // apps whose `text-foreground` does not. Recolour with `style`, not `className`.
176
+
177
+ /** Web `text-lg font-semibold`. */
178
+ export function DialogTitle({ className, style, ...props }: TextPartProps) {
179
+ const largerText = useLargerText();
180
+ const scheme = useScheme();
181
+ return (
182
+ <Text
183
+ accessibilityRole="header"
184
+ className={cn(
185
+ "font-instrument-sans-semibold",
186
+ largerText ? "text-2xl" : "text-lg",
187
+ className,
188
+ )}
189
+ style={[textStyle("foreground", scheme), typeStyle("semibold"), style]}
190
+ {...props}
191
+ />
192
+ );
193
+ }
194
+
195
+ /** Web `text-sm leading-relaxed`, in the web description colour (`palette.overlayMuted`). */
196
+ export function DialogDescription({ className, style, ...props }: TextPartProps) {
197
+ const largerText = useLargerText();
198
+ const scheme = useScheme();
199
+ return (
200
+ <Text
201
+ className={cn(
202
+ "font-instrument-sans leading-relaxed",
203
+ largerText ? "text-lg" : "text-sm",
204
+ className,
205
+ )}
206
+ style={[textStyle("overlayMuted", scheme), style]}
207
+ {...props}
208
+ />
209
+ );
210
+ }
211
+
212
+ /** Scrolling body for long content. Height is capped at `maxHeight` (default 60% of the window). */
213
+ export function DialogBody({
214
+ maxHeight,
215
+ style,
216
+ ...props
217
+ }: ScrollViewProps & { maxHeight?: number }) {
218
+ const { height } = useWindowDimensions();
219
+ return (
220
+ <ScrollView
221
+ bounces={false}
222
+ keyboardShouldPersistTaps="handled"
223
+ style={[{ maxHeight: maxHeight ?? height * 0.6 }, style]}
224
+ {...props}
225
+ />
226
+ );
227
+ }
228
+
229
+ /**
230
+ * Buttons in a row at the end, as the web footer from `sm` up (Cancel, then
231
+ * Save). Give each `Button` `fullWidth={false}`; `AlertDialogAction` and
232
+ * `AlertDialogCancel` already do. Wraps when the labels do not fit.
233
+ *
234
+ * In the compact sheet it is the web phone footer: the buttons stack, the last
235
+ * child on top, each full width (whatever `fullWidth` says) and at least 44
236
+ * high.
237
+ */
238
+ export function DialogFooter({ className, ...props }: PartProps) {
239
+ const { compact } = useDialogLayout();
240
+ if (compact) {
241
+ return (
242
+ <DialogLayoutContext.Provider value={STACKED}>
243
+ <View className={cn("flex-col-reverse gap-2 pt-2", className)} {...props} />
244
+ </DialogLayoutContext.Provider>
245
+ );
246
+ }
247
+ return (
248
+ <View
249
+ className={cn("flex-row flex-wrap items-center justify-end gap-2", className)}
250
+ {...props}
251
+ />
252
+ );
253
+ }
@@ -1,11 +1,14 @@
1
- import type { ComponentType, ReactNode } from "react";
2
- import { View, Text } from "react-native";
1
+ import { createContext, useContext, type ComponentType, type ReactNode } from "react";
2
+ import { View, Text, type TextProps, type ViewProps } from "react-native";
3
3
  import { IconInbox, type IconProps } from "@tabler/icons-react-native";
4
- import { NEUTRAL_500 } from "./colors";
5
4
  import { cn } from "./cn";
5
+ import { textStyle, typeStyle } from "./controlStyles";
6
+ import { palette } from "./palette";
7
+ import { tokens } from "./tokens";
6
8
  import { useLargerText } from "./theme";
9
+ import { useScheme } from "./useScheme";
7
10
 
8
- export function EmptyState({
11
+ function EmptyStateView({
9
12
  title,
10
13
  description,
11
14
  icon: Icon = IconInbox,
@@ -21,26 +24,29 @@ export function EmptyState({
21
24
  testID?: string;
22
25
  }) {
23
26
  const largerText = useLargerText();
27
+ const scheme = useScheme();
24
28
  return (
25
29
  <View
26
30
  testID={testID}
27
31
  className={`items-center px-4 ${compact ? "py-6" : "py-16"}`}
28
32
  >
29
- <Icon size={largerText ? 44 : 40} color={NEUTRAL_500} />
33
+ <Icon size={largerText ? 44 : 40} color={tokens.color.muted[scheme]} />
30
34
  <Text
31
35
  className={cn(
32
- "mt-4 text-center font-instrument-sans-semibold text-foreground",
36
+ "mt-4 text-center font-instrument-sans-semibold",
33
37
  largerText ? "text-xl" : "text-lg",
34
38
  )}
39
+ style={[textStyle("foreground", scheme), typeStyle("semibold")]}
35
40
  >
36
41
  {title}
37
42
  </Text>
38
43
  {description ? (
39
44
  <Text
40
45
  className={cn(
41
- "mt-2 max-w-sm text-center font-instrument-sans text-muted-foreground",
46
+ "mt-2 max-w-sm text-center font-instrument-sans",
42
47
  largerText ? "text-lg" : "text-base",
43
48
  )}
49
+ style={textStyle("muted", scheme)}
44
50
  >
45
51
  {description}
46
52
  </Text>
@@ -49,3 +55,112 @@ export function EmptyState({
49
55
  </View>
50
56
  );
51
57
  }
58
+
59
+ // Composable parts with the web names (`EmptyState.Root`, `.Icon`, `.Title`,
60
+ // `.Description`, `.Actions`). `Root` sets the variant, the parts read it.
61
+
62
+ /** `default`: open centred block with a large bare icon. `panel`: filled rounded block, icon in a tile. */
63
+ export type EmptyStateVariant = "default" | "panel";
64
+
65
+ const EmptyStateContext = createContext<EmptyStateVariant>("default");
66
+
67
+ function Root({
68
+ variant = "default",
69
+ className,
70
+ ...props
71
+ }: ViewProps & { variant?: EmptyStateVariant; className?: string }) {
72
+ return (
73
+ <EmptyStateContext.Provider value={variant}>
74
+ <View
75
+ className={cn(
76
+ "items-center justify-center",
77
+ variant === "panel"
78
+ ? "rounded-[20px] bg-surface-secondary px-4 py-10"
79
+ : "px-4 py-16",
80
+ className,
81
+ )}
82
+ {...props}
83
+ />
84
+ </EmptyStateContext.Provider>
85
+ );
86
+ }
87
+
88
+ function Icon({
89
+ icon: IconComponent,
90
+ size,
91
+ stroke,
92
+ className,
93
+ }: {
94
+ icon: ComponentType<IconProps>;
95
+ /** Default 40 (`default`) or 22 (`panel`). */
96
+ size?: number;
97
+ /** Line width, as on the web. Default the icon's own (`default`) or 1.5 (`panel`). Passed to Tabler as `strokeWidth`, since native Tabler reads `stroke` as the colour. */
98
+ stroke?: number;
99
+ className?: string;
100
+ }) {
101
+ const variant = useContext(EmptyStateContext);
102
+ const scheme = useScheme();
103
+ if (variant === "panel") {
104
+ return (
105
+ <View
106
+ className={cn("mb-3 h-11 w-11 items-center justify-center rounded-xl", className)}
107
+ style={{ backgroundColor: palette.surfaceTertiary[scheme] }}
108
+ >
109
+ <IconComponent size={size ?? 22} strokeWidth={stroke ?? 1.5} color={tokens.color.muted[scheme]} />
110
+ </View>
111
+ );
112
+ }
113
+ return (
114
+ <View className={cn("mb-4", className)}>
115
+ <IconComponent size={size ?? 40} strokeWidth={stroke} color={tokens.color.muted[scheme]} />
116
+ </View>
117
+ );
118
+ }
119
+
120
+ function Title({ className, style, ...props }: TextProps & { className?: string }) {
121
+ const largerText = useLargerText();
122
+ const scheme = useScheme();
123
+ return (
124
+ <Text
125
+ className={cn(
126
+ "text-center font-instrument-sans-semibold",
127
+ largerText ? "text-xl" : "text-lg",
128
+ className,
129
+ )}
130
+ style={[textStyle("foreground", scheme), typeStyle("semibold"), style]}
131
+ {...props}
132
+ />
133
+ );
134
+ }
135
+
136
+ function Description({ className, style, ...props }: TextProps & { className?: string }) {
137
+ const largerText = useLargerText();
138
+ const scheme = useScheme();
139
+ return (
140
+ <Text
141
+ className={cn(
142
+ "mt-2 max-w-sm text-center font-instrument-sans",
143
+ largerText ? "text-lg" : "text-base",
144
+ className,
145
+ )}
146
+ style={[textStyle("muted", scheme), style]}
147
+ {...props}
148
+ />
149
+ );
150
+ }
151
+
152
+ function Actions({ className, ...props }: ViewProps & { className?: string }) {
153
+ return <View className={cn("mt-6", className)} {...props} />;
154
+ }
155
+
156
+ /**
157
+ * "Nothing here yet" block. Call it with `title`, `description`, `icon` and
158
+ * `action`, or compose `EmptyState.Root` and its parts as on the web.
159
+ */
160
+ export const EmptyState = Object.assign(EmptyStateView, {
161
+ Root,
162
+ Icon,
163
+ Title,
164
+ Description,
165
+ Actions,
166
+ });
@@ -32,11 +32,9 @@ export function FloatingFAB({
32
32
  return (
33
33
  <View
34
34
  pointerEvents="box-none"
35
- className={cn(
36
- "right-4 z-10",
37
- Platform.OS === "web" ? "fixed" : "absolute",
38
- )}
35
+ className={cn("z-10", Platform.OS === "web" ? "fixed" : "absolute")}
39
36
  style={{
37
+ right: insets.right + FLOATING_INSET,
40
38
  bottom: FLOATING_INSET + Math.max(insets.bottom, 8) + tabBar + 20,
41
39
  }}
42
40
  >
@@ -1,6 +1,6 @@
1
1
  import { Platform, StyleSheet, View, type ViewProps } from "react-native";
2
- import { BlurView } from "expo-blur";
3
2
  import { useColorScheme } from "nativewind";
3
+ import { glassMode, loadBlur } from "./blur";
4
4
  import { cn } from "./cn";
5
5
  import {
6
6
  floatingFill,
@@ -9,6 +9,7 @@ import {
9
9
  floatingSolidFill,
10
10
  FLOATING_RADIUS,
11
11
  } from "./floating";
12
+ import { useGlassBlurTarget } from "./glassBlur";
12
13
  import { useReduceTransparency } from "./useReduceTransparency";
13
14
 
14
15
  type FloatingSurfaceProps = ViewProps & {
@@ -29,15 +30,19 @@ export function FloatingSurface({
29
30
  }: FloatingSurfaceProps) {
30
31
  const { colorScheme } = useColorScheme();
31
32
  const reduceTransparency = useReduceTransparency();
33
+ const blurTarget = useGlassBlurTarget();
34
+ const BlurView = loadBlur()?.BlurView;
35
+ const mode = glassMode({
36
+ os: Platform.OS,
37
+ blur,
38
+ reduceTransparency,
39
+ hasBlurView: BlurView != null,
40
+ });
32
41
  const isDark = colorScheme === "dark";
33
42
  const opaqueFill = isDark ? floatingSolidFill.dark : floatingSolidFill.light;
34
43
  const translucentFill = isDark ? floatingFill.dark : floatingFill.light;
35
44
  const border = isDark ? floatingHairline.dark : floatingHairline.light;
36
- const useBlur = blur && !reduceTransparency && Platform.OS === "ios";
37
- const fill =
38
- Platform.OS === "android" || reduceTransparency || !blur
39
- ? opaqueFill
40
- : translucentFill;
45
+ const fill = mode === "solid" ? opaqueFill : translucentFill;
41
46
 
42
47
  return (
43
48
  <View
@@ -57,21 +62,38 @@ export function FloatingSurface({
57
62
  borderRadius: radius,
58
63
  borderWidth: StyleSheet.hairlineWidth,
59
64
  borderColor: border,
60
- backgroundColor: useBlur ? "transparent" : fill,
65
+ backgroundColor: mode === "blur" ? "transparent" : fill,
61
66
  },
62
67
  Platform.OS === "ios" ? styles.continuous : null,
63
68
  style != null ? styles.fill : null,
64
69
  ]}
65
70
  >
66
- {useBlur ? (
71
+ {mode === "blur" && BlurView ? (
72
+ // Android: dimezis blur of the `GlassBlurTarget` content (without
73
+ // one, expo-blur draws only the tint). `experimentalBlurMethod` is
74
+ // the pre-SDK 55 name and logs a deprecation warning, so iOS skips it.
67
75
  <BlurView
68
76
  pointerEvents="none"
69
77
  tint={isDark ? "systemMaterialDark" : "systemMaterialLight"}
70
78
  intensity={70}
79
+ experimentalBlurMethod={
80
+ Platform.OS === "android" ? "dimezisBlurView" : undefined
81
+ }
82
+ blurMethod="dimezisBlurView"
83
+ blurReductionFactor={3}
84
+ blurTarget={blurTarget ?? undefined}
71
85
  style={StyleSheet.absoluteFill}
72
86
  />
73
87
  ) : null}
74
- {Platform.OS === "web" && blur && !reduceTransparency ? (
88
+ {mode === "blur" && Platform.OS === "android" ? (
89
+ // The iOS backdrop includes the outer fill; dimezis samples only the
90
+ // target, so Android lays the same fill over the blur.
91
+ <View
92
+ pointerEvents="none"
93
+ style={[StyleSheet.absoluteFill, { backgroundColor: translucentFill }]}
94
+ />
95
+ ) : null}
96
+ {mode === "backdrop" ? (
75
97
  <View
76
98
  pointerEvents="none"
77
99
  className="absolute inset-0 backdrop-blur-xl"