/** * Chip — a compact, interactive token. * * Where Badge only shows and ToggleButton owns a stateful toolbar, Chip is the * thing in between: a small pill you can *press*, mark as *selected*, or * *remove*. It is the filter in a filter bar, the tag on a photo, the removable * recipient in a "To:" field — small, dense, and touchable. * * The close button is its own hit target nested inside the pill, so tapping the * ✕ removes the chip without also firing the chip's own `onPress`. That split * is the whole reason a removable chip needs a component and not just a styled * Badge. * * ```tsx * Design * * remove('design')}>Design * * setOn(!on)}> * * Available * * ``` */ import { createContext, forwardRef, useCallback, useContext, useMemo, type ReactNode, } from 'react'; import { Pressable, View, type ViewProps } from 'react-native'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { IconColorProvider, XIcon } from '../../icons'; import { AnimatedPressable, type AnimatedPressableProps, } from '../../primitives/animated-pressable'; import { Text, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { selectionTick } from '../../utils/haptics'; const chipVariants = tv({ slots: { root: 'flex-row items-center self-start rounded-full border border-transparent', label: 'font-medium', close: 'items-center justify-center rounded-full -me-1 aspect-square opacity-70', }, variants: { variant: { /** The resting tag — a quiet neutral fill. */ default: { root: 'bg-secondary', label: 'text-secondary-foreground' }, /** The accent fill, for the one chip that leads. */ primary: { root: 'bg-primary', label: 'text-primary-foreground' }, /** Just an outline — the lightest a chip gets while still being a shape. */ outline: { root: 'border-border bg-transparent', label: 'text-foreground' }, success: { root: 'bg-success-subtle', label: 'text-success-foreground', }, warning: { root: 'bg-warning-subtle', label: 'text-warning-foreground', }, info: { root: 'bg-info-subtle', label: 'text-info-foreground' }, destructive: { root: 'bg-destructive-subtle', label: 'text-destructive-foreground', }, }, size: { sm: { root: 'h-6 gap-1 px-2', label: 'text-xs' }, md: { root: 'h-7 gap-1.5 px-2.5', label: 'text-sm' }, lg: { root: 'h-9 gap-2 px-3.5', label: 'text-base' }, }, /** * The filter "on" state — a loud accent surface that wins over the resting * variant, so any chip can be a filter without picking a second colour. */ selected: { true: { root: 'border-primary/24 bg-accent', label: 'text-accent-foreground', }, }, /** Trims the trailing padding so the close target hugs the right edge. */ closable: { true: { root: 'pe-1' }, }, disabled: { true: { root: 'opacity-[0.56]' }, }, }, compoundVariants: [ { size: 'sm', closable: true, class: { close: 'h-4' } }, { size: 'md', closable: true, class: { close: 'h-5' } }, { size: 'lg', closable: true, class: { close: 'h-6' } }, ], defaultVariants: { variant: 'default', size: 'md', }, }); type ChipVariantProps = VariantProps; export type ChipVariant = NonNullable; export type ChipSize = NonNullable; /** Which foreground token each variant paints its icons and ✕ with. */ const TINT_VAR: Record = { default: '--color-secondary-foreground', primary: '--color-primary-foreground', outline: '--color-foreground', success: '--color-success-foreground', warning: '--color-warning-foreground', info: '--color-info-foreground', destructive: '--color-destructive-foreground', }; /** The ✕ scales with the pill so it never looks stranded in a large chip. */ const CLOSE_ICON: Record = { sm: 11, md: 13, lg: 15 }; /* -------------------------------------------------------------------------- */ /* State context */ /* -------------------------------------------------------------------------- */ interface ChipStateValue { selected: boolean; disabled: boolean; } interface ChipContextValue extends ChipStateValue { /** The label classes for this chip's variant/size, so `Chip.Label` matches. */ labelClasses: string; } const ChipContext = createContext(null); /** * The chip's own state, for anything rendered inside it — an icon that swaps * when the chip is selected, a count that only shows when it is on. */ export function useChip(): ChipStateValue { const ctx = useContext(ChipContext); if (!ctx) { throw new Error('useChip must be used within a '); } return { selected: ctx.selected, disabled: ctx.disabled }; } /* -------------------------------------------------------------------------- */ /* Chip */ /* -------------------------------------------------------------------------- */ export interface ChipProps extends Omit, Omit { children?: ReactNode; className?: string; /** Extra classes for the label when `children` is a string. */ labelClassName?: string; /** * The filter "on" state. Setting it (even to `false`) makes the chip a * toggle, announced as such — pair it with `onPress` to flip it. */ selected?: boolean; disabled?: boolean; /** A leading icon or avatar, before the label. */ start?: ReactNode; /** * Shows a trailing ✕ and calls this when it is pressed. The ✕ is its own hit * target, so removing a chip never also fires its `onPress`. */ onClose?: () => void; /** Accessibility label for the ✕. Defaults to "Remove". */ closeLabel?: string; /** * A tick under the finger when the chip is pressed or removed. Off by * default — needs the optional `expo-haptics`, and is silent without it. */ haptics?: boolean; } const ChipRoot = forwardRef( ( { children, className, labelClassName, variant = 'default', size = 'md', selected, disabled = false, start, onClose, closeLabel = 'Remove', haptics = false, onPress, ...props }, ref ) => { const isToggle = selected !== undefined; const isSelected = selected ?? false; const closable = onClose !== undefined; // Only the whole chip becoming pressable warrants the press animation; a // static attribute tag stays a plain View so it does not invite a tap. const pressable = onPress !== undefined || isToggle; const slots = chipVariants({ variant, size, selected: isSelected, closable, disabled, }); // Icons and the ✕ inherit this, so they track the surface they sit on // instead of every caller hardcoding a hex that is wrong in one theme. const tint = useCSSVariable( isSelected ? '--color-accent-foreground' : TINT_VAR[variant] ); const handlePress = useCallback>( (event) => { if (haptics) selectionTick(); onPress?.(event); }, [haptics, onPress] ); const handleClose = useCallback(() => { if (haptics) selectionTick(); onClose?.(); }, [haptics, onClose]); const labelClasses = slots.label(); const state = useMemo( () => ({ selected: isSelected, disabled, labelClasses }), [isSelected, disabled, labelClasses] ); const body = ( <> {start} {textChildren(children, (text) => ( {text} ))} {closable ? ( ) : null} ); const sharedProps = { accessibilityRole: 'button' as const, // A toggle chip is read out as checked/unchecked; a plain pressable one // as a button. A static tag gets neither, so it is not announced as // something to press. accessibilityState: { disabled, ...(isToggle ? { selected: isSelected } : {}) }, 'aria-pressed': isToggle ? isSelected : undefined, disabled, className: slots.root({ className }), }; return ( {pressable ? ( {body} ) : ( {body} )} ); } ); ChipRoot.displayName = 'Chip'; /* -------------------------------------------------------------------------- */ /* Chip.Label */ /* -------------------------------------------------------------------------- */ export interface ChipLabelProps { className?: string; children: ReactNode; } /** * The label, when the chip holds more than a string — an icon beside text. It * reads the selected state itself, so composing the two does not mean threading * the colour through by hand. */ function ChipLabel({ className, children }: ChipLabelProps) { const ctx = useContext(ChipContext); if (!ctx) { throw new Error('Chip.Label must be used within a '); } return {children}; } ChipLabel.displayName = 'Chip.Label'; export const Chip = Object.assign(ChipRoot, { Label: ChipLabel, });