panelui-native 0.6.0 → 0.9.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 (181) hide show
  1. package/README.md +1 -1
  2. package/lib/module/components/accordion/index.js +7 -5
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/alert/index.js +3 -6
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/attachment/index.js +219 -0
  7. package/lib/module/components/attachment/index.js.map +1 -0
  8. package/lib/module/components/bottom-sheet/index.js +82 -4
  9. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  10. package/lib/module/components/button/index.js +36 -1
  11. package/lib/module/components/button/index.js.map +1 -1
  12. package/lib/module/components/dialog/index.js +6 -2
  13. package/lib/module/components/dialog/index.js.map +1 -1
  14. package/lib/module/components/empty-state/index.js +98 -22
  15. package/lib/module/components/empty-state/index.js.map +1 -1
  16. package/lib/module/components/inline-select/index.js +171 -46
  17. package/lib/module/components/inline-select/index.js.map +1 -1
  18. package/lib/module/components/input/index.js +28 -5
  19. package/lib/module/components/input/index.js.map +1 -1
  20. package/lib/module/components/input-group/index.js +0 -1
  21. package/lib/module/components/input-group/index.js.map +1 -1
  22. package/lib/module/components/item/index.js +359 -0
  23. package/lib/module/components/item/index.js.map +1 -0
  24. package/lib/module/components/label/index.js +5 -3
  25. package/lib/module/components/label/index.js.map +1 -1
  26. package/lib/module/components/line-chart/index.js +1024 -0
  27. package/lib/module/components/line-chart/index.js.map +1 -0
  28. package/lib/module/components/marker/index.js +190 -0
  29. package/lib/module/components/marker/index.js.map +1 -0
  30. package/lib/module/components/message/index.js +334 -0
  31. package/lib/module/components/message/index.js.map +1 -0
  32. package/lib/module/components/message-scroller/index.js +451 -0
  33. package/lib/module/components/message-scroller/index.js.map +1 -0
  34. package/lib/module/components/popover/index.js +500 -0
  35. package/lib/module/components/popover/index.js.map +1 -0
  36. package/lib/module/components/radio-group/index.js +104 -16
  37. package/lib/module/components/radio-group/index.js.map +1 -1
  38. package/lib/module/components/scroll-fade/index.js +119 -71
  39. package/lib/module/components/scroll-fade/index.js.map +1 -1
  40. package/lib/module/components/select/index.js +35 -6
  41. package/lib/module/components/select/index.js.map +1 -1
  42. package/lib/module/components/separator/index.js +91 -0
  43. package/lib/module/components/separator/index.js.map +1 -0
  44. package/lib/module/components/shimmer/index.js +111 -64
  45. package/lib/module/components/shimmer/index.js.map +1 -1
  46. package/lib/module/components/surface/index.js +24 -4
  47. package/lib/module/components/surface/index.js.map +1 -1
  48. package/lib/module/components/switch/index.js +20 -1
  49. package/lib/module/components/switch/index.js.map +1 -1
  50. package/lib/module/components/toast/index.js +7 -8
  51. package/lib/module/components/toast/index.js.map +1 -1
  52. package/lib/module/components/typography/index.js +3 -3
  53. package/lib/module/hooks/index.js +1 -0
  54. package/lib/module/hooks/index.js.map +1 -1
  55. package/lib/module/hooks/use-breakpoint.js +2 -2
  56. package/lib/module/hooks/use-copy-to-clipboard.js +0 -2
  57. package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -1
  58. package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
  59. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
  60. package/lib/module/icons/index.js +73 -4
  61. package/lib/module/icons/index.js.map +1 -1
  62. package/lib/module/index.js +13 -1
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/native/index.js +53 -0
  65. package/lib/module/native/index.js.map +1 -0
  66. package/lib/module/primitives/keyboard-avoider.js +40 -0
  67. package/lib/module/primitives/keyboard-avoider.js.map +1 -0
  68. package/lib/module/primitives/scrim.js +72 -0
  69. package/lib/module/primitives/scrim.js.map +1 -0
  70. package/lib/module/providers/panel-ui-provider.js +24 -4
  71. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  72. package/lib/module/theme/use-theme.js +1 -1
  73. package/lib/typescript/src/components/accordion/index.d.ts +7 -5
  74. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/alert/index.d.ts +3 -6
  76. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/attachment/index.d.ts +93 -0
  78. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
  79. package/lib/typescript/src/components/bottom-sheet/index.d.ts +27 -2
  80. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  81. package/lib/typescript/src/components/button/index.d.ts +11 -0
  82. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/dialog/index.d.ts +7 -1
  84. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  85. package/lib/typescript/src/components/empty-state/index.d.ts +156 -7
  86. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  87. package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
  88. package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/input/index.d.ts +13 -0
  90. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/input-group/index.d.ts +0 -1
  92. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/item/index.d.ts +286 -0
  94. package/lib/typescript/src/components/item/index.d.ts.map +1 -0
  95. package/lib/typescript/src/components/label/index.d.ts +5 -3
  96. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  97. package/lib/typescript/src/components/line-chart/index.d.ts +247 -0
  98. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
  99. package/lib/typescript/src/components/marker/index.d.ts +137 -0
  100. package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
  101. package/lib/typescript/src/components/message/index.d.ts +177 -0
  102. package/lib/typescript/src/components/message/index.d.ts.map +1 -0
  103. package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
  104. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
  105. package/lib/typescript/src/components/popover/index.d.ts +130 -0
  106. package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
  107. package/lib/typescript/src/components/radio-group/index.d.ts +32 -0
  108. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  109. package/lib/typescript/src/components/scroll-fade/index.d.ts +18 -9
  110. package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -1
  111. package/lib/typescript/src/components/select/index.d.ts +16 -1
  112. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/separator/index.d.ts +47 -0
  114. package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
  115. package/lib/typescript/src/components/shimmer/index.d.ts +38 -20
  116. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
  117. package/lib/typescript/src/components/surface/index.d.ts +51 -2
  118. package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
  119. package/lib/typescript/src/components/switch/index.d.ts +10 -0
  120. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/toast/index.d.ts +4 -4
  122. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  123. package/lib/typescript/src/hooks/index.d.ts +1 -0
  124. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  125. package/lib/typescript/src/hooks/use-breakpoint.d.ts +2 -2
  126. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +0 -2
  127. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -1
  128. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
  129. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
  130. package/lib/typescript/src/icons/index.d.ts +10 -4
  131. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  132. package/lib/typescript/src/index.d.ts +11 -1
  133. package/lib/typescript/src/index.d.ts.map +1 -1
  134. package/lib/typescript/src/native/index.d.ts +35 -0
  135. package/lib/typescript/src/native/index.d.ts.map +1 -0
  136. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
  137. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
  138. package/lib/typescript/src/primitives/scrim.d.ts +21 -0
  139. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -0
  140. package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
  141. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  142. package/lib/typescript/src/theme/use-theme.d.ts +1 -1
  143. package/package.json +16 -3
  144. package/src/components/accordion/index.tsx +7 -5
  145. package/src/components/alert/index.tsx +3 -6
  146. package/src/components/attachment/index.tsx +279 -0
  147. package/src/components/bottom-sheet/index.tsx +102 -2
  148. package/src/components/button/index.tsx +52 -0
  149. package/src/components/dialog/index.tsx +10 -1
  150. package/src/components/empty-state/index.tsx +64 -19
  151. package/src/components/inline-select/index.tsx +191 -44
  152. package/src/components/input/index.tsx +39 -3
  153. package/src/components/input-group/index.tsx +0 -1
  154. package/src/components/item/index.tsx +387 -0
  155. package/src/components/label/index.tsx +5 -3
  156. package/src/components/line-chart/index.tsx +1224 -0
  157. package/src/components/marker/index.tsx +194 -0
  158. package/src/components/message/index.tsx +336 -0
  159. package/src/components/message-scroller/index.tsx +574 -0
  160. package/src/components/popover/index.tsx +599 -0
  161. package/src/components/radio-group/index.tsx +108 -17
  162. package/src/components/scroll-fade/index.tsx +163 -51
  163. package/src/components/select/index.tsx +54 -9
  164. package/src/components/separator/index.tsx +97 -0
  165. package/src/components/shimmer/index.tsx +151 -58
  166. package/src/components/surface/index.tsx +32 -5
  167. package/src/components/switch/index.tsx +27 -1
  168. package/src/components/toast/index.tsx +7 -8
  169. package/src/components/typography/index.tsx +3 -3
  170. package/src/hooks/index.ts +5 -0
  171. package/src/hooks/use-breakpoint.ts +2 -2
  172. package/src/hooks/use-copy-to-clipboard.ts +0 -2
  173. package/src/hooks/use-keyboard-avoidance.ts +140 -0
  174. package/src/icons/index.tsx +52 -4
  175. package/src/index.ts +92 -0
  176. package/src/native/index.ts +79 -0
  177. package/src/primitives/keyboard-avoider.tsx +49 -0
  178. package/src/primitives/scrim.tsx +95 -0
  179. package/src/providers/panel-ui-provider.tsx +30 -9
  180. package/src/theme/use-theme.ts +1 -1
  181. package/theme.css +63 -3
