panelui-native 0.9.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/lib/module/components/bottom-sheet/index.js +38 -2
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +19 -1
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/frame/index.js +128 -15
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/input/index.js +107 -15
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/popover/index.js +22 -11
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/progress/index.js +52 -2
- package/lib/module/components/progress/index.js.map +1 -1
- package/lib/module/components/radio-group/index.js +34 -7
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/section-rail/index.js +355 -0
- package/lib/module/components/section-rail/index.js.map +1 -0
- package/lib/module/components/select/index.js +245 -56
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/slider/index.js +355 -0
- package/lib/module/components/slider/index.js.map +1 -0
- package/lib/module/components/switch/index.js +10 -1
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +186 -16
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/toggle-button/index.js +297 -0
- package/lib/module/components/toggle-button/index.js.map +1 -0
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +57 -8
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/hooks/use-scroll-sections.js +89 -0
- package/lib/module/hooks/use-scroll-sections.js.map +1 -0
- package/lib/module/index.js +4 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/primitives/keyboard-avoider.js +2 -0
- package/lib/module/primitives/keyboard-avoider.js.map +1 -1
- package/lib/module/utils/haptics.js +57 -0
- package/lib/module/utils/haptics.js.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +9 -4
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/frame/index.d.ts +47 -7
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/index.d.ts +113 -2
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/popover/index.d.ts +7 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/progress/index.d.ts +33 -0
- package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radio-group/index.d.ts +7 -0
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/section-rail/index.d.ts +115 -0
- package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +46 -7
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts +203 -0
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -0
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +46 -4
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toggle-button/index.d.ts +244 -0
- package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +1 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +10 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts +31 -0
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +6 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +16 -1
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts +8 -1
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
- package/lib/typescript/src/utils/haptics.d.ts +12 -0
- package/lib/typescript/src/utils/haptics.d.ts.map +1 -0
- package/package.json +5 -1
- package/src/components/bottom-sheet/index.tsx +38 -2
- package/src/components/button/index.tsx +26 -5
- package/src/components/chip/index.tsx +333 -0
- package/src/components/frame/index.tsx +175 -29
- package/src/components/input/index.tsx +122 -16
- package/src/components/popover/index.tsx +49 -24
- package/src/components/progress/index.tsx +81 -1
- package/src/components/radio-group/index.tsx +55 -8
- package/src/components/section-rail/index.tsx +459 -0
- package/src/components/select/index.tsx +265 -52
- package/src/components/slider/index.tsx +371 -0
- package/src/components/switch/index.tsx +8 -1
- package/src/components/tabs/index.tsx +208 -28
- package/src/components/toggle-button/index.tsx +396 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-keyboard-avoidance.ts +73 -7
- package/src/hooks/use-scroll-sections.ts +127 -0
- package/src/index.ts +45 -6
- package/src/native/index.ts +13 -1
- package/src/primitives/keyboard-avoider.tsx +9 -1
- package/src/utils/haptics.ts +63 -0
- package/theme.css +6 -2
- package/lib/module/components/inline-select/index.js +0 -284
- package/lib/module/components/inline-select/index.js.map +0 -1
- package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
- package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
- package/src/components/inline-select/index.tsx +0 -332
|
@@ -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
|
|
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
|
});
|