'use client'; import React, { createContext, forwardRef, useCallback, useMemo, useRef, } from 'react'; import { Surface, Variant, cn, useEffectiveSurface, } from '../common'; import { useControllableState } from '../hooks/useControllableState'; export type GroupSize = 'sm' | 'md' | 'lg'; export type GroupVariant = Extract; export interface ToggleGroupContextValue { type: 'single' | 'multiple'; value: string | string[]; isPressed: (value: string) => boolean; toggleValue: (value: string) => void; registerItem: (el: HTMLButtonElement | null, value: string) => void; unregisterItem: (value: string) => void; onItemKeyDown: (e: React.KeyboardEvent, value: string) => void; rovingFocus: boolean; focusedValue: string | null; size: GroupSize; variant: GroupVariant; surface: Surface; } export const ToggleGroupContext = createContext(null); // PixelToggle moved to its own file; re-exported so this module's API // stays unchanged. Keep this re-export BELOW the ToggleGroupContext // definition — PixelToggle.tsx imports the context back from this module // (intentional cycle), so the context must be initialized first. export { PixelToggle, type PixelToggleProps } from './PixelToggle'; /** Shared props (no value/onChange — those vary by discriminator). */ interface PixelToggleGroupSharedProps extends Omit, 'onChange'> { rovingFocus?: boolean; loop?: boolean; size?: GroupSize; variant?: GroupVariant; surface?: Surface; /** Accessible label for the toolbar / radiogroup. */ 'aria-label'?: string; children: React.ReactNode; } interface PixelToggleGroupSingleProps extends PixelToggleGroupSharedProps { type?: 'single'; value?: string; defaultValue?: string; onChange?: (next: string) => void; } interface PixelToggleGroupMultipleProps extends PixelToggleGroupSharedProps { type: 'multiple'; value?: string[]; defaultValue?: string[]; onChange?: (next: string[]) => void; } /** * Public prop bag for {@link PixelToggleGroup}. Discriminated by `type`: * * - `type` omitted or `'single'` → value/onChange are `string`. * - `type='multiple'` → value/onChange are `string[]`. */ export type PixelToggleGroupProps = | PixelToggleGroupSingleProps | PixelToggleGroupMultipleProps; // Internal widened shape used inside the component body so destructuring works. // The public PixelToggleGroupProps stays a discriminated union (better DX). interface _InternalToggleGroupProps extends Omit, 'onChange'> { type?: 'single' | 'multiple'; value?: string | string[]; defaultValue?: string | string[]; onChange?: (next: string | string[]) => void; rovingFocus?: boolean; loop?: boolean; size?: GroupSize; variant?: GroupVariant; surface?: Surface; 'aria-label'?: string; 'aria-labelledby'?: string; children: React.ReactNode; } export const PixelToggleGroup = forwardRef( function PixelToggleGroup(props, ref) { const { type = 'single', value, defaultValue, onChange, rovingFocus = false, loop = false, size = 'md', variant = 'soft', surface: surfaceProp, className, children, ...rest } = props as _InternalToggleGroupProps; const surface = useEffectiveSurface(surfaceProp); const resolvedDefault = defaultValue !== undefined ? defaultValue : type === 'multiple' ? [] : ''; const [internalValue, setInternalValue] = useControllableState({ value, defaultValue: resolvedDefault, onChange, }); const itemsRef = useRef>(new Map()); const orderRef = useRef([]); const focusedValueRef = useRef(null); const [focusedValue, setFocusedValue] = React.useState(null); const registerItem = useCallback((el: HTMLButtonElement | null, val: string) => { if (el) { itemsRef.current.set(val, el); if (!orderRef.current.includes(val)) orderRef.current.push(val); // Seed first focusable on first registration if (focusedValueRef.current === null) { focusedValueRef.current = val; setFocusedValue(val); } } }, []); const unregisterItem = useCallback((val: string) => { itemsRef.current.delete(val); orderRef.current = orderRef.current.filter((v) => v !== val); if (focusedValueRef.current === val) { focusedValueRef.current = orderRef.current[0] ?? null; setFocusedValue(focusedValueRef.current); } }, []); const isPressed = useCallback( (val: string) => { if (type === 'multiple') { return Array.isArray(internalValue) && internalValue.includes(val); } return internalValue === val; }, [type, internalValue], ); const toggleValue = useCallback( (val: string) => { if (type === 'multiple') { const current = Array.isArray(internalValue) ? internalValue : []; const next = current.includes(val) ? current.filter((v) => v !== val) : [...current, val]; setInternalValue(next); } else { // Single mode: clicking pressed item unsets it const next = internalValue === val ? '' : val; setInternalValue(next); } }, [type, internalValue, setInternalValue], ); const moveFocus = useCallback( (currentValue: string, direction: 1 | -1 | 'first' | 'last') => { const order = orderRef.current; if (order.length === 0) return; let nextIndex: number; if (direction === 'first') { nextIndex = 0; } else if (direction === 'last') { nextIndex = order.length - 1; } else { const idx = order.indexOf(currentValue); if (idx === -1) return; nextIndex = idx + direction; if (loop) { nextIndex = (nextIndex + order.length) % order.length; } else { nextIndex = Math.max(0, Math.min(order.length - 1, nextIndex)); } } const nextValue = order[nextIndex]; const nextEl = itemsRef.current.get(nextValue); if (nextEl) { focusedValueRef.current = nextValue; setFocusedValue(nextValue); nextEl.focus(); } }, [loop], ); const onItemKeyDown = useCallback( (e: React.KeyboardEvent, val: string) => { switch (e.key) { case 'ArrowRight': case 'ArrowDown': e.preventDefault(); moveFocus(val, 1); break; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); moveFocus(val, -1); break; case 'Home': e.preventDefault(); moveFocus(val, 'first'); break; case 'End': e.preventDefault(); moveFocus(val, 'last'); break; default: break; } }, [moveFocus], ); const ctxValue = useMemo( () => ({ type, value: internalValue, isPressed, toggleValue, registerItem, unregisterItem, onItemKeyDown, rovingFocus, focusedValue, size, variant, surface, }), [ type, internalValue, isPressed, toggleValue, registerItem, unregisterItem, onItemKeyDown, rovingFocus, focusedValue, size, variant, surface, ], ); const ariaLabel = (rest as { 'aria-label'?: string })['aria-label']; const ariaLabelledBy = (rest as { 'aria-labelledby'?: string })['aria-labelledby']; const hasName = !!(ariaLabel || ariaLabelledBy); // Single mode = radiogroup; multi = toolbar group. Group needs an // accessible name to be exposed at all — fall back to no role if unnamed // and multi-select (keeps the SR tree clean instead of announcing "group"). const wrapperRole = type === 'single' ? 'radiogroup' : (hasName ? 'group' : undefined); return (
{children}
); }, ); PixelToggleGroup.displayName = 'PixelToggleGroup';