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.
- package/README.md +1 -1
- 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/attachment/index.js +219 -0
- package/lib/module/components/attachment/index.js.map +1 -0
- package/lib/module/components/bottom-sheet/index.js +82 -4
- 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/dialog/index.js +6 -2
- package/lib/module/components/dialog/index.js.map +1 -1
- package/lib/module/components/empty-state/index.js +98 -22
- 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/line-chart/index.js +1024 -0
- package/lib/module/components/line-chart/index.js.map +1 -0
- package/lib/module/components/marker/index.js +190 -0
- package/lib/module/components/marker/index.js.map +1 -0
- package/lib/module/components/message/index.js +334 -0
- package/lib/module/components/message/index.js.map +1 -0
- package/lib/module/components/message-scroller/index.js +451 -0
- package/lib/module/components/message-scroller/index.js.map +1 -0
- package/lib/module/components/popover/index.js +500 -0
- package/lib/module/components/popover/index.js.map +1 -0
- package/lib/module/components/radio-group/index.js +104 -16
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/scroll-fade/index.js +119 -71
- package/lib/module/components/scroll-fade/index.js.map +1 -1
- package/lib/module/components/select/index.js +35 -6
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/separator/index.js +91 -0
- package/lib/module/components/separator/index.js.map +1 -0
- package/lib/module/components/shimmer/index.js +111 -64
- package/lib/module/components/shimmer/index.js.map +1 -1
- package/lib/module/components/surface/index.js +24 -4
- package/lib/module/components/surface/index.js.map +1 -1
- 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 +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-breakpoint.js +2 -2
- package/lib/module/hooks/use-copy-to-clipboard.js +0 -2
- package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
- package/lib/module/icons/index.js +73 -4
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +13 -1
- 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/primitives/scrim.js +72 -0
- package/lib/module/primitives/scrim.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/attachment/index.d.ts +93 -0
- package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +27 -2
- 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/dialog/index.d.ts +7 -1
- package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
- package/lib/typescript/src/components/empty-state/index.d.ts +156 -7
- 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/line-chart/index.d.ts +247 -0
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/marker/index.d.ts +137 -0
- package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
- package/lib/typescript/src/components/message/index.d.ts +177 -0
- package/lib/typescript/src/components/message/index.d.ts.map +1 -0
- package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
- package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
- package/lib/typescript/src/components/popover/index.d.ts +130 -0
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
- package/lib/typescript/src/components/radio-group/index.d.ts +32 -0
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/scroll-fade/index.d.ts +18 -9
- package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -1
- 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/separator/index.d.ts +47 -0
- package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
- package/lib/typescript/src/components/shimmer/index.d.ts +38 -20
- package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
- package/lib/typescript/src/components/surface/index.d.ts +51 -2
- package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
- 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 +1 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-breakpoint.d.ts +2 -2
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +0 -2
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -1
- 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/icons/index.d.ts +10 -4
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +11 -1
- 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/primitives/scrim.d.ts +21 -0
- package/lib/typescript/src/primitives/scrim.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 +16 -3
- package/src/components/accordion/index.tsx +7 -5
- package/src/components/alert/index.tsx +3 -6
- package/src/components/attachment/index.tsx +279 -0
- package/src/components/bottom-sheet/index.tsx +102 -2
- package/src/components/button/index.tsx +52 -0
- package/src/components/dialog/index.tsx +10 -1
- package/src/components/empty-state/index.tsx +64 -19
- 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/line-chart/index.tsx +1224 -0
- package/src/components/marker/index.tsx +194 -0
- package/src/components/message/index.tsx +336 -0
- package/src/components/message-scroller/index.tsx +574 -0
- package/src/components/popover/index.tsx +599 -0
- package/src/components/radio-group/index.tsx +108 -17
- package/src/components/scroll-fade/index.tsx +163 -51
- package/src/components/select/index.tsx +54 -9
- package/src/components/separator/index.tsx +97 -0
- package/src/components/shimmer/index.tsx +151 -58
- package/src/components/surface/index.tsx +32 -5
- 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 +5 -0
- package/src/hooks/use-breakpoint.ts +2 -2
- package/src/hooks/use-copy-to-clipboard.ts +0 -2
- package/src/hooks/use-keyboard-avoidance.ts +140 -0
- package/src/icons/index.tsx +52 -4
- package/src/index.ts +92 -0
- package/src/native/index.ts +79 -0
- package/src/primitives/keyboard-avoider.tsx +49 -0
- package/src/primitives/scrim.tsx +95 -0
- package/src/providers/panel-ui-provider.tsx +30 -9
- package/src/theme/use-theme.ts +1 -1
- 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
|
-
(
|
|
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={
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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?:
|
|
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
|
|
68
|
-
const
|
|
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 =
|
|
102
|
+
const horizontal =
|
|
103
|
+
orientation !== undefined
|
|
104
|
+
? orientation === 'horizontal'
|
|
105
|
+
: isValidElement(child) && !!child.props.horizontal;
|
|
76
106
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
?
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
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 [
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|