panelui-native 0.12.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "panelui-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "High-performance React Native UI components for Expo. Semantic design tokens, powered by Uniwind (Tailwind v4) and Reanimated.",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"module": "./lib/module/index.js",
|
|
@@ -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
|
+
});
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from '../../primitives/animated-pressable';
|
|
45
45
|
import { Text } from '../../primitives/text';
|
|
46
46
|
import { cn } from '../../utils/cn';
|
|
47
|
+
import { selectionTick } from '../../utils/haptics';
|
|
47
48
|
|
|
48
49
|
const toggleVariants = tv({
|
|
49
50
|
slots: {
|
|
@@ -100,6 +101,7 @@ interface ToggleGroupContextValue {
|
|
|
100
101
|
disabled: boolean;
|
|
101
102
|
variant?: ToggleButtonVariant;
|
|
102
103
|
size?: ToggleButtonSize;
|
|
104
|
+
haptics?: boolean;
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);
|
|
@@ -121,6 +123,12 @@ export interface ToggleButtonGroupProps extends ViewProps {
|
|
|
121
123
|
/** Applied to every button that does not set its own. */
|
|
122
124
|
variant?: ToggleButtonVariant;
|
|
123
125
|
size?: ToggleButtonSize;
|
|
126
|
+
/**
|
|
127
|
+
* A tick under the finger each time a button is pressed. Off by default —
|
|
128
|
+
* needs the optional `expo-haptics`, and is silent without it. Inherited by
|
|
129
|
+
* every button that does not set its own.
|
|
130
|
+
*/
|
|
131
|
+
haptics?: boolean;
|
|
124
132
|
children: ReactNode;
|
|
125
133
|
}
|
|
126
134
|
|
|
@@ -135,6 +143,7 @@ const ToggleButtonGroup = forwardRef<View, ToggleButtonGroupProps>(
|
|
|
135
143
|
disabled = false,
|
|
136
144
|
variant,
|
|
137
145
|
size,
|
|
146
|
+
haptics,
|
|
138
147
|
children,
|
|
139
148
|
...props
|
|
140
149
|
},
|
|
@@ -171,8 +180,9 @@ const ToggleButtonGroup = forwardRef<View, ToggleButtonGroupProps>(
|
|
|
171
180
|
disabled,
|
|
172
181
|
variant,
|
|
173
182
|
size,
|
|
183
|
+
haptics,
|
|
174
184
|
}),
|
|
175
|
-
[selection, toggle, disabled, variant, size]
|
|
185
|
+
[selection, toggle, disabled, variant, size, haptics]
|
|
176
186
|
);
|
|
177
187
|
|
|
178
188
|
return (
|
|
@@ -232,6 +242,12 @@ export interface ToggleButtonProps
|
|
|
232
242
|
defaultSelected?: boolean;
|
|
233
243
|
onSelectedChange?: (selected: boolean) => void;
|
|
234
244
|
disabled?: boolean;
|
|
245
|
+
/**
|
|
246
|
+
* A tick under the finger each time the button is pressed. Off by default —
|
|
247
|
+
* needs the optional `expo-haptics`, and is silent without it. Inside a
|
|
248
|
+
* group, falls back to the group's `haptics`.
|
|
249
|
+
*/
|
|
250
|
+
haptics?: boolean;
|
|
235
251
|
/** Square, with no horizontal padding — for a single icon. */
|
|
236
252
|
iconOnly?: boolean;
|
|
237
253
|
/** Extra classes applied only while selected. */
|
|
@@ -253,6 +269,7 @@ const ToggleButtonRoot = forwardRef<View, ToggleButtonProps>(
|
|
|
253
269
|
disabled,
|
|
254
270
|
variant,
|
|
255
271
|
size,
|
|
272
|
+
haptics,
|
|
256
273
|
iconOnly = false,
|
|
257
274
|
selectedClassName,
|
|
258
275
|
unselectedClassName,
|
|
@@ -272,6 +289,7 @@ const ToggleButtonRoot = forwardRef<View, ToggleButtonProps>(
|
|
|
272
289
|
*/
|
|
273
290
|
const selected = group && id ? group.isSelected(id) : (selectedProp ?? internal);
|
|
274
291
|
const isDisabled = disabled || (group?.disabled ?? false);
|
|
292
|
+
const enableHaptics = haptics ?? group?.haptics ?? false;
|
|
275
293
|
|
|
276
294
|
const slots = toggleVariants({
|
|
277
295
|
variant: variant ?? group?.variant,
|
|
@@ -290,6 +308,10 @@ const ToggleButtonRoot = forwardRef<View, ToggleButtonProps>(
|
|
|
290
308
|
const handlePress = useCallback<NonNullable<AnimatedPressableProps['onPress']>>(
|
|
291
309
|
(event) => {
|
|
292
310
|
onPress?.(event);
|
|
311
|
+
// A tick per tap, not per resolved-state change — a single-select
|
|
312
|
+
// group flips two buttons on every pick, and one buzz is the honest
|
|
313
|
+
// count of "the user pressed something".
|
|
314
|
+
if (enableHaptics) selectionTick();
|
|
293
315
|
if (group && id) {
|
|
294
316
|
group.toggle(id);
|
|
295
317
|
return;
|
|
@@ -298,7 +320,7 @@ const ToggleButtonRoot = forwardRef<View, ToggleButtonProps>(
|
|
|
298
320
|
if (selectedProp === undefined) setInternal(next);
|
|
299
321
|
onSelectedChange?.(next);
|
|
300
322
|
},
|
|
301
|
-
[onPress, group, id, selected, selectedProp, onSelectedChange]
|
|
323
|
+
[onPress, enableHaptics, group, id, selected, selectedProp, onSelectedChange]
|
|
302
324
|
);
|
|
303
325
|
|
|
304
326
|
const state = useMemo(
|
package/src/index.ts
CHANGED
|
@@ -120,6 +120,14 @@ export {
|
|
|
120
120
|
} from './components/tabs';
|
|
121
121
|
export { Card, type CardProps } from './components/card';
|
|
122
122
|
export { Checkbox, type CheckboxProps } from './components/checkbox';
|
|
123
|
+
export {
|
|
124
|
+
Chip,
|
|
125
|
+
useChip,
|
|
126
|
+
type ChipProps,
|
|
127
|
+
type ChipLabelProps,
|
|
128
|
+
type ChipVariant,
|
|
129
|
+
type ChipSize,
|
|
130
|
+
} from './components/chip';
|
|
123
131
|
export {
|
|
124
132
|
EmptyState,
|
|
125
133
|
type EmptyStateProps,
|