/**
* Collapsible — one section of content that a header shows and hides.
*
* ```tsx
*
*
* Delivery options
*
*
*
* Anything, of any height.
*
*
* ```
*
* ## Why this and not a one-item Accordion
*
* The two look the same and differ in what happens to the body when it closes.
* An accordion unmounts it, which is right when a closed section should cost
* nothing and there is nothing inside worth keeping.
*
* This one keeps the body mounted and animates its height, so what is inside
* survives being closed — a part-filled form still has what was typed into it,
* a list is still scrolled to where it was, a video is still part-way through.
* That is the case this component exists for; reach for `Accordion` when
* sections are mutually exclusive or the body is expensive and rarely opened.
*
* ## The body is mounted whether or not it is open
*
* A height cannot be animated from `auto`, so the content has to be measured
* before it can be travelled to, and measuring it means rendering it. There is
* no lazy version of that: a body that has never been opened has still been
* rendered once. For a body heavy enough that this matters, an `Accordion` with
* a single item unmounts it instead.
*
* ## Reduced motion
*
* With the operating system set to reduce motion the panel snaps between its
* two states and the chevron turns without travelling. The disclosure still
* happens — it is the movement that is dropped, not the change.
*/
import {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import {
Pressable,
View,
type PressableProps,
type Text as RNText,
type ViewProps,
} from 'react-native';
import Animated, {
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { tv } from 'tailwind-variants';
import { ChevronDownIcon } from '../../icons';
import { Collapse } from '../../primitives/collapse';
import { Text, type TextProps, textChildren } from '../../primitives/text';
export type CollapsibleVariant = 'default' | 'surface' | 'ghost';
/** Matches the chevron in `Accordion`, so the two read as the same control. */
const INDICATOR_DURATION = 200;
const collapsibleVariants = tv({
slots: {
root: 'w-full flex-col',
trigger: 'flex-row items-center justify-between gap-4 py-4',
title: 'flex-1 text-base font-medium text-foreground',
indicator: 'items-center justify-center',
content: 'pb-4',
contentText: 'text-sm text-muted-foreground',
},
variants: {
variant: {
default: { trigger: 'px-1', content: 'px-1' },
surface: {
root: 'overflow-hidden rounded-2xl border border-border bg-surface',
trigger: 'px-4',
content: 'px-4',
},
ghost: { trigger: 'px-0', content: 'px-0' },
},
},
defaultVariants: {
variant: 'default',
},
});
interface CollapsibleContextValue {
open: boolean;
toggle: () => void;
isDisabled: boolean;
variant: CollapsibleVariant;
}
const CollapsibleContext = createContext(null);
function useCollapsible(component: string): CollapsibleContextValue {
const context = useContext(CollapsibleContext);
if (!context) throw new Error(`${component} must be used within a `);
return context;
}
export interface CollapsibleProps extends ViewProps {
className?: string;
variant?: CollapsibleVariant;
/** Whether the body is showing, controlled. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Stops the trigger opening or closing it, and marks it disabled to a screen reader. */
isDisabled?: boolean;
children?: ReactNode;
}
const CollapsibleRoot = forwardRef(
(
{
className,
variant = 'default',
open,
defaultOpen = false,
onOpenChange,
isDisabled = false,
children,
...props
},
ref
) => {
const [internal, setInternal] = useState(defaultOpen);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internal;
const toggle = useCallback(() => {
const next = !isOpen;
if (!isControlled) setInternal(next);
onOpenChange?.(next);
}, [isOpen, isControlled, onOpenChange]);
const context = useMemo(
() => ({ open: isOpen, toggle, isDisabled, variant }),
[isOpen, toggle, isDisabled, variant]
);
const { root } = collapsibleVariants({ variant });
return (
{children}
);
}
);
CollapsibleRoot.displayName = 'Collapsible';
export interface CollapsibleTriggerProps extends Omit {
className?: string;
children?: ReactNode;
}
/** The pressable header row. Bare strings are wrapped in the title style. */
const CollapsibleTrigger = forwardRef(
({ className, children, onPress, ...props }, ref) => {
const { open, toggle, isDisabled, variant } = useCollapsible('Collapsible.Trigger');
const { trigger, title } = collapsibleVariants({ variant });
return (
{
onPress?.(event);
toggle();
}}
>
{textChildren(children, (text) => (
{text}
))}
);
}
);
CollapsibleTrigger.displayName = 'Collapsible.Trigger';
const CollapsibleTitle = forwardRef(({ className, ...props }, ref) => {
const { variant } = useCollapsible('Collapsible.Title');
const { title } = collapsibleVariants({ variant });
return ;
});
CollapsibleTitle.displayName = 'Collapsible.Title';
export interface CollapsibleIndicatorProps extends ViewProps {
className?: string;
/** Replaces the default chevron. */
children?: ReactNode;
}
/** Chevron that rotates 180° while the body is open. */
const CollapsibleIndicator = forwardRef(
({ className, children, ...props }, ref) => {
const { open, variant } = useCollapsible('Collapsible.Indicator');
const { indicator } = collapsibleVariants({ variant });
const reducedMotion = useReducedMotion();
const progress = useSharedValue(open ? 1 : 0);
useEffect(() => {
progress.value = reducedMotion
? open
? 1
: 0
: withTiming(open ? 1 : 0, { duration: INDICATOR_DURATION });
}, [open, reducedMotion, progress]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${progress.value * 180}deg` }],
}));
return (
{children ?? }
);
}
);
CollapsibleIndicator.displayName = 'Collapsible.Indicator';
export interface CollapsibleContentProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The body, opened and closed by animating its own height.
*
* It stays mounted while closed, which is the point of the component — see the
* note at the top of the file. A closed body is hidden from the accessibility
* tree as well as from the eye, because a screen reader reading out a section
* that is not on screen is worse than one that skips it.
*/
const CollapsibleContent = forwardRef(
({ className, children, ...props }, ref) => {
const { open, variant } = useCollapsible('Collapsible.Content');
const { content, contentText } = collapsibleVariants({ variant });
return (
{textChildren(children, (text) => (
{text}
))}
);
}
);
CollapsibleContent.displayName = 'Collapsible.Content';
export const Collapsible = Object.assign(CollapsibleRoot, {
Trigger: CollapsibleTrigger,
Title: CollapsibleTitle,
Indicator: CollapsibleIndicator,
Content: CollapsibleContent,
});