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
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Chip — a compact, interactive token.
3
+ *
4
+ * Where Badge only shows and ToggleButton owns a stateful toolbar, Chip is the
5
+ * thing in between: a small pill you can *press*, mark as *selected*, or
6
+ * *remove*. It is the filter in a filter bar, the tag on a photo, the removable
7
+ * recipient in a "To:" field — small, dense, and touchable.
8
+ *
9
+ * The close button is its own hit target nested inside the pill, so tapping the
10
+ * ✕ removes the chip without also firing the chip's own `onPress`. That split
11
+ * is the whole reason a removable chip needs a component and not just a styled
12
+ * Badge.
13
+ *
14
+ * ```tsx
15
+ * <Chip>Design</Chip>
16
+ *
17
+ * <Chip variant="info" onClose={() => remove('design')}>Design</Chip>
18
+ *
19
+ * <Chip selected={on} onPress={() => setOn(!on)}>
20
+ * <CheckIcon size={14} />
21
+ * <Chip.Label>Available</Chip.Label>
22
+ * </Chip>
23
+ * ```
24
+ */
25
+ import {
26
+ createContext,
27
+ forwardRef,
28
+ useCallback,
29
+ useContext,
30
+ useMemo,
31
+ type ReactNode,
32
+ } from 'react';
33
+ import { Pressable, View, type ViewProps } from 'react-native';
34
+ import { tv, type VariantProps } from 'tailwind-variants';
35
+ import { useCSSVariable } from 'uniwind';
36
+ import { IconColorProvider, XIcon } from '../../icons';
37
+ import {
38
+ AnimatedPressable,
39
+ type AnimatedPressableProps,
40
+ } from '../../primitives/animated-pressable';
41
+ import { Text } from '../../primitives/text';
42
+ import { cn } from '../../utils/cn';
43
+ import { selectionTick } from '../../utils/haptics';
44
+
45
+ const chipVariants = tv({
46
+ slots: {
47
+ root: 'flex-row items-center self-start rounded-full border border-transparent',
48
+ label: 'font-medium',
49
+ close:
50
+ 'items-center justify-center rounded-full -mr-1 aspect-square opacity-70',
51
+ },
52
+ variants: {
53
+ variant: {
54
+ /** The resting tag — a quiet neutral fill. */
55
+ default: { root: 'bg-secondary', label: 'text-secondary-foreground' },
56
+ /** The accent fill, for the one chip that leads. */
57
+ primary: { root: 'bg-primary', label: 'text-primary-foreground' },
58
+ /** Just an outline — the lightest a chip gets while still being a shape. */
59
+ outline: { root: 'border-border bg-transparent', label: 'text-foreground' },
60
+ success: {
61
+ root: 'bg-success-subtle',
62
+ label: 'text-success-foreground',
63
+ },
64
+ warning: {
65
+ root: 'bg-warning-subtle',
66
+ label: 'text-warning-foreground',
67
+ },
68
+ info: { root: 'bg-info-subtle', label: 'text-info-foreground' },
69
+ destructive: {
70
+ root: 'bg-destructive-subtle',
71
+ label: 'text-destructive-foreground',
72
+ },
73
+ },
74
+ size: {
75
+ sm: { root: 'h-6 gap-1 px-2', label: 'text-xs' },
76
+ md: { root: 'h-7 gap-1.5 px-2.5', label: 'text-sm' },
77
+ lg: { root: 'h-9 gap-2 px-3.5', label: 'text-base' },
78
+ },
79
+ /**
80
+ * The filter "on" state — a loud accent surface that wins over the resting
81
+ * variant, so any chip can be a filter without picking a second colour.
82
+ */
83
+ selected: {
84
+ true: {
85
+ root: 'border-primary/24 bg-accent',
86
+ label: 'text-accent-foreground',
87
+ },
88
+ },
89
+ /** Trims the trailing padding so the close target hugs the right edge. */
90
+ closable: {
91
+ true: { root: 'pr-1' },
92
+ },
93
+ disabled: {
94
+ true: { root: 'opacity-[0.56]' },
95
+ },
96
+ },
97
+ compoundVariants: [
98
+ { size: 'sm', closable: true, class: { close: 'h-4' } },
99
+ { size: 'md', closable: true, class: { close: 'h-5' } },
100
+ { size: 'lg', closable: true, class: { close: 'h-6' } },
101
+ ],
102
+ defaultVariants: {
103
+ variant: 'default',
104
+ size: 'md',
105
+ },
106
+ });
107
+
108
+ type ChipVariantProps = VariantProps<typeof chipVariants>;
109
+ export type ChipVariant = NonNullable<ChipVariantProps['variant']>;
110
+ export type ChipSize = NonNullable<ChipVariantProps['size']>;
111
+
112
+ /** Which foreground token each variant paints its icons and ✕ with. */
113
+ const TINT_VAR: Record<ChipVariant, string> = {
114
+ default: '--color-secondary-foreground',
115
+ primary: '--color-primary-foreground',
116
+ outline: '--color-foreground',
117
+ success: '--color-success-foreground',
118
+ warning: '--color-warning-foreground',
119
+ info: '--color-info-foreground',
120
+ destructive: '--color-destructive-foreground',
121
+ };
122
+
123
+ /** The ✕ scales with the pill so it never looks stranded in a large chip. */
124
+ const CLOSE_ICON: Record<ChipSize, number> = { sm: 11, md: 13, lg: 15 };
125
+
126
+ /* -------------------------------------------------------------------------- */
127
+ /* State context */
128
+ /* -------------------------------------------------------------------------- */
129
+
130
+ interface ChipStateValue {
131
+ selected: boolean;
132
+ disabled: boolean;
133
+ }
134
+
135
+ interface ChipContextValue extends ChipStateValue {
136
+ /** The label classes for this chip's variant/size, so `Chip.Label` matches. */
137
+ labelClasses: string;
138
+ }
139
+
140
+ const ChipContext = createContext<ChipContextValue | null>(null);
141
+
142
+ /**
143
+ * The chip's own state, for anything rendered inside it — an icon that swaps
144
+ * when the chip is selected, a count that only shows when it is on.
145
+ */
146
+ export function useChip(): ChipStateValue {
147
+ const ctx = useContext(ChipContext);
148
+ if (!ctx) {
149
+ throw new Error('useChip must be used within a <Chip>');
150
+ }
151
+ return { selected: ctx.selected, disabled: ctx.disabled };
152
+ }
153
+
154
+ /* -------------------------------------------------------------------------- */
155
+ /* Chip */
156
+ /* -------------------------------------------------------------------------- */
157
+
158
+ export interface ChipProps
159
+ extends Omit<AnimatedPressableProps, 'children' | 'disabled'>,
160
+ Omit<ChipVariantProps, 'selected' | 'disabled' | 'closable'> {
161
+ children?: ReactNode;
162
+ className?: string;
163
+ /** Extra classes for the label when `children` is a string. */
164
+ labelClassName?: string;
165
+ /**
166
+ * The filter "on" state. Setting it (even to `false`) makes the chip a
167
+ * toggle, announced as such — pair it with `onPress` to flip it.
168
+ */
169
+ selected?: boolean;
170
+ disabled?: boolean;
171
+ /** A leading icon or avatar, before the label. */
172
+ start?: ReactNode;
173
+ /**
174
+ * Shows a trailing ✕ and calls this when it is pressed. The ✕ is its own hit
175
+ * target, so removing a chip never also fires its `onPress`.
176
+ */
177
+ onClose?: () => void;
178
+ /** Accessibility label for the ✕. Defaults to "Remove". */
179
+ closeLabel?: string;
180
+ /**
181
+ * A tick under the finger when the chip is pressed or removed. Off by
182
+ * default — needs the optional `expo-haptics`, and is silent without it.
183
+ */
184
+ haptics?: boolean;
185
+ }
186
+
187
+ const ChipRoot = forwardRef<View, ChipProps>(
188
+ (
189
+ {
190
+ children,
191
+ className,
192
+ labelClassName,
193
+ variant = 'default',
194
+ size = 'md',
195
+ selected,
196
+ disabled = false,
197
+ start,
198
+ onClose,
199
+ closeLabel = 'Remove',
200
+ haptics = false,
201
+ onPress,
202
+ ...props
203
+ },
204
+ ref
205
+ ) => {
206
+ const isToggle = selected !== undefined;
207
+ const isSelected = selected ?? false;
208
+ const closable = onClose !== undefined;
209
+ // Only the whole chip becoming pressable warrants the press animation; a
210
+ // static attribute tag stays a plain View so it does not invite a tap.
211
+ const pressable = onPress !== undefined || isToggle;
212
+
213
+ const slots = chipVariants({
214
+ variant,
215
+ size,
216
+ selected: isSelected,
217
+ closable,
218
+ disabled,
219
+ });
220
+
221
+ // Icons and the ✕ inherit this, so they track the surface they sit on
222
+ // instead of every caller hardcoding a hex that is wrong in one theme.
223
+ const tint = useCSSVariable(
224
+ isSelected ? '--color-accent-foreground' : TINT_VAR[variant]
225
+ );
226
+
227
+ const handlePress = useCallback<NonNullable<AnimatedPressableProps['onPress']>>(
228
+ (event) => {
229
+ if (haptics) selectionTick();
230
+ onPress?.(event);
231
+ },
232
+ [haptics, onPress]
233
+ );
234
+
235
+ const handleClose = useCallback(() => {
236
+ if (haptics) selectionTick();
237
+ onClose?.();
238
+ }, [haptics, onClose]);
239
+
240
+ const labelClasses = slots.label();
241
+ const state = useMemo(
242
+ () => ({ selected: isSelected, disabled, labelClasses }),
243
+ [isSelected, disabled, labelClasses]
244
+ );
245
+
246
+ const body = (
247
+ <>
248
+ {start}
249
+ {typeof children === 'string' ? (
250
+ <Text className={slots.label({ className: labelClassName })}>
251
+ {children}
252
+ </Text>
253
+ ) : (
254
+ children
255
+ )}
256
+ {closable ? (
257
+ <Pressable
258
+ accessibilityRole="button"
259
+ accessibilityLabel={closeLabel}
260
+ // A small pill leaves little room for the ✕; the slop gives the
261
+ // finger the target the pixels do not.
262
+ hitSlop={8}
263
+ disabled={disabled}
264
+ onPress={handleClose}
265
+ className={slots.close()}
266
+ >
267
+ <XIcon size={CLOSE_ICON[size]} />
268
+ </Pressable>
269
+ ) : null}
270
+ </>
271
+ );
272
+
273
+ const sharedProps = {
274
+ accessibilityRole: 'button' as const,
275
+ // A toggle chip is read out as checked/unchecked; a plain pressable one
276
+ // as a button. A static tag gets neither, so it is not announced as
277
+ // something to press.
278
+ accessibilityState: { disabled, ...(isToggle ? { selected: isSelected } : {}) },
279
+ 'aria-pressed': isToggle ? isSelected : undefined,
280
+ disabled,
281
+ className: slots.root({ className }),
282
+ };
283
+
284
+ return (
285
+ <ChipContext.Provider value={state}>
286
+ <IconColorProvider color={typeof tint === 'string' ? tint : undefined}>
287
+ {pressable ? (
288
+ <AnimatedPressable
289
+ ref={ref}
290
+ onPress={handlePress}
291
+ {...sharedProps}
292
+ {...props}
293
+ >
294
+ {body}
295
+ </AnimatedPressable>
296
+ ) : (
297
+ <View ref={ref} className={slots.root({ className })} {...(props as ViewProps)}>
298
+ {body}
299
+ </View>
300
+ )}
301
+ </IconColorProvider>
302
+ </ChipContext.Provider>
303
+ );
304
+ }
305
+ );
306
+ ChipRoot.displayName = 'Chip';
307
+
308
+ /* -------------------------------------------------------------------------- */
309
+ /* Chip.Label */
310
+ /* -------------------------------------------------------------------------- */
311
+
312
+ export interface ChipLabelProps {
313
+ className?: string;
314
+ children: ReactNode;
315
+ }
316
+
317
+ /**
318
+ * The label, when the chip holds more than a string — an icon beside text. It
319
+ * reads the selected state itself, so composing the two does not mean threading
320
+ * the colour through by hand.
321
+ */
322
+ function ChipLabel({ className, children }: ChipLabelProps) {
323
+ const ctx = useContext(ChipContext);
324
+ if (!ctx) {
325
+ throw new Error('Chip.Label must be used within a <Chip>');
326
+ }
327
+ return <Text className={cn(ctx.labelClasses, className)}>{children}</Text>;
328
+ }
329
+ ChipLabel.displayName = 'Chip.Label';
330
+
331
+ export const Chip = Object.assign(ChipRoot, {
332
+ Label: ChipLabel,
333
+ });
@@ -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
  });