panelui-native 0.9.0 → 0.12.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/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 +374 -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 +37 -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
|
@@ -15,28 +15,67 @@
|
|
|
15
15
|
* </Frame.Header>
|
|
16
16
|
* <Frame.Panel>
|
|
17
17
|
* <Frame.Row>…</Frame.Row>
|
|
18
|
-
* <Frame.Row
|
|
18
|
+
* <Frame.Row>…</Frame.Row>
|
|
19
19
|
* </Frame.Panel>
|
|
20
20
|
* <Frame.Footer>Updated 2 minutes ago</Frame.Footer>
|
|
21
21
|
* </Frame>
|
|
22
22
|
* ```
|
|
23
|
+
*
|
|
24
|
+
* The panel draws the hairlines between its own rows. React Native has no
|
|
25
|
+
* `:first-child`, so the alternative is every caller writing
|
|
26
|
+
* `divided={index > 0}` on every row and getting it wrong once.
|
|
23
27
|
*/
|
|
24
|
-
import { forwardRef, type ReactNode } from 'react';
|
|
25
|
-
import {
|
|
28
|
+
import { Children, cloneElement, forwardRef, isValidElement, type ReactNode } from 'react';
|
|
29
|
+
import {
|
|
30
|
+
Pressable,
|
|
31
|
+
View,
|
|
32
|
+
type PressableProps,
|
|
33
|
+
type Text as RNText,
|
|
34
|
+
type ViewProps,
|
|
35
|
+
} from 'react-native';
|
|
36
|
+
import { tv } from 'tailwind-variants';
|
|
37
|
+
import { ChevronRightIcon } from '../../icons';
|
|
26
38
|
import { Text, type TextProps } from '../../primitives/text';
|
|
27
39
|
import { cn } from '../../utils/cn';
|
|
28
40
|
|
|
41
|
+
const frameVariants = tv({
|
|
42
|
+
slots: {
|
|
43
|
+
root: '',
|
|
44
|
+
panel: 'overflow-hidden rounded-xl border border-border bg-card',
|
|
45
|
+
},
|
|
46
|
+
variants: {
|
|
47
|
+
variant: {
|
|
48
|
+
default: { root: 'rounded-2xl border border-border bg-surface p-1.5' },
|
|
49
|
+
// No shell: the panel is the whole widget. For a Frame nested inside a
|
|
50
|
+
// card that already draws a border, where the shell's own edge sitting
|
|
51
|
+
// just inside it reads as a double line.
|
|
52
|
+
plain: { root: '', panel: 'rounded-2xl' },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
defaultVariants: {
|
|
56
|
+
variant: 'default',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type FrameVariant = 'default' | 'plain';
|
|
61
|
+
|
|
29
62
|
export interface FrameProps extends ViewProps {
|
|
30
63
|
className?: string;
|
|
31
64
|
}
|
|
32
65
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
66
|
+
export interface FrameRootProps extends FrameProps {
|
|
67
|
+
/**
|
|
68
|
+
* `plain` drops the outer shell so the panel is the widget — for a Frame
|
|
69
|
+
* inside a container that already draws its own border.
|
|
70
|
+
*/
|
|
71
|
+
variant?: FrameVariant;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const FrameRoot = forwardRef<View, FrameRootProps>(
|
|
75
|
+
({ className, variant, ...props }, ref) => (
|
|
76
|
+
<View ref={ref} className={frameVariants({ variant }).root({ className })} {...props} />
|
|
77
|
+
)
|
|
78
|
+
);
|
|
40
79
|
FrameRoot.displayName = 'Frame';
|
|
41
80
|
|
|
42
81
|
export interface FrameHeaderProps extends FrameProps {
|
|
@@ -103,41 +142,147 @@ const FrameDescription = forwardRef<RNText, TextProps>(
|
|
|
103
142
|
);
|
|
104
143
|
FrameDescription.displayName = 'Frame.Description';
|
|
105
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Marks the parts that take part in the panel's own divider bookkeeping —
|
|
147
|
+
* a Row draws a line above itself, a Section draws one above its heading.
|
|
148
|
+
* Anything else the panel is given is left alone.
|
|
149
|
+
*/
|
|
150
|
+
interface Dividable {
|
|
151
|
+
divided?: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Draws the hairline above every child but the first. An explicit `divided`
|
|
156
|
+
* on a child wins, so a row can still opt out or force one.
|
|
157
|
+
*/
|
|
158
|
+
function divideChildren(children: ReactNode) {
|
|
159
|
+
let seen = 0;
|
|
160
|
+
return Children.map(children, (child) => {
|
|
161
|
+
if (!isValidElement<Dividable>(child)) return child;
|
|
162
|
+
if (!DIVIDABLE.has(child.type)) return child;
|
|
163
|
+
|
|
164
|
+
const index = seen++;
|
|
165
|
+
if (child.props.divided !== undefined) return child;
|
|
166
|
+
return cloneElement(child, { divided: index > 0 });
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface FramePanelProps extends FrameProps {
|
|
171
|
+
/**
|
|
172
|
+
* Set false to place the hairlines by hand instead — for a panel whose rows
|
|
173
|
+
* are generated somewhere the divider order is not obvious.
|
|
174
|
+
*/
|
|
175
|
+
dividers?: boolean;
|
|
176
|
+
children?: ReactNode;
|
|
177
|
+
}
|
|
178
|
+
|
|
106
179
|
/**
|
|
107
180
|
* The inset card holding the frame's content — the raised surface sitting in
|
|
108
181
|
* from the shell's edges, with its own smaller radius.
|
|
109
182
|
*/
|
|
110
|
-
const FramePanel = forwardRef<View,
|
|
111
|
-
|
|
112
|
-
ref={ref}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)
|
|
183
|
+
const FramePanel = forwardRef<View, FramePanelProps>(
|
|
184
|
+
({ className, dividers = true, children, ...props }, ref) => (
|
|
185
|
+
<View ref={ref} className={frameVariants().panel({ className })} {...props}>
|
|
186
|
+
{dividers ? divideChildren(children) : children}
|
|
187
|
+
</View>
|
|
188
|
+
)
|
|
189
|
+
);
|
|
117
190
|
FramePanel.displayName = 'Frame.Panel';
|
|
118
191
|
|
|
192
|
+
export interface FrameRowProps extends Omit<PressableProps, 'children'>, Dividable {
|
|
193
|
+
className?: string;
|
|
194
|
+
/**
|
|
195
|
+
* Draw a hairline above this row. `Frame.Panel` sets it for you; pass it
|
|
196
|
+
* explicitly to override the panel's decision either way.
|
|
197
|
+
*/
|
|
198
|
+
divided?: boolean;
|
|
199
|
+
/** Trailing chevron marking the row as leading somewhere. */
|
|
200
|
+
chevron?: boolean;
|
|
201
|
+
children?: ReactNode;
|
|
202
|
+
}
|
|
203
|
+
|
|
119
204
|
/**
|
|
120
|
-
* A row inside a Frame.Panel.
|
|
121
|
-
*
|
|
205
|
+
* A row inside a Frame.Panel. Give it an `onPress` and it becomes a real
|
|
206
|
+
* pressable — press feedback, a button role — rather than a View with a
|
|
207
|
+
* handler bolted on.
|
|
122
208
|
*/
|
|
123
|
-
|
|
209
|
+
const FrameRow = forwardRef<View, FrameRowProps>(
|
|
210
|
+
({ className, divided, chevron, children, onPress, ...props }, ref) => {
|
|
211
|
+
const classes = cn(
|
|
212
|
+
'flex-row items-center gap-3 px-4 py-3.5',
|
|
213
|
+
divided && 'border-t border-border',
|
|
214
|
+
onPress && 'active:bg-muted',
|
|
215
|
+
className
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const body = (
|
|
219
|
+
<>
|
|
220
|
+
{children}
|
|
221
|
+
{chevron ? <ChevronRightIcon size={16} /> : null}
|
|
222
|
+
</>
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
if (!onPress) {
|
|
226
|
+
return (
|
|
227
|
+
<View ref={ref} className={classes} {...(props as ViewProps)}>
|
|
228
|
+
{body}
|
|
229
|
+
</View>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<Pressable
|
|
235
|
+
ref={ref}
|
|
236
|
+
accessibilityRole="button"
|
|
237
|
+
onPress={onPress}
|
|
238
|
+
className={classes}
|
|
239
|
+
{...props}
|
|
240
|
+
>
|
|
241
|
+
{body}
|
|
242
|
+
</Pressable>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
FrameRow.displayName = 'Frame.Row';
|
|
247
|
+
|
|
248
|
+
export interface FrameSectionProps extends FrameProps, Dividable {
|
|
249
|
+
/** Heading above the rows. Strings are wrapped for you. */
|
|
250
|
+
title?: ReactNode;
|
|
124
251
|
divided?: boolean;
|
|
252
|
+
children?: ReactNode;
|
|
125
253
|
}
|
|
126
254
|
|
|
127
|
-
|
|
128
|
-
|
|
255
|
+
/**
|
|
256
|
+
* A labelled cluster of rows inside a Panel, for a widget holding more than
|
|
257
|
+
* one group. It divides its own rows the way the panel does, so the two nest
|
|
258
|
+
* without either having to know about the other.
|
|
259
|
+
*/
|
|
260
|
+
const FrameSection = forwardRef<View, FrameSectionProps>(
|
|
261
|
+
({ className, title, divided, children, ...props }, ref) => (
|
|
129
262
|
<View
|
|
130
263
|
ref={ref}
|
|
131
|
-
className={cn(
|
|
132
|
-
'flex-row items-center gap-3 px-4 py-3.5',
|
|
133
|
-
divided && 'border-t border-border',
|
|
134
|
-
className
|
|
135
|
-
)}
|
|
264
|
+
className={cn(divided && 'border-t border-border', className)}
|
|
136
265
|
{...props}
|
|
137
|
-
|
|
266
|
+
>
|
|
267
|
+
{title ? (
|
|
268
|
+
<View className="bg-muted/40 px-4 pb-1.5 pt-2.5">
|
|
269
|
+
{typeof title === 'string' ? (
|
|
270
|
+
<Text size="xs" weight="medium" muted className="uppercase tracking-wider">
|
|
271
|
+
{title}
|
|
272
|
+
</Text>
|
|
273
|
+
) : (
|
|
274
|
+
title
|
|
275
|
+
)}
|
|
276
|
+
</View>
|
|
277
|
+
) : null}
|
|
278
|
+
{divideChildren(children)}
|
|
279
|
+
</View>
|
|
138
280
|
)
|
|
139
281
|
);
|
|
140
|
-
|
|
282
|
+
FrameSection.displayName = 'Frame.Section';
|
|
283
|
+
|
|
284
|
+
/** Parts the panel divides. Declared after them, since it holds references. */
|
|
285
|
+
const DIVIDABLE = new Set<unknown>([FrameRow, FrameSection]);
|
|
141
286
|
|
|
142
287
|
export interface FrameFooterProps extends FrameProps {
|
|
143
288
|
children?: ReactNode;
|
|
@@ -169,6 +314,7 @@ export const Frame = Object.assign(FrameRoot, {
|
|
|
169
314
|
Action: FrameAction,
|
|
170
315
|
Description: FrameDescription,
|
|
171
316
|
Panel: FramePanel,
|
|
317
|
+
Section: FrameSection,
|
|
172
318
|
Row: FrameRow,
|
|
173
319
|
Footer: FrameFooter,
|
|
174
320
|
});
|
|
@@ -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>
|