panelui-native 0.5.3 → 0.7.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 (156) hide show
  1. package/README.md +9 -5
  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/bottom-sheet/index.js +67 -3
  7. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  8. package/lib/module/components/button/index.js +36 -1
  9. package/lib/module/components/button/index.js.map +1 -1
  10. package/lib/module/components/empty-state/index.js +4 -5
  11. package/lib/module/components/empty-state/index.js.map +1 -1
  12. package/lib/module/components/inline-select/index.js +171 -46
  13. package/lib/module/components/inline-select/index.js.map +1 -1
  14. package/lib/module/components/input/index.js +28 -5
  15. package/lib/module/components/input/index.js.map +1 -1
  16. package/lib/module/components/input-group/index.js +0 -1
  17. package/lib/module/components/input-group/index.js.map +1 -1
  18. package/lib/module/components/item/index.js +359 -0
  19. package/lib/module/components/item/index.js.map +1 -0
  20. package/lib/module/components/label/index.js +5 -3
  21. package/lib/module/components/label/index.js.map +1 -1
  22. package/lib/module/components/message/index.js +318 -0
  23. package/lib/module/components/message/index.js.map +1 -0
  24. package/lib/module/components/scroll-fade/index.js +204 -0
  25. package/lib/module/components/scroll-fade/index.js.map +1 -0
  26. package/lib/module/components/select/index.js +35 -6
  27. package/lib/module/components/select/index.js.map +1 -1
  28. package/lib/module/components/shimmer/index.js +167 -0
  29. package/lib/module/components/shimmer/index.js.map +1 -0
  30. package/lib/module/components/surface/index.js +65 -0
  31. package/lib/module/components/surface/index.js.map +1 -0
  32. package/lib/module/components/switch/index.js +20 -1
  33. package/lib/module/components/switch/index.js.map +1 -1
  34. package/lib/module/components/toast/index.js +7 -8
  35. package/lib/module/components/toast/index.js.map +1 -1
  36. package/lib/module/components/typography/index.js +3 -3
  37. package/lib/module/hooks/index.js +10 -0
  38. package/lib/module/hooks/index.js.map +1 -0
  39. package/lib/module/hooks/use-breakpoint.js +39 -0
  40. package/lib/module/hooks/use-breakpoint.js.map +1 -0
  41. package/lib/module/hooks/use-copy-to-clipboard.js +60 -0
  42. package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -0
  43. package/lib/module/hooks/use-debounced-value.js +24 -0
  44. package/lib/module/hooks/use-debounced-value.js.map +1 -0
  45. package/lib/module/hooks/use-disclosure.js +43 -0
  46. package/lib/module/hooks/use-disclosure.js.map +1 -0
  47. package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
  48. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
  49. package/lib/module/hooks/use-keyboard.js +36 -0
  50. package/lib/module/hooks/use-keyboard.js.map +1 -0
  51. package/lib/module/hooks/use-previous.js +25 -0
  52. package/lib/module/hooks/use-previous.js.map +1 -0
  53. package/lib/module/icons/index.js +4 -4
  54. package/lib/module/index.js +12 -0
  55. package/lib/module/index.js.map +1 -1
  56. package/lib/module/native/index.js +53 -0
  57. package/lib/module/native/index.js.map +1 -0
  58. package/lib/module/primitives/keyboard-avoider.js +40 -0
  59. package/lib/module/primitives/keyboard-avoider.js.map +1 -0
  60. package/lib/module/providers/panel-ui-provider.js +24 -4
  61. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  62. package/lib/module/theme/use-theme.js +1 -1
  63. package/lib/typescript/src/components/accordion/index.d.ts +7 -5
  64. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  65. package/lib/typescript/src/components/alert/index.d.ts +3 -6
  66. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/bottom-sheet/index.d.ts +21 -1
  68. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/button/index.d.ts +11 -0
  70. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/empty-state/index.d.ts +4 -5
  72. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  73. package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
  74. package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/input/index.d.ts +13 -0
  76. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/input-group/index.d.ts +0 -1
  78. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  79. package/lib/typescript/src/components/item/index.d.ts +286 -0
  80. package/lib/typescript/src/components/item/index.d.ts.map +1 -0
  81. package/lib/typescript/src/components/label/index.d.ts +5 -3
  82. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/message/index.d.ts +170 -0
  84. package/lib/typescript/src/components/message/index.d.ts.map +1 -0
  85. package/lib/typescript/src/components/scroll-fade/index.d.ts +47 -0
  86. package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -0
  87. package/lib/typescript/src/components/select/index.d.ts +16 -1
  88. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/shimmer/index.d.ts +60 -0
  90. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -0
  91. package/lib/typescript/src/components/surface/index.d.ts +30 -0
  92. package/lib/typescript/src/components/surface/index.d.ts.map +1 -0
  93. package/lib/typescript/src/components/switch/index.d.ts +10 -0
  94. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/toast/index.d.ts +4 -4
  96. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  97. package/lib/typescript/src/hooks/index.d.ts +8 -0
  98. package/lib/typescript/src/hooks/index.d.ts.map +1 -0
  99. package/lib/typescript/src/hooks/use-breakpoint.d.ts +32 -0
  100. package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -0
  101. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +28 -0
  102. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -0
  103. package/lib/typescript/src/hooks/use-debounced-value.d.ts +13 -0
  104. package/lib/typescript/src/hooks/use-debounced-value.d.ts.map +1 -0
  105. package/lib/typescript/src/hooks/use-disclosure.d.ts +32 -0
  106. package/lib/typescript/src/hooks/use-disclosure.d.ts.map +1 -0
  107. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
  108. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
  109. package/lib/typescript/src/hooks/use-keyboard.d.ts +20 -0
  110. package/lib/typescript/src/hooks/use-keyboard.d.ts.map +1 -0
  111. package/lib/typescript/src/hooks/use-previous.d.ts +15 -0
  112. package/lib/typescript/src/hooks/use-previous.d.ts.map +1 -0
  113. package/lib/typescript/src/icons/index.d.ts +4 -4
  114. package/lib/typescript/src/index.d.ts +8 -0
  115. package/lib/typescript/src/index.d.ts.map +1 -1
  116. package/lib/typescript/src/native/index.d.ts +35 -0
  117. package/lib/typescript/src/native/index.d.ts.map +1 -0
  118. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
  119. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
  120. package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
  121. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  122. package/lib/typescript/src/theme/use-theme.d.ts +1 -1
  123. package/package.json +19 -3
  124. package/src/components/accordion/index.tsx +7 -5
  125. package/src/components/alert/index.tsx +3 -6
  126. package/src/components/bottom-sheet/index.tsx +82 -2
  127. package/src/components/button/index.tsx +52 -0
  128. package/src/components/empty-state/index.tsx +4 -5
  129. package/src/components/inline-select/index.tsx +191 -44
  130. package/src/components/input/index.tsx +39 -3
  131. package/src/components/input-group/index.tsx +0 -1
  132. package/src/components/item/index.tsx +387 -0
  133. package/src/components/label/index.tsx +5 -3
  134. package/src/components/message/index.tsx +312 -0
  135. package/src/components/scroll-fade/index.tsx +280 -0
  136. package/src/components/select/index.tsx +54 -9
  137. package/src/components/shimmer/index.tsx +228 -0
  138. package/src/components/surface/index.tsx +64 -0
  139. package/src/components/switch/index.tsx +27 -1
  140. package/src/components/toast/index.tsx +7 -8
  141. package/src/components/typography/index.tsx +3 -3
  142. package/src/hooks/index.ts +24 -0
  143. package/src/hooks/use-breakpoint.ts +52 -0
  144. package/src/hooks/use-copy-to-clipboard.ts +79 -0
  145. package/src/hooks/use-debounced-value.ts +23 -0
  146. package/src/hooks/use-disclosure.ts +57 -0
  147. package/src/hooks/use-keyboard-avoidance.ts +140 -0
  148. package/src/hooks/use-keyboard.ts +43 -0
  149. package/src/hooks/use-previous.ts +24 -0
  150. package/src/icons/index.tsx +4 -4
  151. package/src/index.ts +41 -0
  152. package/src/native/index.ts +79 -0
  153. package/src/primitives/keyboard-avoider.tsx +49 -0
  154. package/src/providers/panel-ui-provider.tsx +30 -9
  155. package/src/theme/use-theme.ts +1 -1
  156. package/theme.css +23 -3
