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.
Files changed (122) hide show
  1. package/lib/module/components/alert/index.js +122 -0
  2. package/lib/module/components/alert/index.js.map +1 -0
  3. package/lib/module/components/avatar/index.js +74 -0
  4. package/lib/module/components/avatar/index.js.map +1 -0
  5. package/lib/module/components/badge/index.js +77 -0
  6. package/lib/module/components/badge/index.js.map +1 -0
  7. package/lib/module/components/bottom-sheet/index.js +137 -0
  8. package/lib/module/components/bottom-sheet/index.js.map +1 -0
  9. package/lib/module/components/button/index.js +101 -0
  10. package/lib/module/components/button/index.js.map +1 -0
  11. package/lib/module/components/card/index.js +73 -0
  12. package/lib/module/components/card/index.js.map +1 -0
  13. package/lib/module/components/checkbox/index.js +84 -0
  14. package/lib/module/components/checkbox/index.js.map +1 -0
  15. package/lib/module/components/dialog/index.js +140 -0
  16. package/lib/module/components/dialog/index.js.map +1 -0
  17. package/lib/module/components/input/index.js +92 -0
  18. package/lib/module/components/input/index.js.map +1 -0
  19. package/lib/module/components/radio-group/index.js +92 -0
  20. package/lib/module/components/radio-group/index.js.map +1 -0
  21. package/lib/module/components/select/index.js +147 -0
  22. package/lib/module/components/select/index.js.map +1 -0
  23. package/lib/module/components/skeleton/index.js +26 -0
  24. package/lib/module/components/skeleton/index.js.map +1 -0
  25. package/lib/module/components/spinner/index.js +47 -0
  26. package/lib/module/components/spinner/index.js.map +1 -0
  27. package/lib/module/components/switch/index.js +96 -0
  28. package/lib/module/components/switch/index.js.map +1 -0
  29. package/lib/module/components/tabs/index.js +161 -0
  30. package/lib/module/components/tabs/index.js.map +1 -0
  31. package/lib/module/icons/index.js +65 -0
  32. package/lib/module/icons/index.js.map +1 -0
  33. package/lib/module/index.js +36 -0
  34. package/lib/module/index.js.map +1 -0
  35. package/lib/module/package.json +1 -0
  36. package/lib/module/primitives/animated-pressable.js +51 -0
  37. package/lib/module/primitives/animated-pressable.js.map +1 -0
  38. package/lib/module/primitives/portal.js +76 -0
  39. package/lib/module/primitives/portal.js.map +1 -0
  40. package/lib/module/primitives/text.js +51 -0
  41. package/lib/module/primitives/text.js.map +1 -0
  42. package/lib/module/providers/panel-ui-provider.js +29 -0
  43. package/lib/module/providers/panel-ui-provider.js.map +1 -0
  44. package/lib/module/theme/use-theme.js +19 -0
  45. package/lib/module/theme/use-theme.js.map +1 -0
  46. package/lib/module/uniwind-env.d.js +4 -0
  47. package/lib/module/uniwind-env.d.js.map +1 -0
  48. package/lib/module/utils/cn.js +8 -0
  49. package/lib/module/utils/cn.js.map +1 -0
  50. package/lib/typescript/package.json +1 -0
  51. package/lib/typescript/src/components/alert/index.d.ts +113 -0
  52. package/lib/typescript/src/components/alert/index.d.ts.map +1 -0
  53. package/lib/typescript/src/components/avatar/index.d.ts +83 -0
  54. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/badge/index.d.ts +114 -0
  56. package/lib/typescript/src/components/badge/index.d.ts.map +1 -0
  57. package/lib/typescript/src/components/bottom-sheet/index.d.ts +30 -0
  58. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -0
  59. package/lib/typescript/src/components/button/index.d.ts +159 -0
  60. package/lib/typescript/src/components/button/index.d.ts.map +1 -0
  61. package/lib/typescript/src/components/card/index.d.ts +13 -0
  62. package/lib/typescript/src/components/card/index.d.ts.map +1 -0
  63. package/lib/typescript/src/components/checkbox/index.d.ts +47 -0
  64. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/dialog/index.d.ts +50 -0
  66. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -0
  67. package/lib/typescript/src/components/input/index.d.ts +12 -0
  68. package/lib/typescript/src/components/input/index.d.ts.map +1 -0
  69. package/lib/typescript/src/components/radio-group/index.d.ts +20 -0
  70. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -0
  71. package/lib/typescript/src/components/select/index.d.ts +23 -0
  72. package/lib/typescript/src/components/select/index.d.ts.map +1 -0
  73. package/lib/typescript/src/components/skeleton/index.d.ts +6 -0
  74. package/lib/typescript/src/components/skeleton/index.d.ts.map +1 -0
  75. package/lib/typescript/src/components/spinner/index.d.ts +27 -0
  76. package/lib/typescript/src/components/spinner/index.d.ts.map +1 -0
  77. package/lib/typescript/src/components/switch/index.d.ts +76 -0
  78. package/lib/typescript/src/components/switch/index.d.ts.map +1 -0
  79. package/lib/typescript/src/components/tabs/index.d.ts +34 -0
  80. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -0
  81. package/lib/typescript/src/icons/index.d.ts +9 -0
  82. package/lib/typescript/src/icons/index.d.ts.map +1 -0
  83. package/lib/typescript/src/index.d.ts +23 -0
  84. package/lib/typescript/src/index.d.ts.map +1 -0
  85. package/lib/typescript/src/primitives/animated-pressable.d.ts +14 -0
  86. package/lib/typescript/src/primitives/animated-pressable.d.ts.map +1 -0
  87. package/lib/typescript/src/primitives/portal.d.ts +11 -0
  88. package/lib/typescript/src/primitives/portal.d.ts.map +1 -0
  89. package/lib/typescript/src/primitives/text.d.ts +66 -0
  90. package/lib/typescript/src/primitives/text.d.ts.map +1 -0
  91. package/lib/typescript/src/providers/panel-ui-provider.d.ts +13 -0
  92. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -0
  93. package/lib/typescript/src/theme/use-theme.d.ts +12 -0
  94. package/lib/typescript/src/theme/use-theme.d.ts.map +1 -0
  95. package/lib/typescript/src/utils/cn.d.ts +3 -0
  96. package/lib/typescript/src/utils/cn.d.ts.map +1 -0
  97. package/package.json +73 -0
  98. package/src/components/alert/index.tsx +106 -0
  99. package/src/components/avatar/index.tsx +68 -0
  100. package/src/components/badge/index.tsx +51 -0
  101. package/src/components/bottom-sheet/index.tsx +186 -0
  102. package/src/components/button/index.tsx +93 -0
  103. package/src/components/card/index.tsx +64 -0
  104. package/src/components/checkbox/index.tsx +79 -0
  105. package/src/components/dialog/index.tsx +180 -0
  106. package/src/components/input/index.tsx +104 -0
  107. package/src/components/radio-group/index.tsx +118 -0
  108. package/src/components/select/index.tsx +165 -0
  109. package/src/components/skeleton/index.tsx +34 -0
  110. package/src/components/spinner/index.tsx +53 -0
  111. package/src/components/switch/index.tsx +83 -0
  112. package/src/components/tabs/index.tsx +208 -0
  113. package/src/icons/index.tsx +48 -0
  114. package/src/index.ts +54 -0
  115. package/src/primitives/animated-pressable.tsx +71 -0
  116. package/src/primitives/portal.tsx +90 -0
  117. package/src/primitives/text.tsx +47 -0
  118. package/src/providers/panel-ui-provider.tsx +30 -0
  119. package/src/theme/use-theme.ts +18 -0
  120. package/src/uniwind-env.d.ts +1 -0
  121. package/src/utils/cn.ts +6 -0
  122. 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
+ });