/** * SegmentedControl Component * * Theme-agnostic segmented control (radio group) with keyboard navigation. * Implements WCAG 2.1 AA accessibility with roving tabindex pattern. */ import { useRef, useCallback } from 'react'; import type { ReactNode, KeyboardEvent } from 'react'; import styled, { css } from 'styled-components'; import type { ThemeInterface } from '@lilith/ui-theme'; export interface SegmentedControlOption { value: T; label: string; icon?: ReactNode; disabled?: boolean; } export interface SegmentedControlProps { value: T; onChange: (value: T) => void; options: Array>; size?: 'sm' | 'md'; disabled?: boolean; 'aria-label': string; className?: string; } const Container = styled.div<{ $size: 'sm' | 'md' }>` display: inline-flex; align-items: stretch; border: 2px solid ${(props: { theme: ThemeInterface }) => props.theme.colors.border}; border-radius: ${(props: { theme: ThemeInterface }) => props.theme.borderRadius.lg}; overflow: hidden; background-color: ${(props) => typeof props.theme.colors.background === 'object' ? props.theme.colors.background.secondary : props.theme.colors.surface}; transition: border-color ${(props: { theme: ThemeInterface }) => props.theme.transitions.normal}; &:focus-within { border-color: ${(props: { theme: ThemeInterface }) => props.theme.colors.primary}; box-shadow: 0 0 0 2px ${(props: { theme: ThemeInterface }) => props.theme.colors.primary}33; } `; const OptionButton = styled.button<{ $active: boolean; $size: 'sm' | 'md'; $hasIcon: boolean; }>` position: relative; display: inline-flex; align-items: center; justify-content: center; font-family: ${(props: { theme: ThemeInterface }) => props.theme.typography.fontFamily.body}; font-weight: ${(props: { theme: ThemeInterface }) => props.theme.typography.fontWeight.medium}; text-align: center; cursor: pointer; border: none; border-right: 1px solid ${(props: { theme: ThemeInterface }) => props.theme.colors.border}; transition: all ${(props: { theme: ThemeInterface }) => props.theme.transitions.normal}; white-space: nowrap; flex-shrink: 0; &:last-child { border-right: none; } /* Size variants */ ${({ $size, $hasIcon, theme }) => { switch ($size) { case 'sm': return css` padding: ${$hasIcon ? `${theme.spacing.xs} ${theme.spacing.sm}` : `${theme.spacing.xs} ${theme.spacing.md}`}; font-size: ${theme.typography.fontSize.sm}; gap: ${theme.spacing.xs}; min-height: 32px; `; case 'md': return css` padding: ${$hasIcon ? `${theme.spacing.sm} ${theme.spacing.md}` : `${theme.spacing.sm} ${theme.spacing.lg}`}; font-size: ${theme.typography.fontSize.base}; gap: ${theme.spacing.sm}; min-height: 40px; `; default: return css` padding: ${theme.spacing.sm} ${theme.spacing.md}; font-size: ${theme.typography.fontSize.base}; gap: ${theme.spacing.sm}; min-height: 40px; `; } }} /* Active/Inactive states */ ${({ $active, theme }) => $active ? css` background-color: ${theme.colors.primary}; color: ${typeof theme.colors.background === 'object' ? theme.colors.background.primary : theme.colors.text.primary}; font-weight: ${theme.typography.fontWeight.semibold}; ${theme.extensions?.cyberpunk && css` box-shadow: inset 0 0 10px ${theme.colors.primary}33; `} ` : css` background-color: transparent; color: ${theme.colors.text.secondary}; &:hover:not(:disabled) { background-color: ${typeof theme.colors.background === 'object' ? theme.colors.background.tertiary : theme.colors.hover.surface}; color: ${theme.colors.text.primary}; } &:active:not(:disabled) { background-color: ${theme.colors.hover.primary}; } `} /* Disabled state */ &:disabled { cursor: not-allowed; opacity: 0.5; color: ${(props: { theme: ThemeInterface }) => props.theme.colors.disabled.text}; &:hover { background-color: transparent; } } /* Focus state (visible only for keyboard navigation) */ &:focus-visible { outline: 2px solid ${(props: { theme: ThemeInterface }) => props.theme.colors.primary}; outline-offset: -2px; z-index: 1; } /* Remove default focus outline (replaced by focus-visible) */ &:focus { outline: none; } `; const IconWrapper = styled.span<{ $size: 'sm' | 'md' }>` display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; ${({ $size }) => $size === 'sm' ? css` width: 16px; height: 16px; ` : css` width: 20px; height: 20px; `} svg { width: 100%; height: 100%; } `; const Label = styled.span` /* Text is always visible in SegmentedControl */ `; /** * SegmentedControl component with full keyboard navigation support. * * Implements roving tabindex pattern for arrow key navigation: * - Left/Right arrows move focus * - Space/Enter select option * - Tab moves to next focusable element * * @example * // Basic usage * * * @example * // With icons * }, * { value: 'dark', label: 'Dark', icon: }, * ]} * size="sm" * aria-label="Theme selection" * /> */ export const SegmentedControl = ({ value, onChange, options, size = 'md', disabled = false, className, 'aria-label': ariaLabel, }: SegmentedControlProps) => { const containerRef = useRef(null); const handleKeyDown = useCallback( (event: KeyboardEvent, currentValue: T) => { const currentIndex = options.findIndex((opt) => opt.value === currentValue); switch (event.key) { case 'ArrowLeft': case 'ArrowUp': { event.preventDefault(); // Find previous enabled option let prevIndex = currentIndex - 1; while (prevIndex >= 0 && prevIndex < options.length) { const prevOption = options[prevIndex]; if (prevOption && !prevOption.disabled) { onChange(prevOption.value); break; } prevIndex--; } break; } case 'ArrowRight': case 'ArrowDown': { event.preventDefault(); // Find next enabled option let nextIndex = currentIndex + 1; while (nextIndex >= 0 && nextIndex < options.length) { const nextOption = options[nextIndex]; if (nextOption && !nextOption.disabled) { onChange(nextOption.value); break; } nextIndex++; } break; } case 'Home': { event.preventDefault(); // Find first enabled option const firstEnabled = options.find((opt) => !opt.disabled); if (firstEnabled) { onChange(firstEnabled.value); } break; } case 'End': { event.preventDefault(); // Find last enabled option const reversedOptions = [...options].reverse(); const lastEnabled = reversedOptions.find((opt) => !opt.disabled); if (lastEnabled) { onChange(lastEnabled.value); } break; } default: break; } }, [options, onChange], ); const handleClick = useCallback( (optionValue: T, optionDisabled?: boolean) => { if (disabled || optionDisabled) { return; } onChange(optionValue); }, [disabled, onChange], ); return ( {options.map((option) => { const isActive = option.value === value; const isDisabled = disabled || option.disabled; return ( handleClick(option.value, option.disabled)} onKeyDown={(e) => handleKeyDown(e, option.value)} > {option.icon && {option.icon}} ); })} ); };