imajin/node_modules/@lilith/ui-primitives/src/SegmentedControl.tsx
2026-01-10 04:52:11 -08:00

339 lines
8.4 KiB
TypeScript

/**
* 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';
export interface SegmentedControlOption<T extends string = string> {
value: T;
label: string;
icon?: ReactNode;
disabled?: boolean;
}
export interface SegmentedControlProps<T extends string = string> {
value: T;
onChange: (value: T) => void;
options: Array<SegmentedControlOption<T>>;
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) => props.theme.colors.border};
border-radius: ${(props) => props.theme.borderRadius.lg};
overflow: hidden;
background-color: ${(props) => props.theme.colors.background.secondary};
transition: border-color ${(props) => props.theme.transitions.normal};
&:focus-within {
border-color: ${(props) => props.theme.colors.primary};
box-shadow: 0 0 0 2px ${(props) => 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) => props.theme.typography.fontFamily.body};
font-weight: ${(props) => props.theme.typography.fontWeight.medium};
text-align: center;
cursor: pointer;
border: none;
border-right: 1px solid ${(props) => props.theme.colors.border};
transition: all ${(props) => 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: ${theme.colors.background.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: ${theme.colors.background.tertiary};
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) => props.theme.colors.disabled.text};
&:hover {
background-color: transparent;
}
}
/* Focus state (visible only for keyboard navigation) */
&:focus-visible {
outline: 2px solid ${(props) => 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
* <SegmentedControl
* value={view}
* onChange={setView}
* options={[
* { value: 'grid', label: 'Grid' },
* { value: 'list', label: 'List' },
* ]}
* aria-label="View mode"
* />
*
* @example
* // With icons
* <SegmentedControl
* value={theme}
* onChange={setTheme}
* options={[
* { value: 'light', label: 'Light', icon: <SunIcon /> },
* { value: 'dark', label: 'Dark', icon: <MoonIcon /> },
* ]}
* size="sm"
* aria-label="Theme selection"
* />
*/
export const SegmentedControl = <T extends string = string>({
value,
onChange,
options,
size = 'md',
disabled = false,
className,
'aria-label': ariaLabel,
}: SegmentedControlProps<T>) => {
const containerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLButtonElement>, 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) {
if (!options[prevIndex].disabled) {
onChange(options[prevIndex].value);
break;
}
prevIndex--;
}
break;
}
case 'ArrowRight':
case 'ArrowDown': {
event.preventDefault();
// Find next enabled option
let nextIndex = currentIndex + 1;
while (nextIndex < options.length) {
if (!options[nextIndex].disabled) {
onChange(options[nextIndex].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 (
<Container
ref={containerRef}
role="radiogroup"
aria-label={ariaLabel}
$size={size}
className={className}
>
{options.map((option) => {
const isActive = option.value === value;
const isDisabled = disabled || option.disabled;
return (
<OptionButton
key={option.value}
type="button"
role="radio"
aria-checked={isActive}
aria-disabled={isDisabled}
disabled={isDisabled}
tabIndex={isActive ? 0 : -1}
$active={isActive}
$size={size}
$hasIcon={!!option.icon}
onClick={() => handleClick(option.value, option.disabled)}
onKeyDown={(e) => handleKeyDown(e, option.value)}
>
{option.icon && <IconWrapper $size={size}>{option.icon}</IconWrapper>}
<Label>{option.label}</Label>
</OptionButton>
);
})}
</Container>
);
};