/**
* 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 };