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,83 @@
1
+ import { forwardRef, useEffect } from 'react';
2
+ import { Pressable, View } from 'react-native';
3
+ import Animated, {
4
+ interpolate,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withSpring,
8
+ } from 'react-native-reanimated';
9
+ import { tv, type VariantProps } from 'tailwind-variants';
10
+
11
+ const SPRING = { damping: 18, stiffness: 250, mass: 0.5 } as const;
12
+
13
+ const switchVariants = tv({
14
+ slots: {
15
+ track: 'justify-center rounded-full border border-transparent bg-input p-[3px]',
16
+ activeTrack: 'absolute inset-0 rounded-full bg-primary',
17
+ thumb: 'rounded-full bg-white shadow-sm',
18
+ },
19
+ variants: {
20
+ size: {
21
+ sm: { track: 'h-6 w-10', thumb: 'h-4 w-4' },
22
+ md: { track: 'h-7 w-12', thumb: 'h-5 w-5' },
23
+ },
24
+ disabled: {
25
+ true: { track: 'opacity-50' },
26
+ },
27
+ },
28
+ defaultVariants: {
29
+ size: 'md',
30
+ },
31
+ });
32
+
33
+ const TRAVEL: Record<'sm' | 'md', number> = { sm: 16, md: 20 };
34
+
35
+ export interface SwitchProps extends VariantProps<typeof switchVariants> {
36
+ className?: string;
37
+ value: boolean;
38
+ onValueChange?: (value: boolean) => void;
39
+ disabled?: boolean;
40
+ }
41
+
42
+ /**
43
+ * Animated switch. Thumb position and active-track opacity are driven on the
44
+ * UI thread; toggling never re-renders beyond the value change itself.
45
+ */
46
+ export const Switch = forwardRef<View, SwitchProps>(
47
+ ({ className, value, onValueChange, disabled, size = 'md' }, ref) => {
48
+ const progress = useSharedValue(value ? 1 : 0);
49
+ const slots = switchVariants({ size, disabled: !!disabled });
50
+
51
+ useEffect(() => {
52
+ progress.value = withSpring(value ? 1 : 0, SPRING);
53
+ }, [value, progress]);
54
+
55
+ const thumbStyle = useAnimatedStyle(() => ({
56
+ transform: [
57
+ { translateX: interpolate(progress.value, [0, 1], [0, TRAVEL[size]]) },
58
+ ],
59
+ }));
60
+
61
+ const activeTrackStyle = useAnimatedStyle(() => ({
62
+ opacity: progress.value,
63
+ }));
64
+
65
+ return (
66
+ <Pressable
67
+ ref={ref}
68
+ accessibilityRole="switch"
69
+ accessibilityState={{ checked: value, disabled: !!disabled }}
70
+ disabled={disabled}
71
+ onPress={() => onValueChange?.(!value)}
72
+ hitSlop={8}
73
+ >
74
+ <View className={slots.track({ className })}>
75
+ <Animated.View style={activeTrackStyle} className={slots.activeTrack()} />
76
+ <Animated.View style={thumbStyle} className={slots.thumb()} />
77
+ </View>
78
+ </Pressable>
79
+ );
80
+ }
81
+ );
82
+
83
+ Switch.displayName = 'Switch';
@@ -0,0 +1,208 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useMemo,
6
+ useState,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { Pressable, View, type LayoutChangeEvent, type ViewProps } from 'react-native';
10
+ import Animated, {
11
+ FadeIn,
12
+ useAnimatedStyle,
13
+ useSharedValue,
14
+ withSpring,
15
+ } from 'react-native-reanimated';
16
+ import { Text } from '../../primitives/text';
17
+ import { cn } from '../../utils/cn';
18
+
19
+ const SPRING = { damping: 24, stiffness: 300, mass: 0.7 } as const;
20
+
21
+ interface TabLayout {
22
+ x: number;
23
+ width: number;
24
+ }
25
+
26
+ interface TabsContextValue {
27
+ value: string;
28
+ setValue: (value: string) => void;
29
+ registerLayout: (value: string, layout: TabLayout) => void;
30
+ layouts: Record<string, TabLayout>;
31
+ }
32
+
33
+ const TabsContext = createContext<TabsContextValue | null>(null);
34
+
35
+ function useTabs(component: string): TabsContextValue {
36
+ const context = useContext(TabsContext);
37
+ if (!context) {
38
+ throw new Error(`${component} must be used within a <Tabs>`);
39
+ }
40
+ return context;
41
+ }
42
+
43
+ export interface TabsProps extends ViewProps {
44
+ className?: string;
45
+ value?: string;
46
+ onValueChange?: (value: string) => void;
47
+ defaultValue: string;
48
+ children: ReactNode;
49
+ }
50
+
51
+ function TabsRoot({
52
+ className,
53
+ value,
54
+ onValueChange,
55
+ defaultValue,
56
+ children,
57
+ ...props
58
+ }: TabsProps) {
59
+ const [internalValue, setInternalValue] = useState(defaultValue);
60
+ const [layouts, setLayouts] = useState<Record<string, TabLayout>>({});
61
+ const isControlled = value !== undefined;
62
+ const resolvedValue = isControlled ? value : internalValue;
63
+
64
+ const setValue = useCallback(
65
+ (next: string) => {
66
+ if (!isControlled) setInternalValue(next);
67
+ onValueChange?.(next);
68
+ },
69
+ [isControlled, onValueChange]
70
+ );
71
+
72
+ const registerLayout = useCallback((tab: string, layout: TabLayout) => {
73
+ setLayouts((current) => {
74
+ const existing = current[tab];
75
+ if (existing && existing.x === layout.x && existing.width === layout.width) {
76
+ return current;
77
+ }
78
+ return { ...current, [tab]: layout };
79
+ });
80
+ }, []);
81
+
82
+ const context = useMemo(
83
+ () => ({ value: resolvedValue, setValue, registerLayout, layouts }),
84
+ [resolvedValue, setValue, registerLayout, layouts]
85
+ );
86
+
87
+ return (
88
+ <TabsContext.Provider value={context}>
89
+ <View className={cn('gap-3', className)} {...props}>
90
+ {children}
91
+ </View>
92
+ </TabsContext.Provider>
93
+ );
94
+ }
95
+
96
+ function TabsIndicator() {
97
+ const { value, layouts } = useTabs('Tabs.List');
98
+ const x = useSharedValue(0);
99
+ const width = useSharedValue(0);
100
+ const initialized = useSharedValue(0);
101
+
102
+ const layout = layouts[value];
103
+ if (layout) {
104
+ if (initialized.value === 0) {
105
+ x.value = layout.x;
106
+ width.value = layout.width;
107
+ initialized.value = 1;
108
+ } else {
109
+ x.value = withSpring(layout.x, SPRING);
110
+ width.value = withSpring(layout.width, SPRING);
111
+ }
112
+ }
113
+
114
+ const style = useAnimatedStyle(() => ({
115
+ opacity: initialized.value,
116
+ transform: [{ translateX: x.value }],
117
+ width: width.value,
118
+ }));
119
+
120
+ return (
121
+ <Animated.View
122
+ style={style}
123
+ className="absolute bottom-1 top-1 left-0 rounded-md bg-popover shadow-sm"
124
+ />
125
+ );
126
+ }
127
+
128
+ export interface TabsListProps extends ViewProps {
129
+ className?: string;
130
+ children: ReactNode;
131
+ }
132
+
133
+ function TabsList({ className, children, ...props }: TabsListProps) {
134
+ return (
135
+ <View
136
+ accessibilityRole="tablist"
137
+ className={cn('flex-row rounded-lg bg-muted p-1', className)}
138
+ {...props}
139
+ >
140
+ <TabsIndicator />
141
+ {children}
142
+ </View>
143
+ );
144
+ }
145
+
146
+ export interface TabsTriggerProps {
147
+ className?: string;
148
+ value: string;
149
+ children: ReactNode;
150
+ }
151
+
152
+ function TabsTrigger({ className, value, children }: TabsTriggerProps) {
153
+ const context = useTabs('Tabs.Trigger');
154
+ const active = context.value === value;
155
+
156
+ const handleLayout = useCallback(
157
+ (event: LayoutChangeEvent) => {
158
+ const { x, width } = event.nativeEvent.layout;
159
+ context.registerLayout(value, { x, width });
160
+ },
161
+ [context, value]
162
+ );
163
+
164
+ return (
165
+ <Pressable
166
+ accessibilityRole="tab"
167
+ accessibilityState={{ selected: active }}
168
+ onPress={() => context.setValue(value)}
169
+ onLayout={handleLayout}
170
+ className={cn('flex-1 items-center justify-center rounded-md py-1.5', className)}
171
+ >
172
+ {typeof children === 'string' ? (
173
+ <Text
174
+ size="sm"
175
+ weight="medium"
176
+ className={active ? 'text-foreground' : 'text-muted-foreground'}
177
+ >
178
+ {children}
179
+ </Text>
180
+ ) : (
181
+ children
182
+ )}
183
+ </Pressable>
184
+ );
185
+ }
186
+
187
+ export interface TabsContentProps extends ViewProps {
188
+ className?: string;
189
+ value: string;
190
+ children: ReactNode;
191
+ }
192
+
193
+ function TabsContent({ className, value, children, ...props }: TabsContentProps) {
194
+ const context = useTabs('Tabs.Content');
195
+ if (context.value !== value) return null;
196
+
197
+ return (
198
+ <Animated.View entering={FadeIn.duration(150)} className={className} {...props}>
199
+ {children}
200
+ </Animated.View>
201
+ );
202
+ }
203
+
204
+ export const Tabs = Object.assign(TabsRoot, {
205
+ List: TabsList,
206
+ Trigger: TabsTrigger,
207
+ Content: TabsContent,
208
+ });
@@ -0,0 +1,48 @@
1
+ import Svg, { Path, type SvgProps } from 'react-native-svg';
2
+
3
+ export interface IconProps extends SvgProps {
4
+ size?: number;
5
+ color?: string;
6
+ }
7
+
8
+ export function CheckIcon({ size = 14, color = '#fff', ...props }: IconProps) {
9
+ return (
10
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
11
+ <Path
12
+ d="M20 6 9 17l-5-5"
13
+ stroke={color}
14
+ strokeWidth={3}
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ />
18
+ </Svg>
19
+ );
20
+ }
21
+
22
+ export function ChevronDownIcon({ size = 16, color = '#737373', ...props }: IconProps) {
23
+ return (
24
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
25
+ <Path
26
+ d="m6 9 6 6 6-6"
27
+ stroke={color}
28
+ strokeWidth={2}
29
+ strokeLinecap="round"
30
+ strokeLinejoin="round"
31
+ />
32
+ </Svg>
33
+ );
34
+ }
35
+
36
+ export function XIcon({ size = 16, color = '#737373', ...props }: IconProps) {
37
+ return (
38
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
39
+ <Path
40
+ d="M18 6 6 18M6 6l12 12"
41
+ stroke={color}
42
+ strokeWidth={2}
43
+ strokeLinecap="round"
44
+ strokeLinejoin="round"
45
+ />
46
+ </Svg>
47
+ );
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ // Providers
2
+ export { PanelUIProvider, type PanelUIProviderProps } from './providers/panel-ui-provider';
3
+
4
+ // Theme
5
+ export { useTheme, type ThemeName } from './theme/use-theme';
6
+
7
+ // Primitives
8
+ export { Portal, PortalHost, PortalProvider } from './primitives/portal';
9
+ export { Text, type TextProps } from './primitives/text';
10
+ export {
11
+ AnimatedPressable,
12
+ type AnimatedPressableProps,
13
+ } from './primitives/animated-pressable';
14
+
15
+ // Components
16
+ export { Alert, type AlertProps } from './components/alert';
17
+ export { Avatar, type AvatarProps } from './components/avatar';
18
+ export { Badge, type BadgeProps } from './components/badge';
19
+ export {
20
+ BottomSheet,
21
+ type BottomSheetProps,
22
+ type BottomSheetContentProps,
23
+ } from './components/bottom-sheet';
24
+ export { Button, type ButtonProps } from './components/button';
25
+ export {
26
+ Dialog,
27
+ type DialogProps,
28
+ type DialogContentProps,
29
+ } from './components/dialog';
30
+ export { Select, type SelectProps, type SelectItemProps } from './components/select';
31
+ export {
32
+ Tabs,
33
+ type TabsProps,
34
+ type TabsListProps,
35
+ type TabsTriggerProps,
36
+ type TabsContentProps,
37
+ } from './components/tabs';
38
+ export { Card, type CardProps } from './components/card';
39
+ export { Checkbox, type CheckboxProps } from './components/checkbox';
40
+ export { Input, type InputProps } from './components/input';
41
+ export {
42
+ RadioGroup,
43
+ type RadioGroupProps,
44
+ type RadioGroupItemProps,
45
+ } from './components/radio-group';
46
+ export { Skeleton, type SkeletonProps } from './components/skeleton';
47
+ export { Spinner, type SpinnerProps } from './components/spinner';
48
+ export { Switch, type SwitchProps } from './components/switch';
49
+
50
+ // Icons
51
+ export { CheckIcon, ChevronDownIcon, XIcon, type IconProps } from './icons';
52
+
53
+ // Utils
54
+ export { cn } from './utils/cn';
@@ -0,0 +1,71 @@
1
+ import { forwardRef, useCallback } from 'react';
2
+ import {
3
+ Pressable,
4
+ type GestureResponderEvent,
5
+ type PressableProps,
6
+ type View,
7
+ } from 'react-native';
8
+ import Animated, {
9
+ useAnimatedStyle,
10
+ useSharedValue,
11
+ withSpring,
12
+ withTiming,
13
+ } from 'react-native-reanimated';
14
+
15
+ const AnimatedPressableBase = Animated.createAnimatedComponent(Pressable);
16
+
17
+ const SPRING_CONFIG = { damping: 20, stiffness: 300, mass: 0.4 } as const;
18
+
19
+ export interface AnimatedPressableProps extends PressableProps {
20
+ className?: string;
21
+ /** Scale applied while pressed. Set to 1 to disable. Default 0.97. */
22
+ pressScale?: number;
23
+ /** Opacity applied while pressed. Set to 1 to disable. Default 1. */
24
+ pressOpacity?: number;
25
+ }
26
+
27
+ /**
28
+ * Pressable with UI-thread press feedback (scale/opacity via Reanimated).
29
+ * The shared base for every interactive PanelUI component.
30
+ */
31
+ export const AnimatedPressable = forwardRef<View, AnimatedPressableProps>(
32
+ (
33
+ { pressScale = 0.97, pressOpacity = 1, onPressIn, onPressOut, style, ...props },
34
+ ref
35
+ ) => {
36
+ const pressed = useSharedValue(0);
37
+
38
+ const handlePressIn = useCallback(
39
+ (event: GestureResponderEvent) => {
40
+ pressed.value = withTiming(1, { duration: 80 });
41
+ onPressIn?.(event);
42
+ },
43
+ [onPressIn, pressed]
44
+ );
45
+
46
+ const handlePressOut = useCallback(
47
+ (event: GestureResponderEvent) => {
48
+ pressed.value = withSpring(0, SPRING_CONFIG);
49
+ onPressOut?.(event);
50
+ },
51
+ [onPressOut, pressed]
52
+ );
53
+
54
+ const animatedStyle = useAnimatedStyle(() => ({
55
+ transform: [{ scale: 1 + pressed.value * (pressScale - 1) }],
56
+ opacity: 1 + pressed.value * (pressOpacity - 1),
57
+ }));
58
+
59
+ return (
60
+ <AnimatedPressableBase
61
+ ref={ref}
62
+ style={[animatedStyle, style as never]}
63
+ onPressIn={handlePressIn}
64
+ onPressOut={handlePressOut}
65
+ {...props}
66
+ />
67
+ );
68
+ }
69
+ );
70
+
71
+ AnimatedPressable.displayName = 'AnimatedPressable';
@@ -0,0 +1,90 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useId,
6
+ useRef,
7
+ useSyncExternalStore,
8
+ type ReactNode,
9
+ } from 'react';
10
+
11
+ type PortalMap = ReadonlyMap<string, ReactNode>;
12
+
13
+ class PortalStore {
14
+ private portals = new Map<string, ReactNode>();
15
+ private snapshot: PortalMap = this.portals;
16
+ private listeners = new Set<() => void>();
17
+
18
+ mount = (key: string, node: ReactNode) => {
19
+ this.portals.set(key, node);
20
+ this.emit();
21
+ };
22
+
23
+ unmount = (key: string) => {
24
+ this.portals.delete(key);
25
+ this.emit();
26
+ };
27
+
28
+ subscribe = (listener: () => void) => {
29
+ this.listeners.add(listener);
30
+ return () => this.listeners.delete(listener);
31
+ };
32
+
33
+ getSnapshot = (): PortalMap => this.snapshot;
34
+
35
+ private emit() {
36
+ this.snapshot = new Map(this.portals);
37
+ this.listeners.forEach((listener) => listener());
38
+ }
39
+ }
40
+
41
+ const PortalContext = createContext<PortalStore | null>(null);
42
+
43
+ export function PortalProvider({ children }: { children: ReactNode }) {
44
+ const storeRef = useRef<PortalStore | null>(null);
45
+ storeRef.current ??= new PortalStore();
46
+ return (
47
+ <PortalContext.Provider value={storeRef.current}>
48
+ {children}
49
+ </PortalContext.Provider>
50
+ );
51
+ }
52
+
53
+ function usePortalStore(component: string): PortalStore {
54
+ const store = useContext(PortalContext);
55
+ if (!store) {
56
+ throw new Error(`${component} must be used within a <PanelUIProvider>`);
57
+ }
58
+ return store;
59
+ }
60
+
61
+ /** Renders children into the nearest PortalHost (above everything else). */
62
+ export function Portal({ children }: { children: ReactNode }) {
63
+ const store = usePortalStore('Portal');
64
+ const key = useId();
65
+
66
+ useEffect(() => {
67
+ store.mount(key, children);
68
+ return () => store.unmount(key);
69
+ }, [store, key, children]);
70
+
71
+ return null;
72
+ }
73
+
74
+ /** Mount point for portaled content. PanelUIProvider renders one automatically. */
75
+ export function PortalHost() {
76
+ const store = usePortalStore('PortalHost');
77
+ const portals = useSyncExternalStore(store.subscribe, store.getSnapshot);
78
+
79
+ return (
80
+ <>
81
+ {Array.from(portals.entries(), ([key, node]) => (
82
+ <PortalFragment key={key}>{node}</PortalFragment>
83
+ ))}
84
+ </>
85
+ );
86
+ }
87
+
88
+ function PortalFragment({ children }: { children: ReactNode }) {
89
+ return <>{children}</>;
90
+ }
@@ -0,0 +1,47 @@
1
+ import { forwardRef } from 'react';
2
+ import { Text as RNText, type Text as RNTextType, type TextProps as RNTextProps } from 'react-native';
3
+ import { tv, type VariantProps } from 'tailwind-variants';
4
+
5
+ const textVariants = tv({
6
+ base: 'text-foreground',
7
+ variants: {
8
+ size: {
9
+ xs: 'text-xs',
10
+ sm: 'text-sm',
11
+ base: 'text-base',
12
+ lg: 'text-lg',
13
+ xl: 'text-xl',
14
+ '2xl': 'text-2xl',
15
+ '3xl': 'text-3xl',
16
+ },
17
+ weight: {
18
+ normal: 'font-normal',
19
+ medium: 'font-medium',
20
+ semibold: 'font-semibold',
21
+ bold: 'font-bold',
22
+ },
23
+ muted: {
24
+ true: 'text-muted-foreground',
25
+ },
26
+ },
27
+ defaultVariants: {
28
+ size: 'base',
29
+ weight: 'normal',
30
+ },
31
+ });
32
+
33
+ export interface TextProps extends RNTextProps, VariantProps<typeof textVariants> {
34
+ className?: string;
35
+ }
36
+
37
+ export const Text = forwardRef<RNTextType, TextProps>(
38
+ ({ className, size, weight, muted, ...props }, ref) => (
39
+ <RNText
40
+ ref={ref}
41
+ className={textVariants({ size, weight, muted, className })}
42
+ {...props}
43
+ />
44
+ )
45
+ );
46
+
47
+ Text.displayName = 'Text';
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import { GestureHandlerRootView } from 'react-native-gesture-handler';
4
+ import { PortalHost, PortalProvider } from '../primitives/portal';
5
+
6
+ export interface PanelUIProviderProps {
7
+ children: ReactNode;
8
+ }
9
+
10
+ /**
11
+ * Root provider for PanelUI. Wraps the app with the gesture handler root and
12
+ * the portal host used by overlay components (Dialog, BottomSheet, Select).
13
+ *
14
+ * Theme switching is handled natively by Uniwind — use `Uniwind.setTheme()`
15
+ * or the `useTheme()` hook exported from panelui-native.
16
+ */
17
+ export function PanelUIProvider({ children }: PanelUIProviderProps) {
18
+ return (
19
+ <GestureHandlerRootView style={styles.root}>
20
+ <PortalProvider>
21
+ {children}
22
+ <PortalHost />
23
+ </PortalProvider>
24
+ </GestureHandlerRootView>
25
+ );
26
+ }
27
+
28
+ const styles = StyleSheet.create({
29
+ root: { flex: 1 },
30
+ });
@@ -0,0 +1,18 @@
1
+ import { Uniwind, useUniwind } from 'uniwind';
2
+
3
+ export type ThemeName = 'light' | 'dark' | 'system';
4
+
5
+ /**
6
+ * Reactive access to the current Uniwind theme plus a setter.
7
+ * Theme changes are applied natively by Uniwind without re-rendering the tree.
8
+ */
9
+ export function useTheme() {
10
+ const { theme } = useUniwind();
11
+
12
+ return {
13
+ /** Currently resolved theme ('light' or 'dark'). */
14
+ theme,
15
+ /** Switch theme: 'light', 'dark', or 'system' (follows device). */
16
+ setTheme: (name: ThemeName) => Uniwind.setTheme(name),
17
+ };
18
+ }
@@ -0,0 +1 @@
1
+ import 'uniwind/types';
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }