Spaces:
Running
Running
File size: 3,799 Bytes
5f3e9f5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | import { useId, useRef } from 'react'
import type { ReactNode, KeyboardEvent } from 'react'
export interface SegmentOption<T extends string> {
value: T
label: ReactNode
icon?: ReactNode
disabled?: boolean
/** Optional `aria-label` override for icon-only options. */
ariaLabel?: string
}
interface Props<T extends string> {
options: ReadonlyArray<SegmentOption<T>>
value: T
onChange: (value: T) => void
ariaLabel: string
/** Stretch each segment to fill the row equally. Defaults to true. */
stretch?: boolean
size?: 'sm' | 'md'
className?: string
}
/**
* Radiogroup-style segmented control — exclusively-selected options
* rendered in a single rounded row. Replaces hand-rolled "row of buttons,
* one is highlighted" patterns scattered across pages.
*
* Keyboard support: ←/→ to move focus + select, Home/End jump to ends.
*/
export default function SegmentedControl<T extends string>({
options,
value,
onChange,
ariaLabel,
stretch = true,
size = 'md',
className,
}: Props<T>) {
const groupId = useId()
const refs = useRef<Array<HTMLButtonElement | null>>([])
const focusAt = (next: number) => {
const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0)
if (enabled.length === 0) return
const wrapped = ((next % enabled.length) + enabled.length) % enabled.length
const targetIdx = enabled[wrapped]
refs.current[targetIdx]?.focus()
onChange(options[targetIdx].value)
}
const onKey = (e: KeyboardEvent<HTMLButtonElement>, idx: number) => {
const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0)
const cursor = enabled.indexOf(idx)
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault()
focusAt(cursor + 1)
break
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault()
focusAt(cursor - 1)
break
case 'Home':
e.preventDefault()
focusAt(0)
break
case 'End':
e.preventDefault()
focusAt(enabled.length - 1)
break
}
}
const sizeClass = size === 'sm' ? 'text-xs px-2.5 py-1.5' : 'text-sm px-3 py-2'
return (
<div
role="radiogroup"
aria-label={ariaLabel}
className={
'inline-flex items-stretch rounded-lg border p-0.5 ' +
'border-[rgb(var(--line))] bg-[rgb(var(--bg-muted))] ' +
(stretch ? 'w-full ' : '') +
(className ?? '')
}
>
{options.map((opt, i) => {
const active = opt.value === value
return (
<button
key={`${groupId}-${opt.value}`}
ref={(el) => {
refs.current[i] = el
}}
type="button"
role="radio"
aria-checked={active}
aria-label={opt.ariaLabel}
disabled={opt.disabled}
tabIndex={active ? 0 : -1}
onClick={() => !opt.disabled && onChange(opt.value)}
onKeyDown={(e) => onKey(e, i)}
className={
'inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors ' +
sizeClass + ' ' +
(stretch ? 'flex-1 ' : '') +
(active
? 'bg-[rgb(var(--bg-surface))] text-[rgb(var(--text-strong))] shadow-sm '
: 'text-muted hover:text-[rgb(var(--text-strong))] ') +
'disabled:cursor-not-allowed disabled:opacity-50 ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-[rgb(var(--bg-app))]'
}
>
{opt.icon}
{opt.label}
</button>
)
})}
</div>
)
}
|