import { type ReactNode, useCallback } from 'react'; import { Pressable, ScrollView, type StyleProp, View, type ViewStyle } from 'react-native'; import { cn } from '../../../lib/cn'; import { H_INTERACTIVE, PX_INTERACTIVE, TEXT_INTERACTIVE } from '../../../lib/radius'; import { Text } from '../../typography/Text/text'; import { SHAPE_RADIUS, shouldSuppressDivider, type ToggleGroupShape, type ToggleGroupSize } from './toggle-group.logic'; // ── types ────────────────────────────────────────────────────────────────────── type ToggleGroupContainerVariant = 'bordered' | 'connected'; // ── shared text ──────────────────────────────────────────────────────────────── /** Selection-aware text class — selected items get a subtle foreground lift above the translucent overlay. */ function itemTextClass(size: ToggleGroupSize, selected: boolean): string { return cn(TEXT_INTERACTIVE[size], selected ? 'text-white' : 'text-muted-foreground'); } // ── exports ──────────────────────────────────────────────────────────────────── export type ToggleGroupItem = { /** Unique identifier for the item — passed to `onValueChange` on press. */ value: string; /** Content rendered inside the item. Typically a short text label. */ label: ReactNode; }; export type ToggleGroupProps = { /** Items to render. Each item must have a unique `value` and a `label` to display. */ items: ToggleGroupItem[]; /** The `value` of the currently-selected item. Pass `undefined` for no selection. */ value?: string; /** Called when the user presses an item, with that item's `value`. */ onValueChange?: (value: string) => void; /** Size drives item height and text size. @default 'md' */ size?: ToggleGroupSize; /** * Corner shape — `rounded` uses the interactive radius, `pill` uses fully-rounded. * Shapes the outer edges of the segmented control. * @default 'pill' */ shape?: ToggleGroupShape; /** * `bordered` renders a segmented control with an outer border and inner dividers * between items; `connected` drops the inner dividers so items read as one * continuous strip. * @default 'bordered' */ containerVariant?: ToggleGroupContainerVariant; /** Layout direction — items sit side by side or stacked. @default 'horizontal' */ orientation?: 'horizontal' | 'vertical'; children?: undefined; className?: string; style?: StyleProp; testID?: string; }; // ── component ────────────────────────────────────────────────────────────────── /** * A segmented control — a row (or column) of flat items where one choice is * selected at a time, built from Pressable + Text. * * `containerVariant` — `bordered` (an outer border with inner dividers between * items) or `connected` (no inner dividers — items sit flush as one continuous * strip). * * `shape` — `rounded` (interactive corner radius) or `pill` (fully-rounded) — * controls the outer edges of the control. * * Dividers adjacent to the selected item are suppressed so the selection reads as * one continuous surface with its neighbours. * * Horizontal groups scroll when they run out of width, while vertical groups keep * their items stacked. * * Each item is identified by a `value` string. Pressing an item calls * `onValueChange` with that value — the consumer updates `value` to complete * the selection. */ export function ToggleGroup({ containerVariant = 'bordered', orientation = 'horizontal', shape = 'pill', size = 'md', value, onValueChange, items, className, style, testID, }: ToggleGroupProps) { const isHorizontal = orientation === 'horizontal'; const radius = SHAPE_RADIUS[shape]; const isConnected = containerVariant === 'connected'; const getOnValueChangeHandler = useCallback((itemValue: string) => () => onValueChange?.(itemValue), [onValueChange]); const total = items.length; const selectedIdx = items.findIndex((it) => it.value === value); const segmentedItems = items.map((item, index) => { const selected = item.value === value; const isLast = index === total - 1; // suppress dividers adjacent to the selected item so it reads as // one continuous surface with its neighbours. connected mode has no // internal dividers at all. const suppressDivider = shouldSuppressDivider(index, selectedIdx, isConnected); const itemClass = cn( // Horizontal items keep the fixed interactive height; vertical items drop // it and grow from `py-3` instead, so the label keeps its full line box // instead of being squeezed into the fixed height minus the padding. isHorizontal && H_INTERACTIVE[size], 'items-center justify-center', PX_INTERACTIVE[size], !isHorizontal && 'py-3', selected ? 'bg-info' : 'bg-muted', total > 1 && !isLast && !suppressDivider && (isHorizontal ? 'hairline-r border-border' : 'hairline-b border-border'), ); return ( {item.label} ); }); // The shell is a bordered, overflow-clipped column so a horizontal ScrollView // can stretch to its width and scroll the items when they overflow; vertical // segmented controls keep the items stacked directly. `self-start` stops the // shell from stretching to its parent's cross size (default alignItems: // stretch in a column) — the control hugs its items like ChoiceGroup/Tabs — // while `max-w-full` caps it at the parent width so an overflowing row still // scrolls instead of spilling. Consumers wanting a full-width control can // pass `className="w-full"`. return ( {isHorizontal ? ( // `grow-0` neutralises the ScrollView's default `flexGrow: 1` so the // horizontal strip hugs its items' height instead of stretching to fill // the shell's column main axis on native. Width still comes from the // shell's cross-axis stretch, so overflowing rows keep scrolling. {segmentedItems} ) : ( segmentedItems )} ); }