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.
- package/README.md +9 -5
- package/lib/module/components/accordion/index.js +7 -5
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/alert/index.js +3 -6
- package/lib/module/components/alert/index.js.map +1 -1
- package/lib/module/components/bottom-sheet/index.js +67 -3
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +36 -1
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/empty-state/index.js +4 -5
- package/lib/module/components/empty-state/index.js.map +1 -1
- package/lib/module/components/inline-select/index.js +171 -46
- package/lib/module/components/inline-select/index.js.map +1 -1
- package/lib/module/components/input/index.js +28 -5
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/input-group/index.js +0 -1
- package/lib/module/components/input-group/index.js.map +1 -1
- package/lib/module/components/item/index.js +359 -0
- package/lib/module/components/item/index.js.map +1 -0
- package/lib/module/components/label/index.js +5 -3
- package/lib/module/components/label/index.js.map +1 -1
- package/lib/module/components/message/index.js +318 -0
- package/lib/module/components/message/index.js.map +1 -0
- package/lib/module/components/scroll-fade/index.js +204 -0
- package/lib/module/components/scroll-fade/index.js.map +1 -0
- package/lib/module/components/select/index.js +35 -6
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/shimmer/index.js +167 -0
- package/lib/module/components/shimmer/index.js.map +1 -0
- package/lib/module/components/surface/index.js +65 -0
- package/lib/module/components/surface/index.js.map +1 -0
- package/lib/module/components/switch/index.js +20 -1
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/toast/index.js +7 -8
- package/lib/module/components/toast/index.js.map +1 -1
- package/lib/module/components/typography/index.js +3 -3
- package/lib/module/hooks/index.js +10 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/use-breakpoint.js +39 -0
- package/lib/module/hooks/use-breakpoint.js.map +1 -0
- package/lib/module/hooks/use-copy-to-clipboard.js +60 -0
- package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -0
- package/lib/module/hooks/use-debounced-value.js +24 -0
- package/lib/module/hooks/use-debounced-value.js.map +1 -0
- package/lib/module/hooks/use-disclosure.js +43 -0
- package/lib/module/hooks/use-disclosure.js.map +1 -0
- package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
- package/lib/module/hooks/use-keyboard.js +36 -0
- package/lib/module/hooks/use-keyboard.js.map +1 -0
- package/lib/module/hooks/use-previous.js +25 -0
- package/lib/module/hooks/use-previous.js.map +1 -0
- package/lib/module/icons/index.js +4 -4
- package/lib/module/index.js +12 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +53 -0
- package/lib/module/native/index.js.map +1 -0
- package/lib/module/primitives/keyboard-avoider.js +40 -0
- package/lib/module/primitives/keyboard-avoider.js.map +1 -0
- package/lib/module/providers/panel-ui-provider.js +24 -4
- package/lib/module/providers/panel-ui-provider.js.map +1 -1
- package/lib/module/theme/use-theme.js +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +7 -5
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/alert/index.d.ts +3 -6
- package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +21 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +11 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/empty-state/index.d.ts +4 -5
- package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
- package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
- package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/index.d.ts +13 -0
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input-group/index.d.ts +0 -1
- package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/item/index.d.ts +286 -0
- package/lib/typescript/src/components/item/index.d.ts.map +1 -0
- package/lib/typescript/src/components/label/index.d.ts +5 -3
- package/lib/typescript/src/components/label/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message/index.d.ts +170 -0
- package/lib/typescript/src/components/message/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-fade/index.d.ts +47 -0
- package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +16 -1
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/shimmer/index.d.ts +60 -0
- package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -0
- package/lib/typescript/src/components/surface/index.d.ts +30 -0
- package/lib/typescript/src/components/surface/index.d.ts.map +1 -0
- package/lib/typescript/src/components/switch/index.d.ts +10 -0
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/index.d.ts +4 -4
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +8 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts +32 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-debounced-value.d.ts +13 -0
- package/lib/typescript/src/hooks/use-debounced-value.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-disclosure.d.ts +32 -0
- package/lib/typescript/src/hooks/use-disclosure.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-keyboard.d.ts +20 -0
- package/lib/typescript/src/hooks/use-keyboard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-previous.d.ts +15 -0
- package/lib/typescript/src/hooks/use-previous.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +4 -4
- package/lib/typescript/src/index.d.ts +8 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +35 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -0
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
- package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/lib/typescript/src/theme/use-theme.d.ts +1 -1
- package/package.json +19 -3
- package/src/components/accordion/index.tsx +7 -5
- package/src/components/alert/index.tsx +3 -6
- package/src/components/bottom-sheet/index.tsx +82 -2
- package/src/components/button/index.tsx +52 -0
- package/src/components/empty-state/index.tsx +4 -5
- package/src/components/inline-select/index.tsx +191 -44
- package/src/components/input/index.tsx +39 -3
- package/src/components/input-group/index.tsx +0 -1
- package/src/components/item/index.tsx +387 -0
- package/src/components/label/index.tsx +5 -3
- package/src/components/message/index.tsx +312 -0
- package/src/components/scroll-fade/index.tsx +280 -0
- package/src/components/select/index.tsx +54 -9
- package/src/components/shimmer/index.tsx +228 -0
- package/src/components/surface/index.tsx +64 -0
- package/src/components/switch/index.tsx +27 -1
- package/src/components/toast/index.tsx +7 -8
- package/src/components/typography/index.tsx +3 -3
- package/src/hooks/index.ts +24 -0
- package/src/hooks/use-breakpoint.ts +52 -0
- package/src/hooks/use-copy-to-clipboard.ts +79 -0
- package/src/hooks/use-debounced-value.ts +23 -0
- package/src/hooks/use-disclosure.ts +57 -0
- package/src/hooks/use-keyboard-avoidance.ts +140 -0
- package/src/hooks/use-keyboard.ts +43 -0
- package/src/hooks/use-previous.ts +24 -0
- package/src/icons/index.tsx +4 -4
- package/src/index.ts +41 -0
- package/src/native/index.ts +79 -0
- package/src/primitives/keyboard-avoider.tsx +49 -0
- package/src/providers/panel-ui-provider.tsx +30 -9
- package/src/theme/use-theme.ts +1 -1
- 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
|
|
104
|
-
|
|
122
|
+
const options = useMemo(() => {
|
|
123
|
+
const collected: SelectItemProps[] = [];
|
|
105
124
|
Children.forEach(children, (child) => {
|
|
106
|
-
if (
|
|
107
|
-
|
|
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
|
|
114
|
-
}, [children
|
|
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
|
+
});
|