@@ -1,3 +1,23 @@
1
+ /**
2
+ * RadioGroup — one choice from a set.
3
+ *
4
+ * The group owns the value and hands each item its selected state through
5
+ * context, so an item is a thin pressable that reports its own value up and
6
+ * reads selection back down.
7
+ *
8
+ * Two shapes, chosen on the group and inherited by every item: `dot` is the
9
+ * classic label-beside-a-disc row; `card` turns the whole surface into the
10
+ * target — for pickable options where the disc is a confirmation rather than
11
+ * the thing you aim at. The card is the same treatment `Checkbox` offers, so a
12
+ * form mixing single- and multi-select choices reads as one family.
13
+ *
14
+ * ```tsx
15
+ * <RadioGroup value={plan} onValueChange={setPlan} variant="card">
16
+ * <RadioGroup.Item value="pro" label="Pro" description="For growing teams" />
17
+ * <RadioGroup.Item value="max" label="Max" description="Everything, uncapped" />
18
+ * </RadioGroup>
19
+ * ```
20
+ */
1
21
  import {
2
22
  createContext,
3
23
  forwardRef,
@@ -13,13 +33,50 @@ import Animated, {
13
33
  withSpring,
14
34
  withTiming,
15
35
  } from 'react-native-reanimated';
36
+ import { tv, type VariantProps } from 'tailwind-variants';
16
37
  import { cn } from '../../utils/cn';
17
38
  import { Text } from '../../primitives/text';
18
39
 
40
+ type RadioVariant = 'dot' | 'card';
41
+
42
+ const itemVariants = tv({
43
+ slots: {
44
+ row: 'flex-row items-center gap-2.5',
45
+ indicator:
46
+ 'h-5 w-5 items-center justify-center rounded-full border border-input bg-background',
47
+ label: 'text-sm text-foreground',
48
+ description: 'text-xs text-muted-foreground',
49
+ },
50
+ variants: {
51
+ variant: {
52
+ dot: {},
53
+ card: {
54
+ // The whole surface is the target; the disc moves to the trailing edge
55
+ // as a confirmation of the row, not the affordance for it.
56
+ row: 'w-full items-start justify-between gap-3 rounded-xl border border-border bg-card p-4',
57
+ label: 'text-base font-medium',
58
+ },
59
+ },
60
+ selected: {
61
+ true: {},
62
+ },
63
+ disabled: {
64
+ true: { row: 'opacity-50' },
65
+ },
66
+ },
67
+ compoundVariants: [
68
+ { variant: 'card', selected: true, class: { row: 'border-primary bg-accent' } },
69
+ ],
70
+ defaultVariants: {
71
+ variant: 'dot',
72
+ },
73
+ });
74
+
19
75
  interface RadioGroupContextValue {
20
76
  value: string | undefined;
21
77
  onValueChange: (value: string) => void;
22
78
  disabled?: boolean;
79
+ variant: RadioVariant;
23
80
  }
24
81
 
25
82
  const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
@@ -29,14 +86,20 @@ export interface RadioGroupProps extends ViewProps {
29
86
  value?: string;
30
87
  onValueChange: (value: string) => void;
31
88
  disabled?: boolean;
89
+ /**
90
+ * `dot` is the label-beside-a-disc row. `card` makes the whole surface the
91
+ * target and highlights the selected option — for a plan picker or a
92
+ * settings choice where each option carries a description.
93
+ */
94
+ variant?: RadioVariant;
32
95
  children: ReactNode;
33
96
  }
34
97
 
35
98
  const RadioGroupRoot = forwardRef<View, RadioGroupProps>(
36
- ({ className, value, onValueChange, disabled, children, ...props }, ref) => {
99
+ ({ className, value, onValueChange, disabled, variant = 'dot', children, ...props }, ref) => {
37
100
  const context = useMemo(
38
- () => ({ value, onValueChange, disabled }),
39
- [value, onValueChange, disabled]
101
+ () => ({ value, onValueChange, disabled, variant }),
102
+ [value, onValueChange, disabled, variant]
40
103
  );
41
104
 
42
105
  return (
@@ -59,12 +122,19 @@ export interface RadioGroupItemProps {
59
122
  className?: string;
60
123
  value: string;
61
124
  label?: string;
125
+ /** Secondary line under the label. Most at home in the `card` variant. */
126
+ description?: string;
62
127
  disabled?: boolean;
128
+ /** Hide the disc entirely — for a card whose selected fill is enough. */
129
+ hideIndicator?: boolean;
63
130
  children?: ReactNode;
64
131
  }
65
132
 
66
133
  const RadioGroupItem = forwardRef<View, RadioGroupItemProps>(
67
- ({ className, value, label, disabled: itemDisabled, children }, ref) => {
134
+ (
135
+ { className, value, label, description, disabled: itemDisabled, hideIndicator, children },
136
+ ref
137
+ ) => {
68
138
  const context = useContext(RadioGroupContext);
69
139
  if (!context) {
70
140
  throw new Error('RadioGroup.Item must be used within a <RadioGroup>');
@@ -72,6 +142,7 @@ const RadioGroupItem = forwardRef<View, RadioGroupItemProps>(
72
142
 
73
143
  const selected = context.value === value;
74
144
  const disabled = itemDisabled || context.disabled;
145
+ const variant = context.variant;
75
146
  const progress = useSharedValue(selected ? 1 : 0);
76
147
 
77
148
  useEffect(() => {
@@ -85,6 +156,34 @@ const RadioGroupItem = forwardRef<View, RadioGroupItemProps>(
85
156
  transform: [{ scale: progress.value }],
86
157
  }));
87
158
 
159
+ const slots = itemVariants({ variant, selected, disabled: !!disabled });
160
+
161
+ const indicator = hideIndicator ? null : (
162
+ <View className={slots.indicator()}>
163
+ <Animated.View style={dotStyle} className="h-2.5 w-2.5 rounded-full bg-primary" />
164
+ </View>
165
+ );
166
+
167
+ // The card lays its text out first so the disc sits at the trailing edge;
168
+ // the dot row keeps the classic disc-then-label order.
169
+ const body =
170
+ variant === 'card' ? (
171
+ <>
172
+ <View className="flex-1 gap-1">
173
+ {label ? <Text className={slots.label()}>{label}</Text> : children}
174
+ {description ? (
175
+ <Text className={slots.description()}>{description}</Text>
176
+ ) : null}
177
+ </View>
178
+ {indicator}
179
+ </>
180
+ ) : (
181
+ <>
182
+ {indicator}
183
+ {label ? <Text className={slots.label()}>{label}</Text> : children}
184
+ </>
185
+ );
186
+
88
187
  return (
89
188
  <Pressable
90
189
  ref={ref}
@@ -93,20 +192,10 @@ const RadioGroupItem = forwardRef<View, RadioGroupItemProps>(
93
192
  accessibilityLabel={label}
94
193
  disabled={disabled}
95
194
  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
- )}
195
+ hitSlop={variant === 'card' ? undefined : 8}
196
+ className={slots.row({ className })}
102
197
  >
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}
198
+ {body}
110
199
  </Pressable>
111
200
  );
112
201
  }
@@ -116,3 +205,5 @@ RadioGroupItem.displayName = 'RadioGroup.Item';
116
205
  export const RadioGroup = Object.assign(RadioGroupRoot, {
117
206
  Item: RadioGroupItem,
118
207
  });
208
+
209
+ export type { RadioVariant };
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * ScrollFade — fades the edges of a scroll container.
3
3
  *
4
- * The React Native equivalent of shadcn-ui/ui's `scroll-fade` utility
5
- * (https://ui.shadcn.com/docs/utils/scroll-fade), which is CSS `mask-image`
6
- * driven by a scroll timeline. React Native has neither masks nor scroll
7
- * timelines, so the fades are SVG gradients overlaid on each edge, with their
8
- * opacity following the scroll position.
4
+ * Content that runs past a boundary reads better fading out than being cut
5
+ * off, and the fade doubles as an affordance: an edge that is fading is an
6
+ * edge with more content behind it.
9
7
  *
10
- * Composition follows heroui-native's ScrollShadow: wrap the scrollable and
11
- * let the orientation be inferred from its `horizontal` prop.
8
+ * Scroll position, content size and viewport size are all held in shared
9
+ * values and consumed by `useAnimatedStyle`, so the fades track the scroll on
10
+ * the UI thread without re-rendering React.
12
11
  *
13
12
  * ```tsx
14
13
  * <ScrollFade>
@@ -20,39 +19,63 @@ import {
20
19
  Children,
21
20
  cloneElement,
22
21
  isValidElement,
23
- useState,
22
+ useMemo,
23
+ type ComponentType,
24
24
  type ReactElement,
25
25
  type ReactNode,
26
26
  } from 'react';
27
27
  import {
28
+ StyleSheet,
28
29
  View,
30
+ type LayoutChangeEvent,
29
31
  type NativeScrollEvent,
30
32
  type NativeSyntheticEvent,
31
33
  type ViewProps,
32
34
  } from 'react-native';
33
- import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
35
+ import { LinearGradient } from 'expo-linear-gradient';
36
+ import Animated, {
37
+ useAnimatedScrollHandler,
38
+ useAnimatedStyle,
39
+ useComposedEventHandler,
40
+ useDerivedValue,
41
+ useSharedValue,
42
+ type AnimatedScrollViewProps,
43
+ type DerivedValue,
44
+ } from 'react-native-reanimated';
34
45
  import { useCSSVariable } from 'uniwind';
35
46
 
36
47
  /** Past this many pixels from an edge, that edge's fade is fully shown. */
37
- const FADE_IN_DISTANCE = 24;
48
+ const DEFAULT_FADE_IN_DISTANCE = 24;
38
49
 
39
50
  export interface ScrollFadeProps extends ViewProps {
40
51
  className?: string;
41
52
  /** Depth of the fade in pixels. */
42
53
  size?: number;
43
54
  /** Which edges fade. */
44
- edges?: 'both' | 'start' | 'end';
55
+ edges?: 'both' | 'start' | 'end' | 'none';
56
+ /**
57
+ * Scroll axis. Inferred from the child's `horizontal` prop when omitted —
58
+ * pass it explicitly for children that scroll horizontally without that prop
59
+ * (a `FlatList` with `horizontal` set through `contentContainerStyle`, say).
60
+ */
61
+ orientation?: 'horizontal' | 'vertical';
45
62
  /**
46
63
  * Colour the fade resolves to — normally whatever sits behind the
47
64
  * scrollable. Defaults to the theme's background.
48
65
  */
49
66
  color?: string;
67
+ /** Distance in pixels over which an edge fades from clear to full. */
68
+ fadeInDistance?: number;
69
+ /** Set false to render the child with no fades at all. */
70
+ enabled?: boolean;
50
71
  children?: ReactNode;
51
72
  }
52
73
 
53
74
  interface ScrollableProps {
54
75
  horizontal?: boolean;
55
- onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
76
+ onScroll?: AnimatedScrollViewProps['onScroll'];
77
+ onLayout?: (event: LayoutChangeEvent) => void;
78
+ onContentSizeChange?: (width: number, height: number) => void;
56
79
  scrollEventThrottle?: number;
57
80
  }
58
81
 
@@ -60,50 +83,106 @@ export function ScrollFade({
60
83
  className,
61
84
  size = 48,
62
85
  edges = 'both',
86
+ orientation,
63
87
  color,
88
+ fadeInDistance = DEFAULT_FADE_IN_DISTANCE,
89
+ enabled = true,
64
90
  children,
65
91
  ...props
66
92
  }: ScrollFadeProps) {
67
- const [startOpacity, setStartOpacity] = useState(0);
68
- const [endOpacity, setEndOpacity] = useState(0);
93
+ const offset = useSharedValue(0);
94
+ const contentLength = useSharedValue(0);
95
+ const viewportLength = useSharedValue(0);
69
96
 
70
97
  const themeBackground = useCSSVariable('--color-background');
71
98
  const fadeColor =
72
99
  color ?? (typeof themeBackground === 'string' ? themeBackground : '#000000');
73
100
 
74
101
  const child = Children.only(children) as ReactElement<ScrollableProps>;
75
- const horizontal = isValidElement(child) ? !!child.props.horizontal : false;
102
+ const horizontal =
103
+ orientation !== undefined
104
+ ? orientation === 'horizontal'
105
+ : isValidElement(child) && !!child.props.horizontal;
76
106
 
77
- const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
78
- const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
79
- const offset = horizontal ? contentOffset.x : contentOffset.y;
80
- const total = horizontal ? contentSize.width : contentSize.height;
81
- const visible = horizontal ? layoutMeasurement.width : layoutMeasurement.height;
82
- const remaining = total - visible - offset;
107
+ // Reanimated can only drive a scroll handler on an animated component, and
108
+ // `Animated.ScrollView` is not necessarily what was passed in — the child may
109
+ // be a FlatList, a SectionList or a custom scrollable. Keyed on the element
110
+ // *type*, not the element: rebuilding the wrapper would remount the list.
111
+ const childType = isValidElement(child)
112
+ ? (child.type as ComponentType<ScrollableProps>)
113
+ : null;
114
+ const AnimatedScrollable = useMemo(
115
+ () => (childType ? Animated.createAnimatedComponent(childType) : null),
116
+ [childType]
117
+ );
83
118
 
84
- // Each edge fades in over the first FADE_IN_DISTANCE px of travel, so an
85
- // edge with nothing past it shows nothing.
86
- setStartOpacity(Math.min(offset / FADE_IN_DISTANCE, 1));
87
- setEndOpacity(Math.min(Math.max(remaining, 0) / FADE_IN_DISTANCE, 1));
119
+ const scrollHandler = useAnimatedScrollHandler({
120
+ onScroll: (event) => {
121
+ const { contentOffset, contentSize, layoutMeasurement } = event;
122
+ offset.value = horizontal ? contentOffset.x : contentOffset.y;
123
+ contentLength.value = horizontal ? contentSize.width : contentSize.height;
124
+ viewportLength.value = horizontal
125
+ ? layoutMeasurement.width
126
+ : layoutMeasurement.height;
127
+ },
128
+ });
88
129
 
89
- child.props.onScroll?.(event);
130
+ // A consumer `onScroll` is composed rather than dropped — but because the
131
+ // child is now an animated component it has to be an animated handler too.
132
+ const onScroll = useComposedEventHandler([
133
+ scrollHandler,
134
+ (child.props.onScroll as typeof scrollHandler | undefined) ?? null,
135
+ ]);
136
+
137
+ // Measured up front as well as on scroll, so an end edge with content behind
138
+ // it fades from the first frame rather than waiting for a scroll event.
139
+ const onLayout = (event: LayoutChangeEvent) => {
140
+ const { width, height } = event.nativeEvent.layout;
141
+ viewportLength.value = horizontal ? width : height;
142
+ child.props.onLayout?.(event);
90
143
  };
91
144
 
92
- const scrollable = isValidElement(child)
93
- ? cloneElement(child, {
94
- onScroll: handleScroll,
95
- scrollEventThrottle: child.props.scrollEventThrottle ?? 16,
96
- })
97
- : child;
145
+ const onContentSizeChange = (width: number, height: number) => {
146
+ contentLength.value = horizontal ? width : height;
147
+ child.props.onContentSizeChange?.(width, height);
148
+ };
149
+
150
+ const startOpacity = useDerivedValue(() =>
151
+ Math.min(offset.value / fadeInDistance, 1)
152
+ );
153
+
154
+ const endOpacity = useDerivedValue(() => {
155
+ // Nothing to fade towards when the content fits inside the viewport.
156
+ const remaining = contentLength.value - viewportLength.value - offset.value;
157
+ return Math.min(Math.max(remaining, 0) / fadeInDistance, 1);
158
+ });
159
+
160
+ const scrollable =
161
+ AnimatedScrollable && isValidElement(child) ? (
162
+ <AnimatedScrollable
163
+ {...child.props}
164
+ onScroll={onScroll}
165
+ onLayout={onLayout}
166
+ onContentSizeChange={onContentSizeChange}
167
+ scrollEventThrottle={child.props.scrollEventThrottle ?? 16}
168
+ />
169
+ ) : (
170
+ child
171
+ );
98
172
 
99
- const showStart = edges !== 'end';
100
- const showEnd = edges !== 'start';
173
+ if (!enabled || edges === 'none') {
174
+ return (
175
+ <View {...props} className={className}>
176
+ {scrollable}
177
+ </View>
178
+ );
179
+ }
101
180
 
102
181
  return (
103
182
  <View {...props} className={className}>
104
183
  {scrollable}
105
184
 
106
- {showStart ? (
185
+ {edges !== 'end' ? (
107
186
  <Fade
108
187
  color={fadeColor}
109
188
  size={size}
@@ -112,7 +191,7 @@ export function ScrollFade({
112
191
  edge="start"
113
192
  />
114
193
  ) : null}
115
- {showEnd ? (
194
+ {edges !== 'start' ? (
116
195
  <Fade
117
196
  color={fadeColor}
118
197
  size={size}
@@ -138,31 +217,64 @@ function Fade({
138
217
  color: string;
139
218
  size: number;
140
219
  horizontal: boolean;
141
- opacity: number;
220
+ opacity: DerivedValue<number>;
142
221
  edge: 'start' | 'end';
143
222
  }) {
144
223
  const isStart = edge === 'start';
145
- const id = `scroll-fade-${edge}-${horizontal ? 'x' : 'y'}`;
146
224
 
147
225
  const position = horizontal
148
226
  ? { top: 0, bottom: 0, width: size, ...(isStart ? { left: 0 } : { right: 0 }) }
149
227
  : { left: 0, right: 0, height: size, ...(isStart ? { top: 0 } : { bottom: 0 }) };
150
228
 
151
- // The gradient runs from the screen edge inwards, so it is reversed on the
229
+ const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
230
+
231
+ // The gradient runs from the edge inwards, so its stops are reversed on the
152
232
  // end edge.
153
- const [x2, y2] = horizontal ? [1, 0] : [0, 1];
233
+ const colors: [string, string] = isStart
234
+ ? [withAlpha(color, 1), withAlpha(color, 0)]
235
+ : [withAlpha(color, 0), withAlpha(color, 1)];
154
236
 
155
237
  return (
156
- <View pointerEvents="none" style={{ position: 'absolute', opacity, ...position }}>
157
- <Svg width="100%" height="100%">
158
- <Defs>
159
- <LinearGradient id={id} x1="0" y1="0" x2={String(x2)} y2={String(y2)}>
160
- <Stop offset="0" stopColor={color} stopOpacity={isStart ? 1 : 0} />
161
- <Stop offset="1" stopColor={color} stopOpacity={isStart ? 0 : 1} />
162
- </LinearGradient>
163
- </Defs>
164
- <Rect x="0" y="0" width="100%" height="100%" fill={`url(#${id})`} />
165
- </Svg>
166
- </View>
238
+ <Animated.View
239
+ pointerEvents="none"
240
+ style={[{ position: 'absolute' }, position, style]}
241
+ >
242
+ <LinearGradient
243
+ colors={colors}
244
+ start={{ x: 0, y: 0 }}
245
+ end={horizontal ? { x: 1, y: 0 } : { x: 0, y: 1 }}
246
+ style={StyleSheet.absoluteFill}
247
+ />
248
+ </Animated.View>
167
249
  );
168
250
  }
251
+
252
+ /**
253
+ * Gradients need a transparent stop of the *same* colour — `transparent` is
254
+ * black at zero alpha on Android, which shows as a grey smear.
255
+ */
256
+ function withAlpha(color: string, alpha: number): string {
257
+ if (color.startsWith('#')) {
258
+ const hex = color.slice(1);
259
+ const full =
260
+ hex.length === 3
261
+ ? hex
262
+ .split('')
263
+ .map((c) => c + c)
264
+ .join('')
265
+ : hex.slice(0, 6);
266
+ const r = parseInt(full.slice(0, 2), 16);
267
+ const g = parseInt(full.slice(2, 4), 16);
268
+ const b = parseInt(full.slice(4, 6), 16);
269
+ if (Number.isNaN(r + g + b)) return color;
270
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
271
+ }
272
+
273
+ const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
274
+ if (channels) {
275
+ const [r, g, b] = channels.split(',').map((part) => part.trim());
276
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
277
+ }
278
+
279
+ return color;
280
+ }
@@ -12,6 +12,7 @@ import { Pressable, ScrollView, View } from 'react-native';
12
12
  import { tv } from 'tailwind-variants';
13
13
  import { useCSSVariable } from 'uniwind';
14
14
  import { CheckIcon, ChevronDownIcon } from '../../icons';
15
+ import { getNativeUI } from '../../native';
15
16
  import { Text } from '../../primitives/text';
16
17
  import { BottomSheet } from '../bottom-sheet';
17
18
 
@@ -86,6 +87,21 @@ export interface SelectProps {
86
87
  /** Sheet title shown above the options. */
87
88
  title?: string;
88
89
  disabled?: boolean;
90
+ /**
91
+ * Render the platform's own picker instead of the trigger-and-sheet pair.
92
+ * Requires the optional `@expo/ui` package; without it this prop does
93
+ * nothing.
94
+ *
95
+ * **Theme tokens do not apply** — the platform draws the picker, so
96
+ * `className` and `title` are ignored. `Select.Item` children still declare
97
+ * the options.
98
+ */
99
+ native?: boolean;
100
+ /**
101
+ * Native picker style. `menu` is a compact button opening a dropdown;
102
+ * `wheel` is an always-visible rotor (iOS; falls back to `menu` elsewhere).
103
+ */
104
+ nativeAppearance?: 'menu' | 'wheel';
89
105
  children: ReactNode;
90
106
  }
91
107
 
@@ -96,22 +112,27 @@ function SelectRoot({
96
112
  placeholder = 'Select an option',
97
113
  title,
98
114
  disabled,
115
+ native,
116
+ nativeAppearance = 'menu',
99
117
  children,
100
118
  }: SelectProps) {
101
119
  const [open, setOpen] = useState(false);
120
+ const nativeUI = native ? getNativeUI() : null;
102
121
 
103
- const selectedLabel = useMemo(() => {
104
- let label: string | undefined;
122
+ const options = useMemo(() => {
123
+ const collected: SelectItemProps[] = [];
105
124
  Children.forEach(children, (child) => {
106
- if (
107
- isValidElement<SelectItemProps>(child) &&
108
- child.props.value === value
109
- ) {
110
- label = child.props.label;
125
+ if (isValidElement<SelectItemProps>(child)) {
126
+ collected.push({ value: child.props.value, label: child.props.label });
111
127
  }
112
128
  });
113
- return label;
114
- }, [children, value]);
129
+ return collected;
130
+ }, [children]);
131
+
132
+ const selectedLabel = useMemo(
133
+ () => options.find((option) => option.value === value)?.label,
134
+ [options, value]
135
+ );
115
136
 
116
137
  const context = useMemo<SelectContextValue>(
117
138
  () => ({
@@ -129,6 +150,30 @@ function SelectRoot({
129
150
  });
130
151
  const chevronColor = useCSSVariable('--color-muted-foreground');
131
152
 
153
+ if (nativeUI) {
154
+ const { Host, Picker } = nativeUI;
155
+ // The native picker has no empty state, so an unset value shows the first
156
+ // option rather than the placeholder.
157
+ return (
158
+ <Host matchContents>
159
+ <Picker
160
+ selectedValue={value ?? options[0]?.value ?? ''}
161
+ onValueChange={(next: string) => onValueChange(next)}
162
+ appearance={nativeAppearance}
163
+ enabled={!disabled}
164
+ >
165
+ {options.map((option) => (
166
+ <Picker.Item
167
+ key={option.value}
168
+ label={option.label}
169
+ value={option.value}
170
+ />
171
+ ))}
172
+ </Picker>
173
+ </Host>
174
+ );
175
+ }
176
+
132
177
  return (
133
178
  <SelectContext.Provider value={context}>
134
179
  <Pressable