@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.
- package/README.md +160 -9
- package/package.json +88 -8
- package/src/AlertDialog.tsx +73 -0
- package/src/AmbientBackground.tsx +26 -11
- package/src/Avatar.tsx +141 -0
- package/src/Badge.tsx +108 -0
- package/src/BottomSheetContainer.tsx +7 -1
- package/src/Button.tsx +153 -52
- package/src/Card.tsx +111 -36
- package/src/Checkbox.tsx +78 -0
- package/src/Chip.tsx +5 -3
- package/src/ConfirmDialog.tsx +105 -0
- package/src/DateTimeField.tsx +6 -2
- package/src/Dialog.tsx +253 -0
- package/src/EmptyState.tsx +122 -7
- package/src/FloatingFAB.tsx +2 -4
- package/src/FloatingSurface.tsx +31 -9
- package/src/FloatingTabBar.tsx +39 -11
- package/src/Input.tsx +15 -4
- package/src/Label.tsx +31 -0
- package/src/LabeledSwitchRow.tsx +96 -0
- package/src/LiquidGlass.tsx +2 -10
- package/src/ListRow.tsx +7 -2
- package/src/PillTabs.tsx +28 -14
- package/src/Progress.tsx +67 -0
- package/src/SearchInput.tsx +163 -0
- package/src/Select.tsx +32 -6
- package/src/Separator.tsx +38 -0
- package/src/Sheet.tsx +107 -0
- package/src/Skeleton.tsx +25 -5
- package/src/Spinner.tsx +92 -0
- package/src/StatusDot.tsx +66 -0
- package/src/Switch.tsx +24 -7
- package/src/Tabs.tsx +190 -0
- package/src/Textarea.tsx +47 -0
- package/src/Toast.tsx +170 -0
- package/src/blur.ts +48 -0
- package/src/brand/AppIcons.tsx +106 -0
- package/src/brand/AppMark.tsx +83 -0
- package/src/brand/AppWordmark.tsx +97 -0
- package/src/brand/familyApps.ts +24 -0
- package/src/brand/index.ts +34 -0
- package/src/brand/svg.ts +263 -0
- package/src/brand/vProAppBenefits.ts +22 -0
- package/src/controlStyles.ts +360 -0
- package/src/dialogLayout.ts +20 -0
- package/src/entries/ambient.ts +3 -0
- package/src/entries/bottom-sheet.ts +2 -0
- package/src/entries/brand.ts +2 -0
- package/src/entries/button.ts +3 -0
- package/src/entries/date-time.ts +3 -0
- package/src/entries/floating.ts +24 -0
- package/src/entries/layout.ts +7 -0
- package/src/entries/marketing.ts +10 -0
- package/src/entries/primitives.ts +99 -0
- package/src/entries/search-input.ts +4 -0
- package/src/entries/tab-bar.ts +10 -0
- package/src/entries/theme.ts +15 -0
- package/src/entries/toast.ts +6 -0
- package/src/examples/ParityGallery.tsx +496 -0
- package/src/floating.ts +5 -1
- package/src/glassBlur.tsx +95 -0
- package/src/gradient.ts +21 -0
- package/src/haptics.ts +22 -2
- package/src/index.ts +99 -2
- package/src/initials.ts +10 -0
- package/src/marketing/PricingModal.tsx +410 -0
- package/src/marketing/UpgradeToProCard.tsx +103 -0
- package/src/marketing/pricing.ts +76 -0
- package/src/palette.ts +43 -0
- package/src/primitives/input.tsx +9 -2
- package/src/primitives/native-only-animated-view.tsx +10 -3
- package/src/primitives/overlay.tsx +179 -0
- package/src/primitives/select.tsx +25 -12
- package/src/primitives/switch.tsx +12 -13
- package/src/primitives/use-controllable-state.ts +24 -0
- package/src/settings/SettingsField.tsx +11 -2
- package/src/settings/SettingsSection.tsx +20 -6
- package/src/settings/SettingsToggleRow.tsx +7 -2
- package/src/tabBarRoutes.ts +52 -0
- package/src/toastChrome.ts +103 -0
- package/src/toastStore.ts +86 -0
- package/src/tokens.ts +19 -14
- package/src/typography.ts +24 -0
- package/src/useReduceMotion.ts +24 -0
- 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
|
+
}
|
package/src/DateTimeField.tsx
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/EmptyState.tsx
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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={
|
|
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
|
|
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
|
|
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
|
+
});
|
package/src/FloatingFAB.tsx
CHANGED
|
@@ -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
|
>
|
package/src/FloatingSurface.tsx
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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"
|