339 lines
8.4 KiB
TypeScript
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>
|
|
);
|
|
};
|