/** * ToggleButton — a button that stays down. * * The difference from Button is the whole point: a button does something and * springs back, a toggle button *is* something afterwards. So the selected * state is the loud one — a filled accent surface — and the resting state is * quiet, either a soft fill or nothing at all. * * ```tsx * Like * * * * * ``` * * `ToggleButtonGroup` takes over the state for a set of them — a segmented * toolbar in `multiple` mode, an either-or choice in `single`. Inside a group * each button needs an `id`, which is the value the group reports. * * ```tsx * * * * * ``` */ import { createContext, forwardRef, useCallback, useContext, useMemo, useState, type ReactNode, } from 'react'; import { View, type ViewProps } from 'react-native'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { IconColorProvider } 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 toggleVariants = tv({ slots: { root: 'flex-row items-center justify-center gap-2 rounded-lg border border-transparent', label: 'font-medium', }, variants: { variant: { /** A soft fill at rest, so the control is visible before you touch it. */ default: { root: 'bg-secondary' }, /** Nothing at rest — for a dense toolbar where six fills would be noise. */ ghost: { root: 'bg-transparent' }, }, size: { sm: { root: 'h-9 gap-1.5 px-2.5', label: 'text-sm' }, md: { root: 'h-11 px-4', label: 'text-base' }, lg: { root: 'h-12 px-6', label: 'text-lg' }, }, selected: { true: { root: 'border-primary/24 bg-accent', label: 'text-accent-foreground' }, false: { label: 'text-muted-foreground' }, }, iconOnly: { true: { root: 'px-0' }, }, disabled: { true: { root: 'opacity-[0.64]' }, }, }, compoundVariants: [ // Square, and sized from the height so the icon sits in the middle. { iconOnly: true, size: 'sm', class: { root: 'w-9' } }, { iconOnly: true, size: 'md', class: { root: 'w-11' } }, { iconOnly: true, size: 'lg', class: { root: 'w-12' } }, ], defaultVariants: { variant: 'default', size: 'md', }, }); type ToggleVariantProps = VariantProps; export type ToggleButtonSize = NonNullable; export type ToggleButtonVariant = NonNullable; export type ToggleSelectionMode = 'single' | 'multiple'; /* -------------------------------------------------------------------------- */ /* Group */ /* -------------------------------------------------------------------------- */ interface ToggleGroupContextValue { isSelected: (id: string) => boolean; toggle: (id: string) => void; disabled: boolean; variant?: ToggleButtonVariant; size?: ToggleButtonSize; haptics?: boolean; } const ToggleGroupContext = createContext(null); export interface ToggleButtonGroupProps extends ViewProps { className?: string; /** * `multiple` is a set of independent marks — bold *and* italic. `single` is * an either-or choice where picking one clears the last. */ selectionMode?: ToggleSelectionMode; /** Selected ids. Controlled — pair with `onValueChange`. */ value?: string[]; /** Starting selection when uncontrolled. */ defaultValue?: string[]; onValueChange?: (value: string[]) => void; /** Disables every button in the group. */ disabled?: boolean; /** Applied to every button that does not set its own. */ variant?: ToggleButtonVariant; size?: ToggleButtonSize; /** * A tick under the finger each time a button is pressed. Off by default — * needs the optional `expo-haptics`, and is silent without it. Inherited by * every button that does not set its own. */ haptics?: boolean; children: ReactNode; } const ToggleButtonGroup = forwardRef( ( { className, selectionMode = 'multiple', value, defaultValue = [], onValueChange, disabled = false, variant, size, haptics, children, ...props }, ref ) => { const isControlled = value !== undefined; const [internal, setInternal] = useState(defaultValue); const selection = isControlled ? value : internal; const toggle = useCallback( (id: string) => { const has = selection.includes(id); const next = selectionMode === 'single' ? // Pressing the selected one again clears it. A toolbar filter you // cannot turn off is a trap. has ? [] : [id] : has ? selection.filter((item) => item !== id) : [...selection, id]; if (!isControlled) setInternal(next); onValueChange?.(next); }, [selection, selectionMode, isControlled, onValueChange] ); const context = useMemo( () => ({ isSelected: (id) => selection.includes(id), toggle, disabled, variant, size, haptics, }), [selection, toggle, disabled, variant, size, haptics] ); return ( {textChildren(children)} ); } ); ToggleButtonGroup.displayName = 'ToggleButtonGroup'; /* -------------------------------------------------------------------------- */ /* Button */ /* -------------------------------------------------------------------------- */ interface ToggleStateValue { selected: boolean; disabled: boolean; } const ToggleStateContext = createContext(null); /** * The toggle's own state, for anything rendered inside it — an icon that * changes shape when selected, a count that only shows when it is on. */ export function useToggleButton(): ToggleStateValue { const state = useContext(ToggleStateContext); if (!state) { throw new Error('useToggleButton must be used within a '); } return state; } export interface ToggleButtonProps extends Omit, Omit { children?: ReactNode; className?: string; /** Extra classes for the label when children is a string. */ labelClassName?: string; /** Identifies this button within a `ToggleButtonGroup`. Required there. */ id?: string; /** Controlled selection. Ignored inside a group, which owns the state. */ selected?: boolean; /** Starting state when uncontrolled and outside a group. */ defaultSelected?: boolean; onSelectedChange?: (selected: boolean) => void; disabled?: boolean; /** * A tick under the finger each time the button is pressed. Off by default — * needs the optional `expo-haptics`, and is silent without it. Inside a * group, falls back to the group's `haptics`. */ haptics?: boolean; /** Square, with no horizontal padding — for a single icon. */ iconOnly?: boolean; /** Extra classes applied only while selected. */ selectedClassName?: string; /** Extra classes applied only while unselected. */ unselectedClassName?: string; } const ToggleButtonRoot = forwardRef( ( { children, className, labelClassName, id, selected: selectedProp, defaultSelected = false, onSelectedChange, disabled, variant, size, haptics, iconOnly = false, selectedClassName, unselectedClassName, onPress, ...props }, ref ) => { const group = useContext(ToggleGroupContext); const [internal, setInternal] = useState(defaultSelected); /* * Three sources of truth, in order: the group owns it if there is one, * then a `selected` prop, then local state. The group comes first because * a button inside one that also took a `selected` prop would fight it, and * the group is the thing that knows about the other buttons. */ const selected = group && id ? group.isSelected(id) : (selectedProp ?? internal); const isDisabled = disabled || (group?.disabled ?? false); const enableHaptics = haptics ?? group?.haptics ?? false; const slots = toggleVariants({ variant: variant ?? group?.variant, size: size ?? group?.size, selected, iconOnly, disabled: isDisabled, }); // Icons inside inherit this, so they follow the surface they sit on // instead of every caller hardcoding a hex that is wrong in one theme. const onColor = useCSSVariable('--color-accent-foreground'); const offColor = useCSSVariable('--color-muted-foreground'); const tint = selected ? onColor : offColor; const handlePress = useCallback>( (event) => { onPress?.(event); // A tick per tap, not per resolved-state change — a single-select // group flips two buttons on every pick, and one buzz is the honest // count of "the user pressed something". if (enableHaptics) selectionTick(); if (group && id) { group.toggle(id); return; } const next = !selected; if (selectedProp === undefined) setInternal(next); onSelectedChange?.(next); }, [onPress, enableHaptics, group, id, selected, selectedProp, onSelectedChange] ); const state = useMemo( () => ({ selected, disabled: isDisabled }), [selected, isDisabled] ); return ( {textChildren(children, (text) => ( {text} ))} ); } ); ToggleButtonRoot.displayName = 'ToggleButton'; export interface ToggleButtonLabelProps { className?: string; children: ReactNode; } /** * The label, when the button 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 ToggleButtonLabel({ className, children }: ToggleButtonLabelProps) { const { selected } = useToggleButton(); return ( {children} ); } ToggleButtonLabel.displayName = 'ToggleButton.Label'; export const ToggleButton = Object.assign(ToggleButtonRoot, { Label: ToggleButtonLabel, }); export { ToggleButtonGroup };