@vvedantb/native-ui 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +89 -2
- package/package.json +72 -3
- package/src/AmbientBackground.tsx +29 -0
- package/src/BottomSheetContainer.tsx +92 -0
- package/src/Button.tsx +111 -0
- package/src/Card.tsx +64 -0
- package/src/Chip.tsx +49 -0
- package/src/DateTimeField.tsx +78 -0
- package/src/EmptyState.tsx +51 -0
- package/src/FloatingFAB.tsx +62 -0
- package/src/FloatingSurface.tsx +114 -0
- package/src/FloatingTabBar.tsx +107 -0
- package/src/HeaderIconButton.tsx +29 -0
- package/src/Input.tsx +32 -0
- package/src/LiquidGlass.tsx +14 -0
- package/src/ListRow.tsx +85 -0
- package/src/PillTabs.tsx +51 -0
- package/src/SafeAreaContainer.tsx +26 -0
- package/src/ScreenContainer.tsx +36 -0
- package/src/Select.tsx +132 -0
- package/src/Skeleton.tsx +37 -0
- package/src/Switch.tsx +45 -0
- package/src/cn.ts +6 -0
- package/src/colors.ts +17 -0
- package/src/floating.ts +40 -0
- package/src/haptics.ts +9 -0
- package/src/index.ts +58 -0
- package/src/primitives/button.tsx +101 -0
- package/src/primitives/card.tsx +102 -0
- package/src/primitives/icon.tsx +14 -0
- package/src/primitives/input.tsx +37 -0
- package/src/primitives/native-only-animated-view.tsx +33 -0
- package/src/primitives/select.tsx +265 -0
- package/src/primitives/switch.tsx +77 -0
- package/src/primitives/text.tsx +95 -0
- package/src/settings/SettingsField.tsx +33 -0
- package/src/settings/SettingsSection.tsx +75 -0
- package/src/settings/SettingsStack.tsx +5 -0
- package/src/settings/SettingsToggleRow.tsx +58 -0
- package/src/theme.tsx +44 -0
- package/src/tokens.ts +43 -0
- package/src/useReduceTransparency.ts +24 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Platform, Pressable, View } from "react-native";
|
|
3
|
+
import { cn } from "./cn";
|
|
4
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
5
|
+
import { FloatingSurface } from "./FloatingSurface";
|
|
6
|
+
import { useAccentColor, useLargerText } from "./theme";
|
|
7
|
+
import {
|
|
8
|
+
FLOATING_INSET,
|
|
9
|
+
FLOATING_TAB_BAR_HEIGHT,
|
|
10
|
+
FLOATING_TAB_BAR_HEIGHT_LARGE,
|
|
11
|
+
} from "./floating";
|
|
12
|
+
|
|
13
|
+
export function FloatingFAB({
|
|
14
|
+
onPress,
|
|
15
|
+
label,
|
|
16
|
+
testID,
|
|
17
|
+
children,
|
|
18
|
+
}: {
|
|
19
|
+
onPress: () => void;
|
|
20
|
+
label: string;
|
|
21
|
+
testID?: string;
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
}) {
|
|
24
|
+
const insets = useSafeAreaInsets();
|
|
25
|
+
const largerText = useLargerText();
|
|
26
|
+
const accentColor = useAccentColor();
|
|
27
|
+
const size = largerText ? 64 : 56;
|
|
28
|
+
const tabBar = largerText
|
|
29
|
+
? FLOATING_TAB_BAR_HEIGHT_LARGE
|
|
30
|
+
: FLOATING_TAB_BAR_HEIGHT;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<View
|
|
34
|
+
pointerEvents="box-none"
|
|
35
|
+
className={cn(
|
|
36
|
+
"right-4 z-10",
|
|
37
|
+
Platform.OS === "web" ? "fixed" : "absolute",
|
|
38
|
+
)}
|
|
39
|
+
style={{
|
|
40
|
+
bottom: FLOATING_INSET + Math.max(insets.bottom, 8) + tabBar + 20,
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<FloatingSurface radius={size / 2} blur={false}>
|
|
44
|
+
<Pressable
|
|
45
|
+
testID={testID}
|
|
46
|
+
accessibilityRole="button"
|
|
47
|
+
accessibilityLabel={label}
|
|
48
|
+
onPress={onPress}
|
|
49
|
+
className="items-center justify-center"
|
|
50
|
+
style={({ pressed }) => ({
|
|
51
|
+
width: size,
|
|
52
|
+
height: size,
|
|
53
|
+
backgroundColor: accentColor,
|
|
54
|
+
opacity: pressed ? 0.7 : 1,
|
|
55
|
+
})}
|
|
56
|
+
>
|
|
57
|
+
{children}
|
|
58
|
+
</Pressable>
|
|
59
|
+
</FloatingSurface>
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
|
2
|
+
import { BlurView } from "expo-blur";
|
|
3
|
+
import { useColorScheme } from "nativewind";
|
|
4
|
+
import { cn } from "./cn";
|
|
5
|
+
import {
|
|
6
|
+
floatingFill,
|
|
7
|
+
floatingHairline,
|
|
8
|
+
floatingShadow,
|
|
9
|
+
floatingSolidFill,
|
|
10
|
+
FLOATING_RADIUS,
|
|
11
|
+
} from "./floating";
|
|
12
|
+
import { useReduceTransparency } from "./useReduceTransparency";
|
|
13
|
+
|
|
14
|
+
type FloatingSurfaceProps = ViewProps & {
|
|
15
|
+
className?: string;
|
|
16
|
+
radius?: number;
|
|
17
|
+
blur?: boolean;
|
|
18
|
+
elevated?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function FloatingSurface({
|
|
22
|
+
className,
|
|
23
|
+
style,
|
|
24
|
+
children,
|
|
25
|
+
radius = FLOATING_RADIUS,
|
|
26
|
+
blur = true,
|
|
27
|
+
elevated = true,
|
|
28
|
+
...props
|
|
29
|
+
}: FloatingSurfaceProps) {
|
|
30
|
+
const { colorScheme } = useColorScheme();
|
|
31
|
+
const reduceTransparency = useReduceTransparency();
|
|
32
|
+
const isDark = colorScheme === "dark";
|
|
33
|
+
const opaqueFill = isDark ? floatingSolidFill.dark : floatingSolidFill.light;
|
|
34
|
+
const translucentFill = isDark ? floatingFill.dark : floatingFill.light;
|
|
35
|
+
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;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<View
|
|
44
|
+
className={cn(className)}
|
|
45
|
+
{...props}
|
|
46
|
+
style={[
|
|
47
|
+
{ borderRadius: radius },
|
|
48
|
+
elevated ? floatingShadow : null,
|
|
49
|
+
style,
|
|
50
|
+
{ backgroundColor: fill },
|
|
51
|
+
]}
|
|
52
|
+
>
|
|
53
|
+
<View
|
|
54
|
+
style={[
|
|
55
|
+
styles.clip,
|
|
56
|
+
{
|
|
57
|
+
borderRadius: radius,
|
|
58
|
+
borderWidth: StyleSheet.hairlineWidth,
|
|
59
|
+
borderColor: border,
|
|
60
|
+
backgroundColor: useBlur ? "transparent" : fill,
|
|
61
|
+
},
|
|
62
|
+
Platform.OS === "ios" ? styles.continuous : null,
|
|
63
|
+
style != null ? styles.fill : null,
|
|
64
|
+
]}
|
|
65
|
+
>
|
|
66
|
+
{useBlur ? (
|
|
67
|
+
<BlurView
|
|
68
|
+
pointerEvents="none"
|
|
69
|
+
tint={isDark ? "systemMaterialDark" : "systemMaterialLight"}
|
|
70
|
+
intensity={70}
|
|
71
|
+
style={StyleSheet.absoluteFill}
|
|
72
|
+
/>
|
|
73
|
+
) : null}
|
|
74
|
+
{Platform.OS === "web" && blur && !reduceTransparency ? (
|
|
75
|
+
<View
|
|
76
|
+
pointerEvents="none"
|
|
77
|
+
className="absolute inset-0 backdrop-blur-xl"
|
|
78
|
+
style={{ backgroundColor: translucentFill }}
|
|
79
|
+
/>
|
|
80
|
+
) : null}
|
|
81
|
+
{children}
|
|
82
|
+
</View>
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function GlassSheetBackground({
|
|
88
|
+
style,
|
|
89
|
+
pointerEvents,
|
|
90
|
+
}: {
|
|
91
|
+
style?: ViewProps["style"];
|
|
92
|
+
pointerEvents?: ViewProps["pointerEvents"];
|
|
93
|
+
}) {
|
|
94
|
+
return (
|
|
95
|
+
<FloatingSurface
|
|
96
|
+
style={style}
|
|
97
|
+
pointerEvents={pointerEvents}
|
|
98
|
+
radius={20}
|
|
99
|
+
className="rounded-t-[20px]"
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const styles = StyleSheet.create({
|
|
105
|
+
clip: {
|
|
106
|
+
overflow: "hidden",
|
|
107
|
+
},
|
|
108
|
+
fill: {
|
|
109
|
+
flex: 1,
|
|
110
|
+
},
|
|
111
|
+
continuous: {
|
|
112
|
+
borderCurve: "continuous",
|
|
113
|
+
},
|
|
114
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import { Tabs } from "expo-router";
|
|
5
|
+
import { selectionHaptic } from "./haptics";
|
|
6
|
+
import { FloatingSurface } from "./FloatingSurface";
|
|
7
|
+
import { useLargerText } from "./theme";
|
|
8
|
+
import {
|
|
9
|
+
FLOATING_INSET,
|
|
10
|
+
FLOATING_PILL_RADIUS,
|
|
11
|
+
FLOATING_TAB_BAR_HEIGHT,
|
|
12
|
+
FLOATING_TAB_BAR_HEIGHT_LARGE,
|
|
13
|
+
} from "./floating";
|
|
14
|
+
|
|
15
|
+
type TabBarProps = Parameters<
|
|
16
|
+
NonNullable<ComponentProps<typeof Tabs>["tabBar"]>
|
|
17
|
+
>[0];
|
|
18
|
+
|
|
19
|
+
export function FloatingTabBar({
|
|
20
|
+
state,
|
|
21
|
+
descriptors,
|
|
22
|
+
navigation,
|
|
23
|
+
}: TabBarProps) {
|
|
24
|
+
const insets = useSafeAreaInsets();
|
|
25
|
+
const largerText = useLargerText();
|
|
26
|
+
const height = largerText
|
|
27
|
+
? FLOATING_TAB_BAR_HEIGHT_LARGE
|
|
28
|
+
: FLOATING_TAB_BAR_HEIGHT;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<View
|
|
32
|
+
pointerEvents="box-none"
|
|
33
|
+
style={{
|
|
34
|
+
position: "absolute",
|
|
35
|
+
left: FLOATING_INSET,
|
|
36
|
+
right: FLOATING_INSET,
|
|
37
|
+
bottom: FLOATING_INSET + Math.max(insets.bottom, 8),
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
<FloatingSurface radius={FLOATING_PILL_RADIUS}>
|
|
41
|
+
<View
|
|
42
|
+
className="flex-row items-center justify-around px-2"
|
|
43
|
+
style={{ height }}
|
|
44
|
+
accessibilityRole="tablist"
|
|
45
|
+
>
|
|
46
|
+
{state.routes.map((route, index) => {
|
|
47
|
+
const focused = state.index === index;
|
|
48
|
+
const options = descriptors[route.key]?.options;
|
|
49
|
+
if (!options) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const label =
|
|
53
|
+
typeof options.tabBarLabel === "string"
|
|
54
|
+
? options.tabBarLabel
|
|
55
|
+
: typeof options.title === "string"
|
|
56
|
+
? options.title
|
|
57
|
+
: route.name;
|
|
58
|
+
const color = focused
|
|
59
|
+
? options.tabBarActiveTintColor
|
|
60
|
+
: options.tabBarInactiveTintColor;
|
|
61
|
+
const onPress = () => {
|
|
62
|
+
const event = navigation.emit({
|
|
63
|
+
type: "tabPress",
|
|
64
|
+
target: route.key,
|
|
65
|
+
canPreventDefault: true,
|
|
66
|
+
});
|
|
67
|
+
if (!focused && !event.defaultPrevented) {
|
|
68
|
+
selectionHaptic();
|
|
69
|
+
navigation.navigate(route.name, route.params);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Pressable
|
|
75
|
+
key={route.key}
|
|
76
|
+
testID={options.tabBarButtonTestID}
|
|
77
|
+
accessibilityRole="tab"
|
|
78
|
+
accessibilityState={{ selected: focused }}
|
|
79
|
+
accessibilityLabel={options.tabBarAccessibilityLabel ?? label}
|
|
80
|
+
onPress={onPress}
|
|
81
|
+
className="min-h-12 min-w-12 flex-1 items-center justify-center gap-1"
|
|
82
|
+
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
|
83
|
+
>
|
|
84
|
+
{options.tabBarIcon
|
|
85
|
+
? options.tabBarIcon({
|
|
86
|
+
focused,
|
|
87
|
+
color: color ?? "#737373",
|
|
88
|
+
size: largerText ? 28 : 24,
|
|
89
|
+
})
|
|
90
|
+
: null}
|
|
91
|
+
<Text
|
|
92
|
+
numberOfLines={1}
|
|
93
|
+
className={`font-instrument-sans-semibold ${
|
|
94
|
+
largerText ? "text-base" : "text-sm"
|
|
95
|
+
}`}
|
|
96
|
+
style={{ color }}
|
|
97
|
+
>
|
|
98
|
+
{label}
|
|
99
|
+
</Text>
|
|
100
|
+
</Pressable>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</View>
|
|
104
|
+
</FloatingSurface>
|
|
105
|
+
</View>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TouchableOpacity } from "react-native";
|
|
2
|
+
import { useLargerText } from "./theme";
|
|
3
|
+
|
|
4
|
+
export function HeaderIconButton({
|
|
5
|
+
onPress,
|
|
6
|
+
label,
|
|
7
|
+
testID,
|
|
8
|
+
children,
|
|
9
|
+
}: {
|
|
10
|
+
onPress: () => void;
|
|
11
|
+
label: string;
|
|
12
|
+
testID?: string;
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
}) {
|
|
15
|
+
const largerText = useLargerText();
|
|
16
|
+
return (
|
|
17
|
+
<TouchableOpacity
|
|
18
|
+
testID={testID}
|
|
19
|
+
className={`${largerText ? "h-14 w-14" : "h-12 w-12"} items-center justify-center rounded-full border border-white/40 bg-surface dark:border-white/10`}
|
|
20
|
+
onPress={onPress}
|
|
21
|
+
accessibilityRole="button"
|
|
22
|
+
accessibilityLabel={label}
|
|
23
|
+
hitSlop={8}
|
|
24
|
+
activeOpacity={0.7}
|
|
25
|
+
>
|
|
26
|
+
{children}
|
|
27
|
+
</TouchableOpacity>
|
|
28
|
+
);
|
|
29
|
+
}
|
package/src/Input.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Input as PrimitiveInput } from "./primitives/input";
|
|
3
|
+
import { cn } from "./cn";
|
|
4
|
+
import { useLargerText } from "./theme";
|
|
5
|
+
|
|
6
|
+
type InputProps = React.ComponentPropsWithoutRef<typeof PrimitiveInput> & {
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const Input = React.forwardRef<
|
|
11
|
+
React.ElementRef<typeof PrimitiveInput>,
|
|
12
|
+
InputProps
|
|
13
|
+
>(({ className, placeholderTextColor, ...props }, ref) => {
|
|
14
|
+
const largerText = useLargerText();
|
|
15
|
+
return (
|
|
16
|
+
<PrimitiveInput
|
|
17
|
+
ref={ref}
|
|
18
|
+
placeholderTextColor={placeholderTextColor ?? "#737373"}
|
|
19
|
+
className={cn(
|
|
20
|
+
"min-h-14 rounded-full border border-white/40 bg-secondary px-4 py-3 text-lg font-instrument-sans-medium text-foreground dark:border-white/10",
|
|
21
|
+
largerText && "min-h-16 py-4 text-xl",
|
|
22
|
+
className,
|
|
23
|
+
)}
|
|
24
|
+
{...props}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
Input.displayName = "Input";
|
|
30
|
+
|
|
31
|
+
export { Input };
|
|
32
|
+
export type { InputProps };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
FloatingSurface as LiquidGlass,
|
|
5
|
+
GlassSheetBackground,
|
|
6
|
+
} from "./FloatingSurface";
|
|
7
|
+
|
|
8
|
+
export function GlassBlurProvider({ children }: { children: ReactNode }) {
|
|
9
|
+
return children;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function GlassBlurTarget({ children }: { children: ReactNode }) {
|
|
13
|
+
return children;
|
|
14
|
+
}
|
package/src/ListRow.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { Pressable, Text, View } from "react-native";
|
|
3
|
+
import type { IconProps } from "@tabler/icons-react-native";
|
|
4
|
+
import { NEUTRAL_500 } from "./colors";
|
|
5
|
+
import { cn } from "./cn";
|
|
6
|
+
import { FLOATING_ROW_RADIUS } from "./floating";
|
|
7
|
+
import { useLargerText } from "./theme";
|
|
8
|
+
|
|
9
|
+
export function ListRow({
|
|
10
|
+
title,
|
|
11
|
+
meta,
|
|
12
|
+
detail,
|
|
13
|
+
icon: Icon,
|
|
14
|
+
iconColor = NEUTRAL_500,
|
|
15
|
+
right,
|
|
16
|
+
onPress,
|
|
17
|
+
onCanvas = false,
|
|
18
|
+
testID,
|
|
19
|
+
}: {
|
|
20
|
+
title: string;
|
|
21
|
+
meta?: string;
|
|
22
|
+
detail?: ReactNode;
|
|
23
|
+
icon?: ComponentType<IconProps>;
|
|
24
|
+
iconColor?: string;
|
|
25
|
+
right?: ReactNode;
|
|
26
|
+
onPress?: () => void;
|
|
27
|
+
onCanvas?: boolean;
|
|
28
|
+
testID?: string;
|
|
29
|
+
}) {
|
|
30
|
+
const largerText = useLargerText();
|
|
31
|
+
const content = (
|
|
32
|
+
<>
|
|
33
|
+
{Icon ? <Icon size={largerText ? 30 : 26} color={iconColor} /> : null}
|
|
34
|
+
<View className="min-w-0 flex-1">
|
|
35
|
+
<Text
|
|
36
|
+
numberOfLines={2}
|
|
37
|
+
className={cn(
|
|
38
|
+
"font-instrument-sans-semibold text-foreground",
|
|
39
|
+
largerText ? "text-xl" : "text-lg",
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
{title}
|
|
43
|
+
</Text>
|
|
44
|
+
{meta ? (
|
|
45
|
+
<Text
|
|
46
|
+
className={cn(
|
|
47
|
+
"font-instrument-sans text-muted-foreground",
|
|
48
|
+
largerText ? "text-lg" : "text-base",
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{meta}
|
|
52
|
+
</Text>
|
|
53
|
+
) : null}
|
|
54
|
+
{detail}
|
|
55
|
+
</View>
|
|
56
|
+
{right}
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
const className = cn(
|
|
60
|
+
"flex-row items-center gap-3 px-4",
|
|
61
|
+
largerText ? "min-h-[72px] py-4" : "min-h-16 py-3",
|
|
62
|
+
onCanvas ? "bg-surface" : "bg-surface-secondary",
|
|
63
|
+
);
|
|
64
|
+
const rowStyle = { borderRadius: FLOATING_ROW_RADIUS };
|
|
65
|
+
|
|
66
|
+
if (!onPress) {
|
|
67
|
+
return (
|
|
68
|
+
<View testID={testID} className={className} style={rowStyle}>
|
|
69
|
+
{content}
|
|
70
|
+
</View>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
<Pressable
|
|
75
|
+
testID={testID}
|
|
76
|
+
accessibilityRole="button"
|
|
77
|
+
accessibilityLabel={title}
|
|
78
|
+
onPress={onPress}
|
|
79
|
+
className={cn(className, "active:opacity-70")}
|
|
80
|
+
style={rowStyle}
|
|
81
|
+
>
|
|
82
|
+
{content}
|
|
83
|
+
</Pressable>
|
|
84
|
+
);
|
|
85
|
+
}
|
package/src/PillTabs.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { View, Text, Pressable } from "react-native";
|
|
2
|
+
import { useLargerText } from "./theme";
|
|
3
|
+
|
|
4
|
+
export function PillTabs<T extends string>({
|
|
5
|
+
options,
|
|
6
|
+
value,
|
|
7
|
+
onChange,
|
|
8
|
+
}: {
|
|
9
|
+
options: ReadonlyArray<{ id: T; label: string; testID?: string }>;
|
|
10
|
+
value: T;
|
|
11
|
+
onChange: (id: T) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const largerText = useLargerText();
|
|
14
|
+
return (
|
|
15
|
+
<View className="flex-row gap-2">
|
|
16
|
+
{options.map((tab) => {
|
|
17
|
+
const isSelected = value === tab.id;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Pressable
|
|
21
|
+
key={tab.id}
|
|
22
|
+
testID={tab.testID}
|
|
23
|
+
accessibilityLabel={tab.label}
|
|
24
|
+
onPress={() => onChange(tab.id)}
|
|
25
|
+
accessibilityRole="tab"
|
|
26
|
+
accessibilityState={{ selected: isSelected }}
|
|
27
|
+
className={`flex-1 justify-center rounded-full border border-white/40 px-3 dark:border-white/10 ${
|
|
28
|
+
largerText ? "min-h-16 py-3" : "min-h-14 py-3"
|
|
29
|
+
} ${
|
|
30
|
+
isSelected ? "bg-pink-100 dark:bg-pink-900/40" : "bg-secondary"
|
|
31
|
+
}`}
|
|
32
|
+
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
|
33
|
+
>
|
|
34
|
+
<Text
|
|
35
|
+
numberOfLines={1}
|
|
36
|
+
className={`text-center font-instrument-sans-semibold ${
|
|
37
|
+
largerText ? "text-xl" : "text-lg"
|
|
38
|
+
} ${
|
|
39
|
+
isSelected
|
|
40
|
+
? "text-pink-700 dark:text-pink-300"
|
|
41
|
+
: "text-muted-foreground"
|
|
42
|
+
}`}
|
|
43
|
+
>
|
|
44
|
+
{tab.label}
|
|
45
|
+
</Text>
|
|
46
|
+
</Pressable>
|
|
47
|
+
);
|
|
48
|
+
})}
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { View } from "react-native";
|
|
2
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
3
|
+
|
|
4
|
+
export const SafeAreaContainer = ({
|
|
5
|
+
children,
|
|
6
|
+
}: {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}) => {
|
|
9
|
+
const insets = useSafeAreaInsets();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<View
|
|
13
|
+
className="bg-transparent"
|
|
14
|
+
style={{
|
|
15
|
+
flex: 1,
|
|
16
|
+
justifyContent: "space-between",
|
|
17
|
+
paddingTop: insets.top,
|
|
18
|
+
paddingBottom: insets.bottom,
|
|
19
|
+
paddingLeft: insets.left,
|
|
20
|
+
paddingRight: insets.right,
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</View>
|
|
25
|
+
);
|
|
26
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ScrollView, View, type ViewProps } from "react-native";
|
|
2
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
3
|
+
import { useLargerText } from "./theme";
|
|
4
|
+
import { tabBarReserve } from "./floating";
|
|
5
|
+
|
|
6
|
+
interface ScreenContainerProps extends ViewProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
reserveTabBar?: boolean;
|
|
9
|
+
footer?: React.ReactNode;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ScreenContainer({
|
|
13
|
+
children,
|
|
14
|
+
reserveTabBar = false,
|
|
15
|
+
footer,
|
|
16
|
+
...props
|
|
17
|
+
}: ScreenContainerProps) {
|
|
18
|
+
const insets = useSafeAreaInsets();
|
|
19
|
+
const largerText = useLargerText();
|
|
20
|
+
const bottomPad = reserveTabBar
|
|
21
|
+
? tabBarReserve(largerText, insets.bottom)
|
|
22
|
+
: 40;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<View className="flex-1 bg-transparent" {...props}>
|
|
26
|
+
<ScrollView
|
|
27
|
+
showsVerticalScrollIndicator={false}
|
|
28
|
+
className="flex-1"
|
|
29
|
+
contentContainerStyle={{ paddingBottom: bottomPad }}
|
|
30
|
+
>
|
|
31
|
+
<View className="flex-col gap-4 p-4">{children}</View>
|
|
32
|
+
{footer ? <View className="pt-4">{footer}</View> : null}
|
|
33
|
+
</ScrollView>
|
|
34
|
+
</View>
|
|
35
|
+
);
|
|
36
|
+
}
|
package/src/Select.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import {
|
|
3
|
+
NativeSelectScrollView,
|
|
4
|
+
Select as PrimitiveSelect,
|
|
5
|
+
SelectContent,
|
|
6
|
+
SelectGroup,
|
|
7
|
+
SelectItem,
|
|
8
|
+
SelectTrigger,
|
|
9
|
+
SelectValue,
|
|
10
|
+
} from "./primitives/select";
|
|
11
|
+
import { cn } from "./cn";
|
|
12
|
+
import { useLargerText } from "./theme";
|
|
13
|
+
|
|
14
|
+
interface SelectOption<TValue = string | number | null> {
|
|
15
|
+
label: string;
|
|
16
|
+
value: TValue;
|
|
17
|
+
color?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SelectProps<TValue = string | number | null> {
|
|
21
|
+
value?: TValue;
|
|
22
|
+
onValueChange: (value: TValue) => void;
|
|
23
|
+
options: Array<SelectOption<TValue>>;
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
triggerClassName?: string;
|
|
28
|
+
contentClassName?: string;
|
|
29
|
+
testID?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const NULL_SENTINEL = "__null__";
|
|
33
|
+
|
|
34
|
+
function toPrimitiveValue<TValue>(
|
|
35
|
+
value: TValue | undefined,
|
|
36
|
+
options: Array<SelectOption<TValue>>,
|
|
37
|
+
) {
|
|
38
|
+
if (value === undefined || value === "") {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const expectedValue = value === null ? NULL_SENTINEL : String(value);
|
|
43
|
+
const option = options.find(
|
|
44
|
+
(item) => String(item.value ?? NULL_SENTINEL) === expectedValue,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (!option) return undefined;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
label: option.label,
|
|
51
|
+
value: String(option.value ?? NULL_SENTINEL),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function Select<TValue = string | number | null>({
|
|
56
|
+
value,
|
|
57
|
+
onValueChange,
|
|
58
|
+
options,
|
|
59
|
+
placeholder,
|
|
60
|
+
disabled,
|
|
61
|
+
className,
|
|
62
|
+
triggerClassName,
|
|
63
|
+
contentClassName,
|
|
64
|
+
testID,
|
|
65
|
+
}: SelectProps<TValue>) {
|
|
66
|
+
const largerText = useLargerText();
|
|
67
|
+
const primitiveValue = toPrimitiveValue(value, options);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<PrimitiveSelect
|
|
71
|
+
value={primitiveValue}
|
|
72
|
+
onValueChange={(nextOption) => {
|
|
73
|
+
if (!nextOption) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const selectedValue =
|
|
78
|
+
nextOption.value === NULL_SENTINEL ? null : nextOption.value;
|
|
79
|
+
const match = options.find(
|
|
80
|
+
(option) =>
|
|
81
|
+
String(option.value ?? NULL_SENTINEL) === String(selectedValue),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (match) {
|
|
85
|
+
onValueChange(match.value);
|
|
86
|
+
}
|
|
87
|
+
}}
|
|
88
|
+
disabled={disabled}
|
|
89
|
+
>
|
|
90
|
+
<SelectTrigger
|
|
91
|
+
testID={testID}
|
|
92
|
+
className={cn(
|
|
93
|
+
"h-14 w-full rounded-lg border-0 bg-secondary px-4 py-0 shadow-none",
|
|
94
|
+
largerText && "h-16",
|
|
95
|
+
triggerClassName,
|
|
96
|
+
className,
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
<SelectValue
|
|
100
|
+
placeholder={placeholder ?? "Select option"}
|
|
101
|
+
className={cn(
|
|
102
|
+
"font-instrument-sans-medium text-foreground",
|
|
103
|
+
largerText ? "text-xl" : "text-lg",
|
|
104
|
+
)}
|
|
105
|
+
/>
|
|
106
|
+
</SelectTrigger>
|
|
107
|
+
<SelectContent className={cn("w-full", contentClassName)}>
|
|
108
|
+
<NativeSelectScrollView>
|
|
109
|
+
<SelectGroup>
|
|
110
|
+
{options.map((option) => {
|
|
111
|
+
const optionId = testID
|
|
112
|
+
? `${testID}-${String(option.value ?? "null")}`
|
|
113
|
+
: undefined;
|
|
114
|
+
return (
|
|
115
|
+
<SelectItem
|
|
116
|
+
key={`${String(option.value)}-${option.label}`}
|
|
117
|
+
testID={optionId}
|
|
118
|
+
accessibilityLabel={option.label}
|
|
119
|
+
label={option.label}
|
|
120
|
+
value={String(option.value ?? NULL_SENTINEL)}
|
|
121
|
+
/>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</SelectGroup>
|
|
125
|
+
</NativeSelectScrollView>
|
|
126
|
+
</SelectContent>
|
|
127
|
+
</PrimitiveSelect>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { Select };
|
|
132
|
+
export type { SelectOption, SelectProps };
|