@@ -0,0 +1,280 @@
1
+ /**
2
+ * ScrollFade — fades the edges of a scroll container.
3
+ *
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.
7
+ *
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.
11
+ *
12
+ * ```tsx
13
+ * <ScrollFade>
14
+ * <ScrollView horizontal>…</ScrollView>
15
+ * </ScrollFade>
16
+ * ```
17
+ */
18
+ import {
19
+ Children,
20
+ cloneElement,
21
+ isValidElement,
22
+ useMemo,
23
+ type ComponentType,
24
+ type ReactElement,
25
+ type ReactNode,
26
+ } from 'react';
27
+ import {
28
+ StyleSheet,
29
+ View,
30
+ type LayoutChangeEvent,
31
+ type NativeScrollEvent,
32
+ type NativeSyntheticEvent,
33
+ type ViewProps,
34
+ } from 'react-native';
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';
45
+ import { useCSSVariable } from 'uniwind';
46
+
47
+ /** Past this many pixels from an edge, that edge's fade is fully shown. */
48
+ const DEFAULT_FADE_IN_DISTANCE = 24;
49
+
50
+ export interface ScrollFadeProps extends ViewProps {
51
+ className?: string;
52
+ /** Depth of the fade in pixels. */
53
+ size?: number;
54
+ /** Which edges fade. */
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';
62
+ /**
63
+ * Colour the fade resolves to — normally whatever sits behind the
64
+ * scrollable. Defaults to the theme's background.
65
+ */
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;
71
+ children?: ReactNode;
72
+ }
73
+
74
+ interface ScrollableProps {
75
+ horizontal?: boolean;
76
+ onScroll?: AnimatedScrollViewProps['onScroll'];
77
+ onLayout?: (event: LayoutChangeEvent) => void;
78
+ onContentSizeChange?: (width: number, height: number) => void;
79
+ scrollEventThrottle?: number;
80
+ }
81
+
82
+ export function ScrollFade({
83
+ className,
84
+ size = 48,
85
+ edges = 'both',
86
+ orientation,
87
+ color,
88
+ fadeInDistance = DEFAULT_FADE_IN_DISTANCE,
89
+ enabled = true,
90
+ children,
91
+ ...props
92
+ }: ScrollFadeProps) {
93
+ const offset = useSharedValue(0);
94
+ const contentLength = useSharedValue(0);
95
+ const viewportLength = useSharedValue(0);
96
+
97
+ const themeBackground = useCSSVariable('--color-background');
98
+ const fadeColor =
99
+ color ?? (typeof themeBackground === 'string' ? themeBackground : '#000000');
100
+
101
+ const child = Children.only(children) as ReactElement<ScrollableProps>;
102
+ const horizontal =
103
+ orientation !== undefined
104
+ ? orientation === 'horizontal'
105
+ : isValidElement(child) && !!child.props.horizontal;
106
+
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
+ );
118
+
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
+ });
129
+
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);
143
+ };
144
+
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
+ );
172
+
173
+ if (!enabled || edges === 'none') {
174
+ return (
175
+ <View {...props} className={className}>
176
+ {scrollable}
177
+ </View>
178
+ );
179
+ }
180
+
181
+ return (
182
+ <View {...props} className={className}>
183
+ {scrollable}
184
+
185
+ {edges !== 'end' ? (
186
+ <Fade
187
+ color={fadeColor}
188
+ size={size}
189
+ horizontal={horizontal}
190
+ opacity={startOpacity}
191
+ edge="start"
192
+ />
193
+ ) : null}
194
+ {edges !== 'start' ? (
195
+ <Fade
196
+ color={fadeColor}
197
+ size={size}
198
+ horizontal={horizontal}
199
+ opacity={endOpacity}
200
+ edge="end"
201
+ />
202
+ ) : null}
203
+ </View>
204
+ );
205
+ }
206
+
207
+ ScrollFade.displayName = 'ScrollFade';
208
+
209
+ /** One edge's gradient, opaque at the edge and clear inwards. */
210
+ function Fade({
211
+ color,
212
+ size,
213
+ horizontal,
214
+ opacity,
215
+ edge,
216
+ }: {
217
+ color: string;
218
+ size: number;
219
+ horizontal: boolean;
220
+ opacity: DerivedValue<number>;
221
+ edge: 'start' | 'end';
222
+ }) {
223
+ const isStart = edge === 'start';
224
+
225
+ const position = horizontal
226
+ ? { top: 0, bottom: 0, width: size, ...(isStart ? { left: 0 } : { right: 0 }) }
227
+ : { left: 0, right: 0, height: size, ...(isStart ? { top: 0 } : { bottom: 0 }) };
228
+
229
+ const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
230
+
231
+ // The gradient runs from the edge inwards, so its stops are reversed on the
232
+ // end edge.
233
+ const colors: [string, string] = isStart
234
+ ? [withAlpha(color, 1), withAlpha(color, 0)]
235
+ : [withAlpha(color, 0), withAlpha(color, 1)];
236
+
237
+ return (
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>
249
+ );
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
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Shimmer — a highlight that sweeps *through* content rather than over it.
3
+ *
4
+ * The content is used as a mask, and a gradient band is swept behind it on the
5
+ * UI thread. Because the content is the mask, the highlight is clipped to the
6
+ * glyph shapes: text reads as though the letters themselves are catching a
7
+ * light, which is the "thinking…" treatment familiar from AI chat interfaces.
8
+ *
9
+ * ```tsx
10
+ * <Shimmer>Thinking…</Shimmer>
11
+ * ```
12
+ *
13
+ * Set `as="view"` to shimmer arbitrary children (skeleton blocks, cards) — the
14
+ * mask is then the rendered subtree's alpha instead of a line of text.
15
+ */
16
+ import { useEffect, useState, type ReactNode } from 'react';
17
+ import {
18
+ StyleSheet,
19
+ View,
20
+ type LayoutChangeEvent,
21
+ type StyleProp,
22
+ type TextStyle,
23
+ type ViewProps,
24
+ } from 'react-native';
25
+ import MaskedView from '@react-native-masked-view/masked-view';
26
+ import { LinearGradient } from 'expo-linear-gradient';
27
+ import Animated, {
28
+ cancelAnimation,
29
+ Easing,
30
+ useAnimatedStyle,
31
+ useReducedMotion,
32
+ useSharedValue,
33
+ withRepeat,
34
+ withTiming,
35
+ } from 'react-native-reanimated';
36
+ import { useCSSVariable } from 'uniwind';
37
+ import { Text } from '../../primitives/text';
38
+ import { cn } from '../../utils/cn';
39
+
40
+ export interface ShimmerProps extends ViewProps {
41
+ className?: string;
42
+ /**
43
+ * `text` renders `children` as a single styled string and masks the sweep to
44
+ * the glyphs. `view` masks the sweep to whatever subtree you pass.
45
+ */
46
+ as?: 'text' | 'view';
47
+ /** Milliseconds for one sweep. */
48
+ duration?: number;
49
+ /** Width of the highlight band, as a multiple of the content width. */
50
+ spread?: number;
51
+ /** Colour of the content at rest. Defaults to the theme's muted foreground. */
52
+ baseColor?: string;
53
+ /** Colour at the centre of the sweep. Defaults to the theme's foreground. */
54
+ shimmerColor?: string;
55
+ /** `loop` restarts from the left; `ping-pong` reverses on each pass. */
56
+ mode?: 'loop' | 'ping-pong';
57
+ /** Sweep once instead of repeating. */
58
+ once?: boolean;
59
+ /** Sweep right-to-left. */
60
+ reverse?: boolean;
61
+ /** Set false to render the content statically without animating. */
62
+ enabled?: boolean;
63
+ /** Extra classes for the text when `as="text"`. */
64
+ textClassName?: string;
65
+ /** Extra styles for the text when `as="text"`. */
66
+ textStyle?: StyleProp<TextStyle>;
67
+ /**
68
+ * @deprecated Use `shimmerColor`.
69
+ */
70
+ color?: string;
71
+ /**
72
+ * Peak opacity of the highlight.
73
+ * @deprecated Set `shimmerColor` to a colour with the alpha you want.
74
+ */
75
+ intensity?: number;
76
+ children?: ReactNode;
77
+ }
78
+
79
+ export function Shimmer({
80
+ className,
81
+ as = 'text',
82
+ duration = 2000,
83
+ spread = 2,
84
+ baseColor,
85
+ shimmerColor,
86
+ mode = 'loop',
87
+ once = false,
88
+ reverse = false,
89
+ enabled = true,
90
+ textClassName,
91
+ textStyle,
92
+ color,
93
+ intensity,
94
+ children,
95
+ ...props
96
+ }: ShimmerProps) {
97
+ const [width, setWidth] = useState(0);
98
+ const progress = useSharedValue(0);
99
+ const reducedMotion = useReducedMotion();
100
+
101
+ const themeMuted = useCSSVariable('--color-muted-foreground');
102
+ const themeForeground = useCSSVariable('--color-foreground');
103
+
104
+ const base =
105
+ baseColor ?? (typeof themeMuted === 'string' ? themeMuted : '#a1a1aa');
106
+ const highlight =
107
+ shimmerColor ??
108
+ color ??
109
+ (typeof themeForeground === 'string' ? themeForeground : '#ffffff');
110
+
111
+ // The band is wider than the content so the highlight ramps in and out
112
+ // instead of appearing at full strength against the edge.
113
+ const bandWidth = Math.max(width * spread, 1);
114
+ const animating = enabled && !reducedMotion && width > 0;
115
+
116
+ useEffect(() => {
117
+ if (!animating) {
118
+ cancelAnimation(progress);
119
+ progress.value = 0;
120
+ return;
121
+ }
122
+
123
+ progress.value = 0;
124
+ progress.value = withRepeat(
125
+ withTiming(1, { duration, easing: Easing.linear }),
126
+ once ? 1 : -1,
127
+ mode === 'ping-pong'
128
+ );
129
+
130
+ return () => cancelAnimation(progress);
131
+ }, [animating, duration, mode, once, progress]);
132
+
133
+ const sweepStyle = useAnimatedStyle(() => {
134
+ // Travel from fully off one edge to fully off the other.
135
+ const from = -bandWidth;
136
+ const to = width;
137
+ const x = reverse
138
+ ? to - progress.value * (to - from)
139
+ : from + progress.value * (to - from);
140
+ return { transform: [{ translateX: x }] };
141
+ });
142
+
143
+ const onLayout = (event: LayoutChangeEvent) => {
144
+ setWidth(event.nativeEvent.layout.width);
145
+ props.onLayout?.(event);
146
+ };
147
+
148
+ const content =
149
+ as === 'text' ? (
150
+ <Text className={textClassName} style={[{ color: base }, textStyle]}>
151
+ {children}
152
+ </Text>
153
+ ) : (
154
+ children
155
+ );
156
+
157
+ // Without a measured width there is nothing to sweep across, and masking an
158
+ // unmeasured subtree just costs a layer — render the content plainly.
159
+ if (!animating) {
160
+ return (
161
+ <View {...props} onLayout={onLayout} className={className}>
162
+ {content}
163
+ </View>
164
+ );
165
+ }
166
+
167
+ const highlightWithAlpha =
168
+ intensity !== undefined ? withAlpha(highlight, intensity) : highlight;
169
+
170
+ return (
171
+ <View {...props} onLayout={onLayout} className={cn('overflow-hidden', className)}>
172
+ <MaskedView maskElement={<View>{content}</View>}>
173
+ {/* The mask only exposes the glyphs, so this layer is what they are
174
+ painted with: flat base colour, plus a moving gradient band. */}
175
+ <View style={{ backgroundColor: base }}>
176
+ <View style={{ opacity: 0 }}>{content}</View>
177
+ <Animated.View
178
+ pointerEvents="none"
179
+ style={[
180
+ StyleSheet.absoluteFill,
181
+ { width: bandWidth, left: 0, right: undefined },
182
+ sweepStyle,
183
+ ]}
184
+ >
185
+ <LinearGradient
186
+ colors={[base, highlightWithAlpha, base]}
187
+ locations={[0, 0.5, 1]}
188
+ start={{ x: 0, y: 0 }}
189
+ end={{ x: 1, y: 0 }}
190
+ style={StyleSheet.absoluteFill}
191
+ />
192
+ </Animated.View>
193
+ </View>
194
+ </MaskedView>
195
+ </View>
196
+ );
197
+ }
198
+
199
+ Shimmer.displayName = 'Shimmer';
200
+
201
+ /** Applies an alpha to a `#rgb`/`#rrggbb`/`rgb()` colour. Supports `intensity`. */
202
+ function withAlpha(color: string, alpha: number): string {
203
+ const clamped = Math.max(0, Math.min(1, alpha));
204
+
205
+ if (color.startsWith('#')) {
206
+ const hex = color.slice(1);
207
+ const full =
208
+ hex.length === 3
209
+ ? hex
210
+ .split('')
211
+ .map((c) => c + c)
212
+ .join('')
213
+ : hex.slice(0, 6);
214
+ const r = parseInt(full.slice(0, 2), 16);
215
+ const g = parseInt(full.slice(2, 4), 16);
216
+ const b = parseInt(full.slice(4, 6), 16);
217
+ if (Number.isNaN(r + g + b)) return color;
218
+ return `rgba(${r}, ${g}, ${b}, ${clamped})`;
219
+ }
220
+
221
+ const channels = color.match(/rgba?\(([^)]+)\)/)?.[1];
222
+ if (channels) {
223
+ const [r, g, b] = channels.split(',').map((part) => part.trim());
224
+ return `rgba(${r}, ${g}, ${b}, ${clamped})`;
225
+ }
226
+
227
+ return color;
228
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Surface — an elevated container.
3
+ *
4
+ * The variants form a ladder rather than a palette: each step is one level
5
+ * further from the background, and nesting them is how you build depth without
6
+ * hardcoding a colour anywhere.
7
+ *
8
+ * Both the fill and the corner radius follow the active theme: the variants
9
+ * resolve to themed `--color-surface-*` tokens, and `rounded-3xl` resolves
10
+ * through the per-theme radius scale — so Moon's surfaces are tight and
11
+ * Grass's are soft, without the component knowing anything about either.
12
+ *
13
+ * ```tsx
14
+ * <Surface>
15
+ * <Surface variant="secondary">
16
+ * <Surface variant="tertiary">…</Surface>
17
+ * </Surface>
18
+ * </Surface>
19
+ * ```
20
+ */
21
+ import { forwardRef } from 'react';
22
+ import { StyleSheet, View, type ViewProps } from 'react-native';
23
+ import { tv, type VariantProps } from 'tailwind-variants';
24
+
25
+ const surfaceVariants = tv({
26
+ base: 'overflow-hidden rounded-3xl p-4',
27
+ variants: {
28
+ variant: {
29
+ default: 'bg-surface',
30
+ secondary: 'bg-surface-secondary',
31
+ tertiary: 'bg-surface-tertiary',
32
+ transparent: 'bg-transparent',
33
+ },
34
+ },
35
+ defaultVariants: {
36
+ variant: 'default',
37
+ },
38
+ });
39
+
40
+ export interface SurfaceProps
41
+ extends ViewProps,
42
+ VariantProps<typeof surfaceVariants> {
43
+ className?: string;
44
+ }
45
+
46
+ export const Surface = forwardRef<View, SurfaceProps>(
47
+ ({ className, variant, style, ...props }, ref) => (
48
+ <View
49
+ ref={ref}
50
+ // `borderCurve` has no Tailwind equivalent. On iOS it gives the
51
+ // continuous (squircle) corner Apple uses, which is visibly smoother
52
+ // than a circular arc at this radius; Android ignores it.
53
+ style={[styles.root, style]}
54
+ className={surfaceVariants({ variant, className })}
55
+ {...props}
56
+ />
57
+ )
58
+ );
59
+
60
+ Surface.displayName = 'Surface';
61
+
62
+ const styles = StyleSheet.create({
63
+ root: { borderCurve: 'continuous' },
64
+ });