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.12.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,