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>
  )
}