panelui-native 0.1.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/lib/module/components/alert/index.js +122 -0
- package/lib/module/components/alert/index.js.map +1 -0
- package/lib/module/components/avatar/index.js +74 -0
- package/lib/module/components/avatar/index.js.map +1 -0
- package/lib/module/components/badge/index.js +77 -0
- package/lib/module/components/badge/index.js.map +1 -0
- package/lib/module/components/bottom-sheet/index.js +137 -0
- package/lib/module/components/bottom-sheet/index.js.map +1 -0
- package/lib/module/components/button/index.js +101 -0
- package/lib/module/components/button/index.js.map +1 -0
- package/lib/module/components/card/index.js +73 -0
- package/lib/module/components/card/index.js.map +1 -0
- package/lib/module/components/checkbox/index.js +84 -0
- package/lib/module/components/checkbox/index.js.map +1 -0
- package/lib/module/components/dialog/index.js +140 -0
- package/lib/module/components/dialog/index.js.map +1 -0
- package/lib/module/components/input/index.js +92 -0
- package/lib/module/components/input/index.js.map +1 -0
- package/lib/module/components/radio-group/index.js +92 -0
- package/lib/module/components/radio-group/index.js.map +1 -0
- package/lib/module/components/select/index.js +147 -0
- package/lib/module/components/select/index.js.map +1 -0
- package/lib/module/components/skeleton/index.js +26 -0
- package/lib/module/components/skeleton/index.js.map +1 -0
- package/lib/module/components/spinner/index.js +47 -0
- package/lib/module/components/spinner/index.js.map +1 -0
- package/lib/module/components/switch/index.js +96 -0
- package/lib/module/components/switch/index.js.map +1 -0
- package/lib/module/components/tabs/index.js +161 -0
- package/lib/module/components/tabs/index.js.map +1 -0
- package/lib/module/icons/index.js +65 -0
- package/lib/module/icons/index.js.map +1 -0
- package/lib/module/index.js +36 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/primitives/animated-pressable.js +51 -0
- package/lib/module/primitives/animated-pressable.js.map +1 -0
- package/lib/module/primitives/portal.js +76 -0
- package/lib/module/primitives/portal.js.map +1 -0
- package/lib/module/primitives/text.js +51 -0
- package/lib/module/primitives/text.js.map +1 -0
- package/lib/module/providers/panel-ui-provider.js +29 -0
- package/lib/module/providers/panel-ui-provider.js.map +1 -0
- package/lib/module/theme/use-theme.js +19 -0
- package/lib/module/theme/use-theme.js.map +1 -0
- package/lib/module/uniwind-env.d.js +4 -0
- package/lib/module/uniwind-env.d.js.map +1 -0
- package/lib/module/utils/cn.js +8 -0
- package/lib/module/utils/cn.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/components/alert/index.d.ts +113 -0
- package/lib/typescript/src/components/alert/index.d.ts.map +1 -0
- package/lib/typescript/src/components/avatar/index.d.ts +83 -0
- package/lib/typescript/src/components/avatar/index.d.ts.map +1 -0
- package/lib/typescript/src/components/badge/index.d.ts +114 -0
- package/lib/typescript/src/components/badge/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +30 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -0
- package/lib/typescript/src/components/button/index.d.ts +159 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -0
- package/lib/typescript/src/components/card/index.d.ts +13 -0
- package/lib/typescript/src/components/card/index.d.ts.map +1 -0
- package/lib/typescript/src/components/checkbox/index.d.ts +47 -0
- package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -0
- package/lib/typescript/src/components/dialog/index.d.ts +50 -0
- package/lib/typescript/src/components/dialog/index.d.ts.map +1 -0
- package/lib/typescript/src/components/input/index.d.ts +12 -0
- package/lib/typescript/src/components/input/index.d.ts.map +1 -0
- package/lib/typescript/src/components/radio-group/index.d.ts +20 -0
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +23 -0
- package/lib/typescript/src/components/select/index.d.ts.map +1 -0
- package/lib/typescript/src/components/skeleton/index.d.ts +6 -0
- package/lib/typescript/src/components/skeleton/index.d.ts.map +1 -0
- package/lib/typescript/src/components/spinner/index.d.ts +27 -0
- package/lib/typescript/src/components/spinner/index.d.ts.map +1 -0
- package/lib/typescript/src/components/switch/index.d.ts +76 -0
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tabs/index.d.ts +34 -0
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +9 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +23 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/primitives/animated-pressable.d.ts +14 -0
- package/lib/typescript/src/primitives/animated-pressable.d.ts.map +1 -0
- package/lib/typescript/src/primitives/portal.d.ts +11 -0
- package/lib/typescript/src/primitives/portal.d.ts.map +1 -0
- package/lib/typescript/src/primitives/text.d.ts +66 -0
- package/lib/typescript/src/primitives/text.d.ts.map +1 -0
- package/lib/typescript/src/providers/panel-ui-provider.d.ts +13 -0
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -0
- package/lib/typescript/src/theme/use-theme.d.ts +12 -0
- package/lib/typescript/src/theme/use-theme.d.ts.map +1 -0
- package/lib/typescript/src/utils/cn.d.ts +3 -0
- package/lib/typescript/src/utils/cn.d.ts.map +1 -0
- package/package.json +73 -0
- package/src/components/alert/index.tsx +106 -0
- package/src/components/avatar/index.tsx +68 -0
- package/src/components/badge/index.tsx +51 -0
- package/src/components/bottom-sheet/index.tsx +186 -0
- package/src/components/button/index.tsx +93 -0
- package/src/components/card/index.tsx +64 -0
- package/src/components/checkbox/index.tsx +79 -0
- package/src/components/dialog/index.tsx +180 -0
- package/src/components/input/index.tsx +104 -0
- package/src/components/radio-group/index.tsx +118 -0
- package/src/components/select/index.tsx +165 -0
- package/src/components/skeleton/index.tsx +34 -0
- package/src/components/spinner/index.tsx +53 -0
- package/src/components/switch/index.tsx +83 -0
- package/src/components/tabs/index.tsx +208 -0
- package/src/icons/index.tsx +48 -0
- package/src/index.ts +54 -0
- package/src/primitives/animated-pressable.tsx +71 -0
- package/src/primitives/portal.tsx +90 -0
- package/src/primitives/text.tsx +47 -0
- package/src/providers/panel-ui-provider.tsx +30 -0
- package/src/theme/use-theme.ts +18 -0
- package/src/uniwind-env.d.ts +1 -0
- package/src/utils/cn.ts +6 -0
- package/theme.css +143 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneElement,
|
|
3
|
+
createContext,
|
|
4
|
+
forwardRef,
|
|
5
|
+
isValidElement,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactElement,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { Pressable, View, type ViewProps } from 'react-native';
|
|
14
|
+
import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
|
|
15
|
+
import { Portal } from '../../primitives/portal';
|
|
16
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
17
|
+
import { cn } from '../../utils/cn';
|
|
18
|
+
|
|
19
|
+
interface DialogContextValue {
|
|
20
|
+
open: boolean;
|
|
21
|
+
setOpen: (open: boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DialogContext = createContext<DialogContextValue | null>(null);
|
|
25
|
+
|
|
26
|
+
function useDialog(component: string): DialogContextValue {
|
|
27
|
+
const context = useContext(DialogContext);
|
|
28
|
+
if (!context) {
|
|
29
|
+
throw new Error(`${component} must be used within a <Dialog>`);
|
|
30
|
+
}
|
|
31
|
+
return context;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DialogProps {
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
/** Controlled open state. */
|
|
37
|
+
open?: boolean;
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
/** Initial state when uncontrolled. */
|
|
40
|
+
defaultOpen?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function DialogRoot({ children, open, onOpenChange, defaultOpen = false }: DialogProps) {
|
|
44
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
45
|
+
const isControlled = open !== undefined;
|
|
46
|
+
const resolvedOpen = isControlled ? open : internalOpen;
|
|
47
|
+
|
|
48
|
+
const setOpen = useCallback(
|
|
49
|
+
(next: boolean) => {
|
|
50
|
+
if (!isControlled) setInternalOpen(next);
|
|
51
|
+
onOpenChange?.(next);
|
|
52
|
+
},
|
|
53
|
+
[isControlled, onOpenChange]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const context = useMemo(
|
|
57
|
+
() => ({ open: resolvedOpen, setOpen }),
|
|
58
|
+
[resolvedOpen, setOpen]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return <DialogContext.Provider value={context}>{children}</DialogContext.Provider>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface DialogTriggerProps {
|
|
65
|
+
children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Wraps its child and opens the dialog on press. */
|
|
69
|
+
function DialogTrigger({ children }: DialogTriggerProps) {
|
|
70
|
+
const { setOpen } = useDialog('Dialog.Trigger');
|
|
71
|
+
if (!isValidElement(children)) return children;
|
|
72
|
+
|
|
73
|
+
return cloneElement(children, {
|
|
74
|
+
onPress: (...args: unknown[]) => {
|
|
75
|
+
children.props.onPress?.(...args);
|
|
76
|
+
setOpen(true);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DialogContentProps extends ViewProps {
|
|
82
|
+
className?: string;
|
|
83
|
+
/** Tap on the backdrop closes the dialog. Default true. */
|
|
84
|
+
dismissible?: boolean;
|
|
85
|
+
children?: ReactNode;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function DialogContent({
|
|
89
|
+
className,
|
|
90
|
+
dismissible = true,
|
|
91
|
+
children,
|
|
92
|
+
...props
|
|
93
|
+
}: DialogContentProps) {
|
|
94
|
+
const { open, setOpen } = useDialog('Dialog.Content');
|
|
95
|
+
|
|
96
|
+
if (!open) return null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<Portal>
|
|
100
|
+
<Animated.View
|
|
101
|
+
entering={FadeIn.duration(150)}
|
|
102
|
+
exiting={FadeOut.duration(150)}
|
|
103
|
+
className="absolute inset-0 items-center justify-center p-6"
|
|
104
|
+
>
|
|
105
|
+
<Pressable
|
|
106
|
+
accessibilityLabel="Close dialog"
|
|
107
|
+
className="absolute inset-0 bg-black/50"
|
|
108
|
+
onPress={dismissible ? () => setOpen(false) : undefined}
|
|
109
|
+
/>
|
|
110
|
+
<Animated.View
|
|
111
|
+
entering={ZoomIn.springify().damping(18).stiffness(250).mass(0.6)}
|
|
112
|
+
exiting={FadeOut.duration(120)}
|
|
113
|
+
accessibilityViewIsModal
|
|
114
|
+
className={cn(
|
|
115
|
+
'w-full max-w-sm gap-1.5 rounded-2xl border border-border bg-popover p-5 shadow-lg',
|
|
116
|
+
className
|
|
117
|
+
)}
|
|
118
|
+
{...props}
|
|
119
|
+
>
|
|
120
|
+
{children}
|
|
121
|
+
</Animated.View>
|
|
122
|
+
</Animated.View>
|
|
123
|
+
</Portal>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const DialogTitle = forwardRef<React.ElementRef<typeof Text>, TextProps>(
|
|
128
|
+
({ className, ...props }, ref) => (
|
|
129
|
+
<Text
|
|
130
|
+
ref={ref}
|
|
131
|
+
size="lg"
|
|
132
|
+
weight="semibold"
|
|
133
|
+
className={cn('text-popover-foreground', className)}
|
|
134
|
+
{...props}
|
|
135
|
+
/>
|
|
136
|
+
)
|
|
137
|
+
);
|
|
138
|
+
DialogTitle.displayName = 'Dialog.Title';
|
|
139
|
+
|
|
140
|
+
const DialogDescription = forwardRef<React.ElementRef<typeof Text>, TextProps>(
|
|
141
|
+
({ className, ...props }, ref) => (
|
|
142
|
+
<Text ref={ref} size="sm" muted className={className} {...props} />
|
|
143
|
+
)
|
|
144
|
+
);
|
|
145
|
+
DialogDescription.displayName = 'Dialog.Description';
|
|
146
|
+
|
|
147
|
+
function DialogFooter({ className, ...props }: ViewProps & { className?: string }) {
|
|
148
|
+
return (
|
|
149
|
+
<View
|
|
150
|
+
className={cn('mt-4 flex-row items-center justify-end gap-2', className)}
|
|
151
|
+
{...props}
|
|
152
|
+
/>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface DialogCloseProps {
|
|
157
|
+
children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Wraps its child and closes the dialog on press. */
|
|
161
|
+
function DialogClose({ children }: DialogCloseProps) {
|
|
162
|
+
const { setOpen } = useDialog('Dialog.Close');
|
|
163
|
+
if (!isValidElement(children)) return children;
|
|
164
|
+
|
|
165
|
+
return cloneElement(children, {
|
|
166
|
+
onPress: (...args: unknown[]) => {
|
|
167
|
+
children.props.onPress?.(...args);
|
|
168
|
+
setOpen(false);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const Dialog = Object.assign(DialogRoot, {
|
|
174
|
+
Trigger: DialogTrigger,
|
|
175
|
+
Content: DialogContent,
|
|
176
|
+
Title: DialogTitle,
|
|
177
|
+
Description: DialogDescription,
|
|
178
|
+
Footer: DialogFooter,
|
|
179
|
+
Close: DialogClose,
|
|
180
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { forwardRef, useCallback, useState } from 'react';
|
|
2
|
+
import { TextInput, View, type TextInputProps } from 'react-native';
|
|
3
|
+
import { tv } from 'tailwind-variants';
|
|
4
|
+
import { useCSSVariable } from 'uniwind';
|
|
5
|
+
import { Text } from '../../primitives/text';
|
|
6
|
+
|
|
7
|
+
const inputVariants = tv({
|
|
8
|
+
slots: {
|
|
9
|
+
container: 'w-full gap-1.5',
|
|
10
|
+
field:
|
|
11
|
+
'h-11 w-full rounded-lg border border-input bg-background px-3 text-base text-foreground',
|
|
12
|
+
label: 'text-sm font-medium text-foreground',
|
|
13
|
+
description: 'text-sm text-muted-foreground',
|
|
14
|
+
error: 'text-sm text-destructive',
|
|
15
|
+
},
|
|
16
|
+
variants: {
|
|
17
|
+
focused: {
|
|
18
|
+
true: { field: 'border-ring' },
|
|
19
|
+
},
|
|
20
|
+
invalid: {
|
|
21
|
+
true: { field: 'border-destructive/40' },
|
|
22
|
+
},
|
|
23
|
+
disabled: {
|
|
24
|
+
true: { field: 'opacity-[0.64]' },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export interface InputProps extends TextInputProps {
|
|
30
|
+
className?: string;
|
|
31
|
+
containerClassName?: string;
|
|
32
|
+
label?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
/** Error message. When set, the field renders in its invalid state. */
|
|
35
|
+
errorMessage?: string;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const Input = forwardRef<TextInput, InputProps>(
|
|
40
|
+
(
|
|
41
|
+
{
|
|
42
|
+
className,
|
|
43
|
+
containerClassName,
|
|
44
|
+
label,
|
|
45
|
+
description,
|
|
46
|
+
errorMessage,
|
|
47
|
+
disabled,
|
|
48
|
+
onFocus,
|
|
49
|
+
onBlur,
|
|
50
|
+
...props
|
|
51
|
+
},
|
|
52
|
+
ref
|
|
53
|
+
) => {
|
|
54
|
+
const [focused, setFocused] = useState(false);
|
|
55
|
+
const placeholderColor = useCSSVariable('--muted-foreground');
|
|
56
|
+
const slots = inputVariants({
|
|
57
|
+
focused,
|
|
58
|
+
invalid: !!errorMessage,
|
|
59
|
+
disabled: !!disabled,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const handleFocus = useCallback<NonNullable<TextInputProps['onFocus']>>(
|
|
63
|
+
(event) => {
|
|
64
|
+
setFocused(true);
|
|
65
|
+
onFocus?.(event);
|
|
66
|
+
},
|
|
67
|
+
[onFocus]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const handleBlur = useCallback<NonNullable<TextInputProps['onBlur']>>(
|
|
71
|
+
(event) => {
|
|
72
|
+
setFocused(false);
|
|
73
|
+
onBlur?.(event);
|
|
74
|
+
},
|
|
75
|
+
[onBlur]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<View className={slots.container({ className: containerClassName })}>
|
|
80
|
+
{label ? <Text className={slots.label()}>{label}</Text> : null}
|
|
81
|
+
<TextInput
|
|
82
|
+
ref={ref}
|
|
83
|
+
editable={!disabled}
|
|
84
|
+
onFocus={handleFocus}
|
|
85
|
+
onBlur={handleBlur}
|
|
86
|
+
accessibilityLabel={label}
|
|
87
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
88
|
+
className={slots.field({ className })}
|
|
89
|
+
placeholderTextColor={
|
|
90
|
+
typeof placeholderColor === 'string' ? placeholderColor : undefined
|
|
91
|
+
}
|
|
92
|
+
{...props}
|
|
93
|
+
/>
|
|
94
|
+
{errorMessage ? (
|
|
95
|
+
<Text className={slots.error()}>{errorMessage}</Text>
|
|
96
|
+
) : description ? (
|
|
97
|
+
<Text className={slots.description()}>{description}</Text>
|
|
98
|
+
) : null}
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
Input.displayName = 'Input';
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { Pressable, View, type ViewProps } from 'react-native';
|
|
10
|
+
import Animated, {
|
|
11
|
+
useAnimatedStyle,
|
|
12
|
+
useSharedValue,
|
|
13
|
+
withSpring,
|
|
14
|
+
withTiming,
|
|
15
|
+
} from 'react-native-reanimated';
|
|
16
|
+
import { cn } from '../../utils/cn';
|
|
17
|
+
import { Text } from '../../primitives/text';
|
|
18
|
+
|
|
19
|
+
interface RadioGroupContextValue {
|
|
20
|
+
value: string | undefined;
|
|
21
|
+
onValueChange: (value: string) => void;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
export interface RadioGroupProps extends ViewProps {
|
|
28
|
+
className?: string;
|
|
29
|
+
value?: string;
|
|
30
|
+
onValueChange: (value: string) => void;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RadioGroupRoot = forwardRef<View, RadioGroupProps>(
|
|
36
|
+
({ className, value, onValueChange, disabled, children, ...props }, ref) => {
|
|
37
|
+
const context = useMemo(
|
|
38
|
+
() => ({ value, onValueChange, disabled }),
|
|
39
|
+
[value, onValueChange, disabled]
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<RadioGroupContext.Provider value={context}>
|
|
44
|
+
<View
|
|
45
|
+
ref={ref}
|
|
46
|
+
accessibilityRole="radiogroup"
|
|
47
|
+
className={cn('gap-3', className)}
|
|
48
|
+
{...props}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</View>
|
|
52
|
+
</RadioGroupContext.Provider>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
RadioGroupRoot.displayName = 'RadioGroup';
|
|
57
|
+
|
|
58
|
+
export interface RadioGroupItemProps {
|
|
59
|
+
className?: string;
|
|
60
|
+
value: string;
|
|
61
|
+
label?: string;
|
|
62
|
+
disabled?: boolean;
|
|
63
|
+
children?: ReactNode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const RadioGroupItem = forwardRef<View, RadioGroupItemProps>(
|
|
67
|
+
({ className, value, label, disabled: itemDisabled, children }, ref) => {
|
|
68
|
+
const context = useContext(RadioGroupContext);
|
|
69
|
+
if (!context) {
|
|
70
|
+
throw new Error('RadioGroup.Item must be used within a <RadioGroup>');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const selected = context.value === value;
|
|
74
|
+
const disabled = itemDisabled || context.disabled;
|
|
75
|
+
const progress = useSharedValue(selected ? 1 : 0);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
progress.value = selected
|
|
79
|
+
? withSpring(1, { damping: 15, stiffness: 300, mass: 0.5 })
|
|
80
|
+
: withTiming(0, { duration: 120 });
|
|
81
|
+
}, [selected, progress]);
|
|
82
|
+
|
|
83
|
+
const dotStyle = useAnimatedStyle(() => ({
|
|
84
|
+
opacity: progress.value,
|
|
85
|
+
transform: [{ scale: progress.value }],
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Pressable
|
|
90
|
+
ref={ref}
|
|
91
|
+
accessibilityRole="radio"
|
|
92
|
+
accessibilityState={{ selected, disabled: !!disabled }}
|
|
93
|
+
accessibilityLabel={label}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
onPress={() => context.onValueChange(value)}
|
|
96
|
+
hitSlop={8}
|
|
97
|
+
className={cn(
|
|
98
|
+
'flex-row items-center gap-2.5',
|
|
99
|
+
disabled && 'opacity-50',
|
|
100
|
+
className
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
<View className="h-5 w-5 items-center justify-center rounded-full border border-input bg-background">
|
|
104
|
+
<Animated.View
|
|
105
|
+
style={dotStyle}
|
|
106
|
+
className="h-2.5 w-2.5 rounded-full bg-primary"
|
|
107
|
+
/>
|
|
108
|
+
</View>
|
|
109
|
+
{label ? <Text className="text-sm text-foreground">{label}</Text> : children}
|
|
110
|
+
</Pressable>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
RadioGroupItem.displayName = 'RadioGroup.Item';
|
|
115
|
+
|
|
116
|
+
export const RadioGroup = Object.assign(RadioGroupRoot, {
|
|
117
|
+
Item: RadioGroupItem,
|
|
118
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useContext,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { Pressable, ScrollView, View } from 'react-native';
|
|
12
|
+
import { tv } from 'tailwind-variants';
|
|
13
|
+
import { useCSSVariable } from 'uniwind';
|
|
14
|
+
import { CheckIcon, ChevronDownIcon } from '../../icons';
|
|
15
|
+
import { Text } from '../../primitives/text';
|
|
16
|
+
import { BottomSheet } from '../bottom-sheet';
|
|
17
|
+
|
|
18
|
+
const selectVariants = tv({
|
|
19
|
+
slots: {
|
|
20
|
+
trigger:
|
|
21
|
+
'h-11 w-full flex-row items-center justify-between rounded-lg border border-input bg-background px-3',
|
|
22
|
+
triggerLabel: 'text-base text-foreground',
|
|
23
|
+
placeholder: 'text-base text-muted-foreground',
|
|
24
|
+
item: 'flex-row items-center justify-between rounded-lg px-3 py-3.5',
|
|
25
|
+
itemLabel: 'text-base text-foreground',
|
|
26
|
+
},
|
|
27
|
+
variants: {
|
|
28
|
+
selected: {
|
|
29
|
+
true: { item: 'bg-accent', itemLabel: 'font-medium' },
|
|
30
|
+
},
|
|
31
|
+
disabled: {
|
|
32
|
+
true: { trigger: 'opacity-50' },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
interface SelectContextValue {
|
|
38
|
+
value: string | undefined;
|
|
39
|
+
onSelect: (value: string) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SelectContext = createContext<SelectContextValue | null>(null);
|
|
43
|
+
|
|
44
|
+
export interface SelectItemProps {
|
|
45
|
+
value: string;
|
|
46
|
+
label: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Declarative option. Rendered inside the picker sheet. */
|
|
50
|
+
function SelectItem({ value, label }: SelectItemProps) {
|
|
51
|
+
const context = useContext(SelectContext);
|
|
52
|
+
if (!context) {
|
|
53
|
+
throw new Error('Select.Item must be used within a <Select>');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const selected = context.value === value;
|
|
57
|
+
const { item, itemLabel } = selectVariants({ selected });
|
|
58
|
+
const checkColor = useCSSVariable('--muted-foreground');
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Pressable
|
|
62
|
+
accessibilityRole="menuitem"
|
|
63
|
+
accessibilityState={{ selected }}
|
|
64
|
+
onPress={() => context.onSelect(value)}
|
|
65
|
+
className={item()}
|
|
66
|
+
>
|
|
67
|
+
<Text className={itemLabel()}>{label}</Text>
|
|
68
|
+
{selected ? (
|
|
69
|
+
<CheckIcon
|
|
70
|
+
size={16}
|
|
71
|
+
color={typeof checkColor === 'string' ? checkColor : '#737373'}
|
|
72
|
+
/>
|
|
73
|
+
) : null}
|
|
74
|
+
</Pressable>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SelectProps {
|
|
79
|
+
className?: string;
|
|
80
|
+
value?: string;
|
|
81
|
+
onValueChange: (value: string) => void;
|
|
82
|
+
placeholder?: string;
|
|
83
|
+
/** Sheet title shown above the options. */
|
|
84
|
+
title?: string;
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
children: ReactNode;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function SelectRoot({
|
|
90
|
+
className,
|
|
91
|
+
value,
|
|
92
|
+
onValueChange,
|
|
93
|
+
placeholder = 'Select an option',
|
|
94
|
+
title,
|
|
95
|
+
disabled,
|
|
96
|
+
children,
|
|
97
|
+
}: SelectProps) {
|
|
98
|
+
const [open, setOpen] = useState(false);
|
|
99
|
+
|
|
100
|
+
const selectedLabel = useMemo(() => {
|
|
101
|
+
let label: string | undefined;
|
|
102
|
+
Children.forEach(children, (child) => {
|
|
103
|
+
if (
|
|
104
|
+
isValidElement<SelectItemProps>(child) &&
|
|
105
|
+
child.props.value === value
|
|
106
|
+
) {
|
|
107
|
+
label = child.props.label;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
return label;
|
|
111
|
+
}, [children, value]);
|
|
112
|
+
|
|
113
|
+
const context = useMemo<SelectContextValue>(
|
|
114
|
+
() => ({
|
|
115
|
+
value,
|
|
116
|
+
onSelect: (next) => {
|
|
117
|
+
onValueChange(next);
|
|
118
|
+
setOpen(false);
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
[value, onValueChange]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const { trigger, triggerLabel, placeholder: placeholderSlot } = selectVariants({
|
|
125
|
+
disabled: !!disabled,
|
|
126
|
+
});
|
|
127
|
+
const chevronColor = useCSSVariable('--muted-foreground');
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<SelectContext.Provider value={context}>
|
|
131
|
+
<Pressable
|
|
132
|
+
accessibilityRole="button"
|
|
133
|
+
accessibilityState={{ disabled: !!disabled, expanded: open }}
|
|
134
|
+
disabled={disabled}
|
|
135
|
+
onPress={() => setOpen(true)}
|
|
136
|
+
className={trigger({ className })}
|
|
137
|
+
>
|
|
138
|
+
{selectedLabel ? (
|
|
139
|
+
<Text className={triggerLabel()}>{selectedLabel}</Text>
|
|
140
|
+
) : (
|
|
141
|
+
<Text className={placeholderSlot()}>{placeholder}</Text>
|
|
142
|
+
)}
|
|
143
|
+
<ChevronDownIcon
|
|
144
|
+
color={typeof chevronColor === 'string' ? chevronColor : '#737373'}
|
|
145
|
+
/>
|
|
146
|
+
</Pressable>
|
|
147
|
+
<BottomSheet open={open} onOpenChange={setOpen}>
|
|
148
|
+
<BottomSheet.Content>
|
|
149
|
+
{title ? (
|
|
150
|
+
<Text size="lg" weight="semibold" className="mb-2 px-3">
|
|
151
|
+
{title}
|
|
152
|
+
</Text>
|
|
153
|
+
) : null}
|
|
154
|
+
<ScrollView bounces={false} className="max-h-96">
|
|
155
|
+
<View className="gap-1 pb-2">{children}</View>
|
|
156
|
+
</ScrollView>
|
|
157
|
+
</BottomSheet.Content>
|
|
158
|
+
</BottomSheet>
|
|
159
|
+
</SelectContext.Provider>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const Select = Object.assign(SelectRoot, {
|
|
164
|
+
Item: SelectItem,
|
|
165
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { memo, useEffect } from 'react';
|
|
2
|
+
import Animated, {
|
|
3
|
+
cancelAnimation,
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useSharedValue,
|
|
6
|
+
withRepeat,
|
|
7
|
+
withTiming,
|
|
8
|
+
} from 'react-native-reanimated';
|
|
9
|
+
import { cn } from '../../utils/cn';
|
|
10
|
+
|
|
11
|
+
export interface SkeletonProps {
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Pulsing placeholder. Opacity animation runs on the UI thread. */
|
|
16
|
+
export const Skeleton = memo(function Skeleton({ className }: SkeletonProps) {
|
|
17
|
+
const opacity = useSharedValue(1);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
opacity.value = withRepeat(withTiming(0.45, { duration: 700 }), -1, true);
|
|
21
|
+
return () => cancelAnimation(opacity);
|
|
22
|
+
}, [opacity]);
|
|
23
|
+
|
|
24
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
25
|
+
opacity: opacity.value,
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Animated.View
|
|
30
|
+
style={animatedStyle}
|
|
31
|
+
className={cn('rounded-md bg-foreground/8', className)}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { memo, useEffect } from 'react';
|
|
2
|
+
import Animated, {
|
|
3
|
+
Easing,
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useSharedValue,
|
|
6
|
+
withRepeat,
|
|
7
|
+
withTiming,
|
|
8
|
+
cancelAnimation,
|
|
9
|
+
} from 'react-native-reanimated';
|
|
10
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
11
|
+
|
|
12
|
+
const spinnerVariants = tv({
|
|
13
|
+
base: 'rounded-full border-2 border-foreground/15 border-t-primary',
|
|
14
|
+
variants: {
|
|
15
|
+
size: {
|
|
16
|
+
sm: 'h-4 w-4',
|
|
17
|
+
md: 'h-6 w-6',
|
|
18
|
+
lg: 'h-8 w-8 border-[3px]',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: {
|
|
22
|
+
size: 'md',
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export interface SpinnerProps extends VariantProps<typeof spinnerVariants> {
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Rotating ring spinner. Animation runs entirely on the UI thread. */
|
|
31
|
+
export const Spinner = memo(function Spinner({ className, size }: SpinnerProps) {
|
|
32
|
+
const rotation = useSharedValue(0);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
rotation.value = withRepeat(
|
|
36
|
+
withTiming(360, { duration: 800, easing: Easing.linear }),
|
|
37
|
+
-1
|
|
38
|
+
);
|
|
39
|
+
return () => cancelAnimation(rotation);
|
|
40
|
+
}, [rotation]);
|
|
41
|
+
|
|
42
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
43
|
+
transform: [{ rotate: `${rotation.value}deg` }],
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<Animated.View
|
|
48
|
+
accessibilityRole="progressbar"
|
|
49
|
+
style={animatedStyle}
|
|
50
|
+
className={spinnerVariants({ size, className })}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
});
|