panelui-native 0.9.0 → 0.13.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 +5 -3
- package/lib/module/components/bottom-sheet/index.js +38 -2
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +19 -1
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/frame/index.js +128 -15
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/input/index.js +107 -15
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/popover/index.js +22 -11
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/progress/index.js +52 -2
- package/lib/module/components/progress/index.js.map +1 -1
- package/lib/module/components/radio-group/index.js +34 -7
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/section-rail/index.js +355 -0
- package/lib/module/components/section-rail/index.js.map +1 -0
- package/lib/module/components/select/index.js +245 -56
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/slider/index.js +355 -0
- package/lib/module/components/slider/index.js.map +1 -0
- package/lib/module/components/switch/index.js +10 -1
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +186 -16
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/toggle-button/index.js +297 -0
- package/lib/module/components/toggle-button/index.js.map +1 -0
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +57 -8
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/hooks/use-scroll-sections.js +89 -0
- package/lib/module/hooks/use-scroll-sections.js.map +1 -0
- package/lib/module/index.js +4 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/primitives/keyboard-avoider.js +2 -0
- package/lib/module/primitives/keyboard-avoider.js.map +1 -1
- package/lib/module/utils/haptics.js +57 -0
- package/lib/module/utils/haptics.js.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +9 -4
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/frame/index.d.ts +47 -7
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/index.d.ts +113 -2
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/popover/index.d.ts +7 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/progress/index.d.ts +33 -0
- package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radio-group/index.d.ts +7 -0
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/section-rail/index.d.ts +115 -0
- package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +46 -7
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts +203 -0
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -0
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +46 -4
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toggle-button/index.d.ts +244 -0
- package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -0
- 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-keyboard-avoidance.d.ts +10 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts +31 -0
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +6 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +16 -1
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts +8 -1
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
- package/lib/typescript/src/utils/haptics.d.ts +12 -0
- package/lib/typescript/src/utils/haptics.d.ts.map +1 -0
- package/package.json +5 -1
- package/src/components/bottom-sheet/index.tsx +38 -2
- package/src/components/button/index.tsx +26 -5
- package/src/components/chip/index.tsx +333 -0
- package/src/components/frame/index.tsx +175 -29
- package/src/components/input/index.tsx +122 -16
- package/src/components/popover/index.tsx +49 -24
- package/src/components/progress/index.tsx +81 -1
- package/src/components/radio-group/index.tsx +55 -8
- package/src/components/section-rail/index.tsx +459 -0
- package/src/components/select/index.tsx +265 -52
- package/src/components/slider/index.tsx +371 -0
- package/src/components/switch/index.tsx +8 -1
- package/src/components/tabs/index.tsx +208 -28
- package/src/components/toggle-button/index.tsx +396 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-keyboard-avoidance.ts +73 -7
- package/src/hooks/use-scroll-sections.ts +127 -0
- package/src/index.ts +45 -6
- package/src/native/index.ts +13 -1
- package/src/primitives/keyboard-avoider.tsx +9 -1
- package/src/utils/haptics.ts +63 -0
- package/theme.css +6 -2
- package/lib/module/components/inline-select/index.js +0 -284
- package/lib/module/components/inline-select/index.js.map +0 -1
- package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
- package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
- package/src/components/inline-select/index.tsx +0 -332
|
@@ -1,44 +1,93 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Input — a text field with a label, a description and an error line.
|
|
3
|
+
*
|
|
4
|
+
* The field's focus state is animated rather than switched. A border that
|
|
5
|
+
* snaps between two colours reads as a redraw; one that crosses over in
|
|
6
|
+
* 150ms reads as the field responding to you. The interpolation runs on the
|
|
7
|
+
* UI thread, so it costs nothing and keeps up with a fast tab through a form.
|
|
8
|
+
*
|
|
9
|
+
* Two backgrounds, because a field's job changes with what surrounds it:
|
|
10
|
+
* `outline` sits on the page and draws its own edge; `filled` sits inside a
|
|
11
|
+
* card or a sheet, where a second border next to the container's own reads as
|
|
12
|
+
* a seam.
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <Input label="Email" placeholder="you@example.com" />
|
|
16
|
+
* <Input variant="filled" size="lg" label="Name" isRequired />
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* For a field with an icon or a button attached, reach for `InputGroup` — it
|
|
20
|
+
* measures its decorators and pads this field around them.
|
|
21
|
+
*/
|
|
22
|
+
import { forwardRef, useCallback, useEffect, useState } from 'react';
|
|
2
23
|
import { TextInput, View, type TextInputProps } from 'react-native';
|
|
3
|
-
import {
|
|
24
|
+
import Animated, {
|
|
25
|
+
interpolateColor,
|
|
26
|
+
useAnimatedStyle,
|
|
27
|
+
useSharedValue,
|
|
28
|
+
withTiming,
|
|
29
|
+
} from 'react-native-reanimated';
|
|
30
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
4
31
|
import { useCSSVariable } from 'uniwind';
|
|
5
32
|
import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
|
|
6
33
|
import { Text } from '../../primitives/text';
|
|
34
|
+
import { Label } from '../label';
|
|
35
|
+
|
|
36
|
+
/** Long enough to read as a transition, short enough not to lag a fast tab. */
|
|
37
|
+
const FOCUS_DURATION = 150;
|
|
7
38
|
|
|
8
39
|
const inputVariants = tv({
|
|
9
40
|
slots: {
|
|
10
41
|
container: 'w-full gap-1.5',
|
|
11
|
-
field:
|
|
12
|
-
'h-11 w-full rounded-lg border border-input bg-background px-3 text-base text-foreground',
|
|
13
|
-
label: 'text-sm font-medium text-foreground',
|
|
42
|
+
field: 'w-full rounded-lg border text-foreground',
|
|
14
43
|
description: 'text-sm text-muted-foreground',
|
|
15
44
|
error: 'text-sm text-destructive',
|
|
16
45
|
},
|
|
17
46
|
variants: {
|
|
18
|
-
|
|
19
|
-
|
|
47
|
+
variant: {
|
|
48
|
+
// The border colour is animated, so it is deliberately not set here —
|
|
49
|
+
// only the background and the resting border width belong to the class.
|
|
50
|
+
outline: { field: 'bg-background' },
|
|
51
|
+
filled: { field: 'bg-muted' },
|
|
52
|
+
},
|
|
53
|
+
size: {
|
|
54
|
+
sm: { field: 'h-10 px-3 text-sm' },
|
|
55
|
+
md: { field: 'h-12 px-3.5 text-base' },
|
|
56
|
+
lg: { field: 'h-14 px-4 text-base' },
|
|
20
57
|
},
|
|
21
|
-
|
|
22
|
-
|
|
58
|
+
multiline: {
|
|
59
|
+
// A multiline field grows, so a fixed height would crop it. Text starts
|
|
60
|
+
// at the top rather than floating in the middle of an empty box.
|
|
61
|
+
true: { field: 'h-auto min-h-24 py-3' },
|
|
23
62
|
},
|
|
24
63
|
disabled: {
|
|
25
64
|
true: { field: 'opacity-[0.64]' },
|
|
26
65
|
},
|
|
27
66
|
},
|
|
67
|
+
defaultVariants: {
|
|
68
|
+
variant: 'outline',
|
|
69
|
+
size: 'md',
|
|
70
|
+
},
|
|
28
71
|
});
|
|
29
72
|
|
|
30
|
-
|
|
73
|
+
type InputVariantProps = VariantProps<typeof inputVariants>;
|
|
74
|
+
|
|
75
|
+
export interface InputProps
|
|
76
|
+
extends TextInputProps,
|
|
77
|
+
Omit<InputVariantProps, 'disabled' | 'multiline'> {
|
|
31
78
|
className?: string;
|
|
32
79
|
containerClassName?: string;
|
|
33
80
|
label?: string;
|
|
34
81
|
description?: string;
|
|
35
82
|
/** Error message. When set, the field renders in its invalid state. */
|
|
36
83
|
errorMessage?: string;
|
|
84
|
+
/** Marks the field required — an asterisk on the label, and the a11y state. */
|
|
85
|
+
isRequired?: boolean;
|
|
37
86
|
disabled?: boolean;
|
|
38
87
|
/**
|
|
39
88
|
* Lift the field above the software keyboard when it would otherwise be
|
|
40
89
|
* covered. Moves by exactly the overlap, and not at all when the field is
|
|
41
|
-
* already clear.
|
|
90
|
+
* already clear — or when the keyboard belongs to a different field.
|
|
42
91
|
*
|
|
43
92
|
* Install `react-native-keyboard-controller` for this to behave on Android.
|
|
44
93
|
*
|
|
@@ -50,6 +99,8 @@ export interface InputProps extends TextInputProps {
|
|
|
50
99
|
keyboardOffset?: number;
|
|
51
100
|
}
|
|
52
101
|
|
|
102
|
+
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
|
|
103
|
+
|
|
53
104
|
export const Input = forwardRef<TextInput, InputProps>(
|
|
54
105
|
(
|
|
55
106
|
{
|
|
@@ -58,23 +109,61 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
58
109
|
label,
|
|
59
110
|
description,
|
|
60
111
|
errorMessage,
|
|
112
|
+
isRequired,
|
|
61
113
|
disabled,
|
|
114
|
+
variant,
|
|
115
|
+
size,
|
|
62
116
|
avoidKeyboard = false,
|
|
63
117
|
keyboardOffset = 16,
|
|
64
118
|
onFocus,
|
|
65
119
|
onBlur,
|
|
120
|
+
style,
|
|
66
121
|
...props
|
|
67
122
|
},
|
|
68
123
|
ref
|
|
69
124
|
) => {
|
|
70
125
|
const [focused, setFocused] = useState(false);
|
|
126
|
+
const invalid = !!errorMessage;
|
|
127
|
+
|
|
71
128
|
const placeholderColor = useCSSVariable('--color-muted-foreground');
|
|
129
|
+
const restColor = useCSSVariable('--color-input');
|
|
130
|
+
const focusColor = useCSSVariable('--color-ring');
|
|
131
|
+
const errorColor = useCSSVariable('--color-destructive');
|
|
132
|
+
|
|
72
133
|
const slots = inputVariants({
|
|
73
|
-
|
|
74
|
-
|
|
134
|
+
variant,
|
|
135
|
+
size,
|
|
136
|
+
multiline: !!props.multiline,
|
|
75
137
|
disabled: !!disabled,
|
|
76
138
|
});
|
|
77
139
|
|
|
140
|
+
/*
|
|
141
|
+
* Border colour is driven by one 0..1 value rather than by a class per
|
|
142
|
+
* state. Uniwind can only swap a class wholesale, which is the snap this
|
|
143
|
+
* is here to avoid, and a shared value crosses between the two colours on
|
|
144
|
+
* the UI thread without a re-render.
|
|
145
|
+
*/
|
|
146
|
+
const focus = useSharedValue(0);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
focus.value = withTiming(focused ? 1 : 0, { duration: FOCUS_DURATION });
|
|
149
|
+
}, [focused, focus]);
|
|
150
|
+
|
|
151
|
+
const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';
|
|
152
|
+
const active = invalid
|
|
153
|
+
? typeof errorColor === 'string'
|
|
154
|
+
? errorColor
|
|
155
|
+
: '#ef4444'
|
|
156
|
+
: typeof focusColor === 'string'
|
|
157
|
+
? focusColor
|
|
158
|
+
: '#a3a3a3';
|
|
159
|
+
// An invalid field is tinted even at rest — the error is a fact about the
|
|
160
|
+
// value, not about whether the field happens to be focused.
|
|
161
|
+
const idle = invalid ? active : resting;
|
|
162
|
+
|
|
163
|
+
const borderStyle = useAnimatedStyle(() => ({
|
|
164
|
+
borderColor: interpolateColor(focus.value, [0, 1], [idle, active]),
|
|
165
|
+
}));
|
|
166
|
+
|
|
78
167
|
const handleFocus = useCallback<NonNullable<TextInputProps['onFocus']>>(
|
|
79
168
|
(event) => {
|
|
80
169
|
setFocused(true);
|
|
@@ -93,15 +182,24 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
93
182
|
|
|
94
183
|
const body = (
|
|
95
184
|
<>
|
|
96
|
-
{label ?
|
|
97
|
-
|
|
185
|
+
{label ? (
|
|
186
|
+
<Label isRequired={isRequired} isInvalid={invalid} isDisabled={!!disabled}>
|
|
187
|
+
{label}
|
|
188
|
+
</Label>
|
|
189
|
+
) : null}
|
|
190
|
+
<AnimatedTextInput
|
|
98
191
|
ref={ref}
|
|
99
192
|
editable={!disabled}
|
|
100
193
|
onFocus={handleFocus}
|
|
101
194
|
onBlur={handleBlur}
|
|
102
195
|
accessibilityLabel={label}
|
|
103
196
|
accessibilityState={{ disabled: !!disabled }}
|
|
197
|
+
aria-required={isRequired}
|
|
198
|
+
aria-invalid={invalid}
|
|
104
199
|
className={slots.field({ className })}
|
|
200
|
+
// Caller styles come last so InputGroup can pad the field around its
|
|
201
|
+
// decorators, but never last enough to drop the animated border.
|
|
202
|
+
style={[borderStyle, style]}
|
|
105
203
|
placeholderTextColor={
|
|
106
204
|
typeof placeholderColor === 'string' ? placeholderColor : undefined
|
|
107
205
|
}
|
|
@@ -127,7 +225,15 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
127
225
|
*/
|
|
128
226
|
if (avoidKeyboard) {
|
|
129
227
|
return (
|
|
130
|
-
<KeyboardAvoider
|
|
228
|
+
<KeyboardAvoider
|
|
229
|
+
// Only while *this* field is the one being typed into. Without it
|
|
230
|
+
// every avoiding field on the screen lifts the moment any field
|
|
231
|
+
// anywhere is tapped, and since they all aim at the same gap above
|
|
232
|
+
// the keyboard, they arrive stacked on top of one another.
|
|
233
|
+
active={focused}
|
|
234
|
+
offset={keyboardOffset}
|
|
235
|
+
className={containerClasses}
|
|
236
|
+
>
|
|
131
237
|
{body}
|
|
132
238
|
</KeyboardAvoider>
|
|
133
239
|
);
|
|
@@ -221,6 +221,12 @@ export interface PopoverContentProps extends ViewProps {
|
|
|
221
221
|
* `full` spans the safe area, and a number is that many pixels.
|
|
222
222
|
*/
|
|
223
223
|
width?: number | 'trigger' | 'full' | 'content-fit';
|
|
224
|
+
/**
|
|
225
|
+
* Floor for the panel's width, in pixels. Worth setting with
|
|
226
|
+
* `width="trigger"`, where a narrow trigger would otherwise squeeze the
|
|
227
|
+
* content into a column.
|
|
228
|
+
*/
|
|
229
|
+
minWidth?: number;
|
|
224
230
|
/** Tap outside the panel closes it. Default true. */
|
|
225
231
|
dismissible?: boolean;
|
|
226
232
|
/**
|
|
@@ -239,9 +245,12 @@ function PopoverContent({
|
|
|
239
245
|
offset = DEFAULT_OFFSET,
|
|
240
246
|
alignOffset = 0,
|
|
241
247
|
width = 'content-fit',
|
|
248
|
+
minWidth,
|
|
242
249
|
dismissible = true,
|
|
243
250
|
blur = false,
|
|
244
251
|
children,
|
|
252
|
+
onLayout: onLayoutProp,
|
|
253
|
+
style,
|
|
245
254
|
...props
|
|
246
255
|
}: PopoverContentProps) {
|
|
247
256
|
const context = usePopover('Popover.Content');
|
|
@@ -259,7 +268,7 @@ function PopoverContent({
|
|
|
259
268
|
? current
|
|
260
269
|
: { width: w, height: h }
|
|
261
270
|
);
|
|
262
|
-
|
|
271
|
+
onLayoutProp?.(event);
|
|
263
272
|
};
|
|
264
273
|
|
|
265
274
|
const bounds = {
|
|
@@ -269,15 +278,23 @@ function PopoverContent({
|
|
|
269
278
|
bottom: screenHeight - insets.bottom - SCREEN_MARGIN,
|
|
270
279
|
};
|
|
271
280
|
|
|
272
|
-
const
|
|
281
|
+
const available = bounds.right - bounds.left;
|
|
282
|
+
const requestedWidth =
|
|
273
283
|
width === 'content-fit'
|
|
274
284
|
? undefined
|
|
275
285
|
: width === 'trigger'
|
|
276
286
|
? trigger?.width
|
|
277
287
|
: width === 'full'
|
|
278
|
-
?
|
|
288
|
+
? available
|
|
279
289
|
: width;
|
|
280
290
|
|
|
291
|
+
// The floor never wins past the space there actually is — a panel wider than
|
|
292
|
+
// the screen is worse than a cramped one.
|
|
293
|
+
const resolvedWidth =
|
|
294
|
+
minWidth !== undefined && requestedWidth !== undefined
|
|
295
|
+
? Math.min(Math.max(requestedWidth, minWidth), available)
|
|
296
|
+
: requestedWidth;
|
|
297
|
+
|
|
281
298
|
const position =
|
|
282
299
|
trigger && size
|
|
283
300
|
? place({ trigger, size, placement, align, offset, alignOffset, bounds })
|
|
@@ -362,31 +379,39 @@ function PopoverContent({
|
|
|
362
379
|
className="absolute inset-0"
|
|
363
380
|
onPress={dismissible ? () => setOpen(false) : undefined}
|
|
364
381
|
/>
|
|
382
|
+
{/*
|
|
383
|
+
Two views, not one, and the reason is a Reanimated rule: a layout
|
|
384
|
+
animation and an animated style may not drive the same property on
|
|
385
|
+
the same component, or the layout animation silently wins. The exit
|
|
386
|
+
fade and `panelStyle`'s opacity both want it — so the outer view
|
|
387
|
+
owns the position and the exit, and the inner one owns the entrance
|
|
388
|
+
and the panel's own surface.
|
|
389
|
+
*/}
|
|
365
390
|
<Animated.View
|
|
366
391
|
exiting={FadeOut.duration(120)}
|
|
367
392
|
onLayout={onLayout}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
width: resolvedWidth,
|
|
379
|
-
},
|
|
380
|
-
panelStyle,
|
|
381
|
-
props.style,
|
|
382
|
-
]}
|
|
383
|
-
className={cn(
|
|
384
|
-
'gap-1.5 rounded-2xl border border-border bg-popover p-4 shadow-lg',
|
|
385
|
-
className
|
|
386
|
-
)}
|
|
387
|
-
{...props}
|
|
393
|
+
style={{
|
|
394
|
+
position: 'absolute',
|
|
395
|
+
// Until it has measured itself the panel has no honest position,
|
|
396
|
+
// so it is laid out off-screen rather than at the origin — an
|
|
397
|
+
// invisible view at 0,0 still catches taps.
|
|
398
|
+
top: position?.top ?? -9999,
|
|
399
|
+
left: position?.left ?? -9999,
|
|
400
|
+
maxWidth: bounds.right - bounds.left,
|
|
401
|
+
width: resolvedWidth,
|
|
402
|
+
}}
|
|
388
403
|
>
|
|
389
|
-
|
|
404
|
+
<Animated.View
|
|
405
|
+
accessibilityViewIsModal
|
|
406
|
+
style={[panelStyle, style]}
|
|
407
|
+
className={cn(
|
|
408
|
+
'gap-1.5 rounded-2xl border border-border bg-popover p-4 shadow-lg',
|
|
409
|
+
className
|
|
410
|
+
)}
|
|
411
|
+
{...props}
|
|
412
|
+
>
|
|
413
|
+
{children}
|
|
414
|
+
</Animated.View>
|
|
390
415
|
</Animated.View>
|
|
391
416
|
</View>
|
|
392
417
|
</PopoverContext.Provider>
|
|
@@ -11,6 +11,8 @@ import Animated, {
|
|
|
11
11
|
withTiming,
|
|
12
12
|
} from 'react-native-reanimated';
|
|
13
13
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
14
|
+
import { Text } from '../../primitives/text';
|
|
15
|
+
import { cn } from '../../utils/cn';
|
|
14
16
|
|
|
15
17
|
const SPRING = { damping: 20, stiffness: 180, mass: 0.6 } as const;
|
|
16
18
|
/** Fraction of the track covered by the sliding bar in indeterminate mode. */
|
|
@@ -18,6 +20,8 @@ const INDETERMINATE_WIDTH = 0.4;
|
|
|
18
20
|
|
|
19
21
|
const progressVariants = tv({
|
|
20
22
|
slots: {
|
|
23
|
+
root: 'w-full gap-2',
|
|
24
|
+
header: 'flex-row items-center justify-between',
|
|
21
25
|
track: 'w-full overflow-hidden rounded-full',
|
|
22
26
|
indicator: 'h-full rounded-full',
|
|
23
27
|
},
|
|
@@ -53,6 +57,30 @@ export interface ProgressProps
|
|
|
53
57
|
indeterminate?: boolean;
|
|
54
58
|
/** Extra classes for the moving indicator. */
|
|
55
59
|
indicatorClassName?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Caption drawn above the track, on the left. Supplying it (or
|
|
62
|
+
* `showValueLabel`) wraps the bar in a header row; the track alone renders
|
|
63
|
+
* otherwise.
|
|
64
|
+
*/
|
|
65
|
+
label?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Draw the percentage above the track, on the right. Hidden while
|
|
68
|
+
* `indeterminate` — there is nothing meaningful to show.
|
|
69
|
+
*/
|
|
70
|
+
showValueLabel?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Text for the value label. Overrides the formatted percentage — use it for
|
|
73
|
+
* a byte count, a step tally, anything that is not a bare percent.
|
|
74
|
+
*/
|
|
75
|
+
valueLabel?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Format the value label as a fraction of the whole with `Intl.NumberFormat`
|
|
78
|
+
* — e.g. `{ style: 'currency', currency: 'USD' }`. Falls back to a rounded
|
|
79
|
+
* percent when omitted.
|
|
80
|
+
*/
|
|
81
|
+
formatOptions?: Intl.NumberFormatOptions;
|
|
82
|
+
/** Extra classes for the label + value-label row. */
|
|
83
|
+
headerClassName?: string;
|
|
56
84
|
}
|
|
57
85
|
|
|
58
86
|
function clamp(value: number) {
|
|
@@ -62,18 +90,43 @@ function clamp(value: number) {
|
|
|
62
90
|
return value;
|
|
63
91
|
}
|
|
64
92
|
|
|
93
|
+
/** The right-hand caption: an explicit override, an Intl fraction, or a percent. */
|
|
94
|
+
function formatValue(
|
|
95
|
+
value: number,
|
|
96
|
+
valueLabel?: string,
|
|
97
|
+
formatOptions?: Intl.NumberFormatOptions
|
|
98
|
+
) {
|
|
99
|
+
if (valueLabel != null) return valueLabel;
|
|
100
|
+
if (formatOptions) {
|
|
101
|
+
try {
|
|
102
|
+
return new Intl.NumberFormat(undefined, formatOptions).format(value / 100);
|
|
103
|
+
} catch {
|
|
104
|
+
// Some engines ship a partial Intl; fall through to the plain percent.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return `${Math.round(value)}%`;
|
|
108
|
+
}
|
|
109
|
+
|
|
65
110
|
/**
|
|
66
111
|
* Determinate or indeterminate progress bar. The fill width (determinate) and
|
|
67
112
|
* the sliding bar (indeterminate) are both driven on the UI thread, so updates
|
|
68
113
|
* never re-render past the value change itself.
|
|
114
|
+
*
|
|
115
|
+
* Pass `label` or `showValueLabel` to caption the bar with a header row; the
|
|
116
|
+
* bare track renders when neither is set, so existing call sites are untouched.
|
|
69
117
|
*/
|
|
70
118
|
export const Progress = forwardRef<View, ProgressProps>(
|
|
71
119
|
(
|
|
72
120
|
{
|
|
73
121
|
className,
|
|
74
122
|
indicatorClassName,
|
|
123
|
+
headerClassName,
|
|
75
124
|
value = 0,
|
|
76
125
|
indeterminate = false,
|
|
126
|
+
label,
|
|
127
|
+
showValueLabel = false,
|
|
128
|
+
valueLabel,
|
|
129
|
+
formatOptions,
|
|
77
130
|
color,
|
|
78
131
|
size,
|
|
79
132
|
...props
|
|
@@ -124,10 +177,15 @@ export const Progress = forwardRef<View, ProgressProps>(
|
|
|
124
177
|
};
|
|
125
178
|
});
|
|
126
179
|
|
|
127
|
-
|
|
180
|
+
// The value label is meaningless while looping, so it is dropped there.
|
|
181
|
+
const showValue = showValueLabel && !indeterminate;
|
|
182
|
+
const hasHeader = label != null || showValue;
|
|
183
|
+
|
|
184
|
+
const track = (
|
|
128
185
|
<View
|
|
129
186
|
ref={ref}
|
|
130
187
|
accessibilityRole="progressbar"
|
|
188
|
+
accessibilityLabel={label}
|
|
131
189
|
accessibilityValue={
|
|
132
190
|
indeterminate ? undefined : { min: 0, max: 100, now: clamp(value) }
|
|
133
191
|
}
|
|
@@ -141,6 +199,28 @@ export const Progress = forwardRef<View, ProgressProps>(
|
|
|
141
199
|
/>
|
|
142
200
|
</View>
|
|
143
201
|
);
|
|
202
|
+
|
|
203
|
+
if (!hasHeader) return track;
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<View className={slots.root()}>
|
|
207
|
+
<View className={slots.header({ className: headerClassName })}>
|
|
208
|
+
{label != null ? (
|
|
209
|
+
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
210
|
+
{label}
|
|
211
|
+
</Text>
|
|
212
|
+
) : (
|
|
213
|
+
<View />
|
|
214
|
+
)}
|
|
215
|
+
{showValue ? (
|
|
216
|
+
<Text size="sm" muted className={cn(label == null && 'ml-auto')}>
|
|
217
|
+
{formatValue(clamp(value), valueLabel, formatOptions)}
|
|
218
|
+
</Text>
|
|
219
|
+
) : null}
|
|
220
|
+
</View>
|
|
221
|
+
{track}
|
|
222
|
+
</View>
|
|
223
|
+
);
|
|
144
224
|
}
|
|
145
225
|
);
|
|
146
226
|
|
|
@@ -38,10 +38,14 @@ import { cn } from '../../utils/cn';
|
|
|
38
38
|
import { Text } from '../../primitives/text';
|
|
39
39
|
|
|
40
40
|
type RadioVariant = 'dot' | 'card';
|
|
41
|
+
type RadioOrientation = 'vertical' | 'horizontal';
|
|
41
42
|
|
|
42
43
|
const itemVariants = tv({
|
|
43
44
|
slots: {
|
|
44
|
-
|
|
45
|
+
// `self-start` matters: the group is a column, so without it Yoga stretches
|
|
46
|
+
// the row to the full width and the dead space to the right of the label
|
|
47
|
+
// becomes part of the target. Tapping nothing would select the option.
|
|
48
|
+
row: 'flex-row items-center gap-2.5 self-start',
|
|
45
49
|
indicator:
|
|
46
50
|
'h-5 w-5 items-center justify-center rounded-full border border-input bg-background',
|
|
47
51
|
label: 'text-sm text-foreground',
|
|
@@ -52,8 +56,9 @@ const itemVariants = tv({
|
|
|
52
56
|
dot: {},
|
|
53
57
|
card: {
|
|
54
58
|
// 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
|
-
|
|
59
|
+
// as a confirmation of the row, not the affordance for it. Here the
|
|
60
|
+
// full width is deliberate, so it takes `self-stretch` back.
|
|
61
|
+
row: 'w-full self-stretch items-start justify-between gap-3 rounded-xl border border-border bg-card p-4',
|
|
57
62
|
label: 'text-base font-medium',
|
|
58
63
|
},
|
|
59
64
|
},
|
|
@@ -63,9 +68,19 @@ const itemVariants = tv({
|
|
|
63
68
|
disabled: {
|
|
64
69
|
true: { row: 'opacity-50' },
|
|
65
70
|
},
|
|
71
|
+
/**
|
|
72
|
+
* A card in a row shares the width rather than filling it, so two or three
|
|
73
|
+
* options sit side by side instead of each taking the whole line.
|
|
74
|
+
*/
|
|
75
|
+
horizontal: {
|
|
76
|
+
true: {},
|
|
77
|
+
},
|
|
66
78
|
},
|
|
67
79
|
compoundVariants: [
|
|
68
80
|
{ variant: 'card', selected: true, class: { row: 'border-primary bg-accent' } },
|
|
81
|
+
// Share the row instead of filling it, keeping the disc on the trailing
|
|
82
|
+
// edge of each narrower card.
|
|
83
|
+
{ variant: 'card', horizontal: true, class: { row: 'w-auto flex-1' } },
|
|
69
84
|
],
|
|
70
85
|
defaultVariants: {
|
|
71
86
|
variant: 'dot',
|
|
@@ -77,6 +92,7 @@ interface RadioGroupContextValue {
|
|
|
77
92
|
onValueChange: (value: string) => void;
|
|
78
93
|
disabled?: boolean;
|
|
79
94
|
variant: RadioVariant;
|
|
95
|
+
orientation: RadioOrientation;
|
|
80
96
|
}
|
|
81
97
|
|
|
82
98
|
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null);
|
|
@@ -92,14 +108,32 @@ export interface RadioGroupProps extends ViewProps {
|
|
|
92
108
|
* settings choice where each option carries a description.
|
|
93
109
|
*/
|
|
94
110
|
variant?: RadioVariant;
|
|
111
|
+
/**
|
|
112
|
+
* `horizontal` lays the options out along a row that wraps — for two or
|
|
113
|
+
* three short choices, where a stacked list wastes the width and reads as
|
|
114
|
+
* longer than it is.
|
|
115
|
+
*/
|
|
116
|
+
orientation?: RadioOrientation;
|
|
95
117
|
children: ReactNode;
|
|
96
118
|
}
|
|
97
119
|
|
|
98
120
|
const RadioGroupRoot = forwardRef<View, RadioGroupProps>(
|
|
99
|
-
(
|
|
121
|
+
(
|
|
122
|
+
{
|
|
123
|
+
className,
|
|
124
|
+
value,
|
|
125
|
+
onValueChange,
|
|
126
|
+
disabled,
|
|
127
|
+
variant = 'dot',
|
|
128
|
+
orientation = 'vertical',
|
|
129
|
+
children,
|
|
130
|
+
...props
|
|
131
|
+
},
|
|
132
|
+
ref
|
|
133
|
+
) => {
|
|
100
134
|
const context = useMemo(
|
|
101
|
-
() => ({ value, onValueChange, disabled, variant }),
|
|
102
|
-
[value, onValueChange, disabled, variant]
|
|
135
|
+
() => ({ value, onValueChange, disabled, variant, orientation }),
|
|
136
|
+
[value, onValueChange, disabled, variant, orientation]
|
|
103
137
|
);
|
|
104
138
|
|
|
105
139
|
return (
|
|
@@ -107,7 +141,15 @@ const RadioGroupRoot = forwardRef<View, RadioGroupProps>(
|
|
|
107
141
|
<View
|
|
108
142
|
ref={ref}
|
|
109
143
|
accessibilityRole="radiogroup"
|
|
110
|
-
className={cn(
|
|
144
|
+
className={cn(
|
|
145
|
+
orientation === 'horizontal'
|
|
146
|
+
? // Wrapping, not scrolling: a choice that runs off the edge is
|
|
147
|
+
// a choice nobody knows is there. The cross-axis gap is
|
|
148
|
+
// smaller because wrapped rows already read as separate.
|
|
149
|
+
'flex-row flex-wrap items-stretch gap-x-5 gap-y-3'
|
|
150
|
+
: 'gap-3',
|
|
151
|
+
className
|
|
152
|
+
)}
|
|
111
153
|
{...props}
|
|
112
154
|
>
|
|
113
155
|
{children}
|
|
@@ -156,7 +198,12 @@ const RadioGroupItem = forwardRef<View, RadioGroupItemProps>(
|
|
|
156
198
|
transform: [{ scale: progress.value }],
|
|
157
199
|
}));
|
|
158
200
|
|
|
159
|
-
const slots = itemVariants({
|
|
201
|
+
const slots = itemVariants({
|
|
202
|
+
variant,
|
|
203
|
+
selected,
|
|
204
|
+
disabled: !!disabled,
|
|
205
|
+
horizontal: context.orientation === 'horizontal',
|
|
206
|
+
});
|
|
160
207
|
|
|
161
208
|
const indicator = hideIndicator ? null : (
|
|
162
209
|
<View className={slots.indicator()}>
|