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.
Files changed (105) hide show
  1. package/README.md +5 -3
  2. package/lib/module/components/bottom-sheet/index.js +38 -2
  3. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +19 -1
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/frame/index.js +128 -15
  7. package/lib/module/components/frame/index.js.map +1 -1
  8. package/lib/module/components/input/index.js +107 -15
  9. package/lib/module/components/input/index.js.map +1 -1
  10. package/lib/module/components/popover/index.js +22 -11
  11. package/lib/module/components/popover/index.js.map +1 -1
  12. package/lib/module/components/progress/index.js +52 -2
  13. package/lib/module/components/progress/index.js.map +1 -1
  14. package/lib/module/components/radio-group/index.js +34 -7
  15. package/lib/module/components/radio-group/index.js.map +1 -1
  16. package/lib/module/components/section-rail/index.js +355 -0
  17. package/lib/module/components/section-rail/index.js.map +1 -0
  18. package/lib/module/components/select/index.js +245 -56
  19. package/lib/module/components/select/index.js.map +1 -1
  20. package/lib/module/components/slider/index.js +355 -0
  21. package/lib/module/components/slider/index.js.map +1 -0
  22. package/lib/module/components/switch/index.js +10 -1
  23. package/lib/module/components/switch/index.js.map +1 -1
  24. package/lib/module/components/tabs/index.js +186 -16
  25. package/lib/module/components/tabs/index.js.map +1 -1
  26. package/lib/module/components/toggle-button/index.js +297 -0
  27. package/lib/module/components/toggle-button/index.js.map +1 -0
  28. package/lib/module/hooks/index.js +1 -0
  29. package/lib/module/hooks/index.js.map +1 -1
  30. package/lib/module/hooks/use-keyboard-avoidance.js +57 -8
  31. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  32. package/lib/module/hooks/use-scroll-sections.js +89 -0
  33. package/lib/module/hooks/use-scroll-sections.js.map +1 -0
  34. package/lib/module/index.js +4 -1
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/module/native/index.js.map +1 -1
  37. package/lib/module/primitives/keyboard-avoider.js +2 -0
  38. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  39. package/lib/module/utils/haptics.js +57 -0
  40. package/lib/module/utils/haptics.js.map +1 -0
  41. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button/index.d.ts +9 -4
  43. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/frame/index.d.ts +47 -7
  45. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  46. package/lib/typescript/src/components/input/index.d.ts +113 -2
  47. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/popover/index.d.ts +7 -1
  49. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/progress/index.d.ts +33 -0
  51. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/radio-group/index.d.ts +7 -0
  53. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/section-rail/index.d.ts +115 -0
  55. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/select/index.d.ts +46 -7
  57. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/slider/index.d.ts +203 -0
  59. package/lib/typescript/src/components/slider/index.d.ts.map +1 -0
  60. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/tabs/index.d.ts +46 -4
  62. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/toggle-button/index.d.ts +244 -0
  64. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -0
  65. package/lib/typescript/src/hooks/index.d.ts +1 -0
  66. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  67. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +10 -1
  68. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  69. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +31 -0
  70. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -0
  71. package/lib/typescript/src/index.d.ts +6 -3
  72. package/lib/typescript/src/index.d.ts.map +1 -1
  73. package/lib/typescript/src/native/index.d.ts +16 -1
  74. package/lib/typescript/src/native/index.d.ts.map +1 -1
  75. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +8 -1
  76. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  77. package/lib/typescript/src/utils/haptics.d.ts +12 -0
  78. package/lib/typescript/src/utils/haptics.d.ts.map +1 -0
  79. package/package.json +5 -1
  80. package/src/components/bottom-sheet/index.tsx +38 -2
  81. package/src/components/button/index.tsx +26 -5
  82. package/src/components/frame/index.tsx +175 -29
  83. package/src/components/input/index.tsx +122 -16
  84. package/src/components/popover/index.tsx +49 -24
  85. package/src/components/progress/index.tsx +81 -1
  86. package/src/components/radio-group/index.tsx +55 -8
  87. package/src/components/section-rail/index.tsx +459 -0
  88. package/src/components/select/index.tsx +265 -52
  89. package/src/components/slider/index.tsx +371 -0
  90. package/src/components/switch/index.tsx +8 -1
  91. package/src/components/tabs/index.tsx +208 -28
  92. package/src/components/toggle-button/index.tsx +374 -0
  93. package/src/hooks/index.ts +5 -0
  94. package/src/hooks/use-keyboard-avoidance.ts +73 -7
  95. package/src/hooks/use-scroll-sections.ts +127 -0
  96. package/src/index.ts +37 -6
  97. package/src/native/index.ts +13 -1
  98. package/src/primitives/keyboard-avoider.tsx +9 -1
  99. package/src/utils/haptics.ts +63 -0
  100. package/theme.css +6 -2
  101. package/lib/module/components/inline-select/index.js +0 -284
  102. package/lib/module/components/inline-select/index.js.map +0 -1
  103. package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
  104. package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
  105. 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 divided>…</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 { View, type Text as RNText, type ViewProps } from 'react-native';
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
- const FrameRoot = forwardRef<View, FrameProps>(({ className, ...props }, ref) => (
34
- <View
35
- ref={ref}
36
- className={cn('rounded-2xl border border-border bg-surface p-1.5', className)}
37
- {...props}
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, FrameProps>(({ className, ...props }, ref) => (
111
- <View
112
- ref={ref}
113
- className={cn('overflow-hidden rounded-xl border border-border bg-card', className)}
114
- {...props}
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. Pass `divided` on every row after the first to
121
- * draw a hairline separator above it (React Native has no :first-child).
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
- export interface FrameRowProps extends FrameProps {
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
- const FrameRow = forwardRef<View, FrameRowProps>(
128
- ({ className, divided, ...props }, ref) => (
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
- FrameRow.displayName = 'Frame.Row';
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
- import { forwardRef, useCallback, useState } from 'react';
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 { tv } from 'tailwind-variants';
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
- focused: {
19
- true: { field: 'border-ring' },
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
- invalid: {
22
- true: { field: 'border-destructive/40' },
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
- export interface InputProps extends TextInputProps {
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
- focused,
74
- invalid: !!errorMessage,
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 ? <Text className={slots.label()}>{label}</Text> : null}
97
- <TextInput
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 offset={keyboardOffset} className={containerClasses}>
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
- props.onLayout?.(event);
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 resolvedWidth =
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
- ? bounds.right - bounds.left
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
- accessibilityViewIsModal
369
- style={[
370
- {
371
- position: 'absolute',
372
- // Until it has measured itself the panel has no honest
373
- // position, so it is laid out off-screen rather than at the
374
- // origin — an invisible view at 0,0 still catches taps.
375
- top: position?.top ?? -9999,
376
- left: position?.left ?? -9999,
377
- maxWidth: bounds.right - bounds.left,
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
- {children}
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>