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.
Files changed (106) 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/chip/index.tsx +333 -0
  83. package/src/components/frame/index.tsx +175 -29
  84. package/src/components/input/index.tsx +122 -16
  85. package/src/components/popover/index.tsx +49 -24
  86. package/src/components/progress/index.tsx +81 -1
  87. package/src/components/radio-group/index.tsx +55 -8
  88. package/src/components/section-rail/index.tsx +459 -0
  89. package/src/components/select/index.tsx +265 -52
  90. package/src/components/slider/index.tsx +371 -0
  91. package/src/components/switch/index.tsx +8 -1
  92. package/src/components/tabs/index.tsx +208 -28
  93. package/src/components/toggle-button/index.tsx +396 -0
  94. package/src/hooks/index.ts +5 -0
  95. package/src/hooks/use-keyboard-avoidance.ts +73 -7
  96. package/src/hooks/use-scroll-sections.ts +127 -0
  97. package/src/index.ts +45 -6
  98. package/src/native/index.ts +13 -1
  99. package/src/primitives/keyboard-avoider.tsx +9 -1
  100. package/src/utils/haptics.ts +63 -0
  101. package/theme.css +6 -2
  102. package/lib/module/components/inline-select/index.js +0 -284
  103. package/lib/module/components/inline-select/index.js.map +0 -1
  104. package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
  105. package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
  106. package/src/components/inline-select/index.tsx +0 -332
@@ -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>
@@ -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
- return (
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
- row: 'flex-row items-center gap-2.5',
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
- row: 'w-full items-start justify-between gap-3 rounded-xl border border-border bg-card p-4',
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
- ({ className, value, onValueChange, disabled, variant = 'dot', children, ...props }, ref) => {
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('gap-3', className)}
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({ variant, selected, disabled: !!disabled });
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()}>