Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions docs/components/segmented.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,24 @@
}
`" />

## 键盘操作

组件按单选组语义暴露给辅助技术。聚焦选项后,可使用方向键切换选项,`Home` 跳到第一个可用选项,`End` 跳到最后一个可用选项。

## API

| 属性 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| options | 选项列表 | `SegmentedOption[]` | - |
| value | 当前值 | `string` | - |
| defaultValue | 默认值 | `string` | - |
| onChange | 变化回调 | `(value: string) => void` | - |
| 属性 | 说明 | 类型 | 默认值 |
| ------------ | ------------------ | ------------------------- | ------- |
| options | 选项列表 | `SegmentedOption[]` | - |
| value | 当前值 | `string` | - |
| defaultValue | 默认值 | `string` | - |
| disabled | 禁用整个分段控制器 | `boolean` | `false` |
| onChange | 变化回调 | `(value: string) => void` | - |

## SegmentedOption

| 属性 | 说明 | 类型 | 默认值 |
| -------- | -------- | ----------- | ------- |
| label | 选项内容 | `ReactNode` | - |
| value | 选项值 | `string` | - |
| disabled | 禁用选项 | `boolean` | `false` |
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'

import { Segmented } from './Segmented'

const options = [
{ label: 'Day', value: 'day' },
{ label: 'Week', value: 'week', disabled: true },
{ label: 'Month', value: 'month' },
]

describe('Segmented', () => {
it('exposes radiogroup semantics for the selected option', () => {
render(<Segmented aria-label="View range" options={options} defaultValue="day" />)

expect(screen.getByRole('radiogroup', { name: 'View range' })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: 'Day' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: 'Month' })).toHaveAttribute('aria-checked', 'false')
})

it('calls onChange when an option is selected', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

render(<Segmented options={options} defaultValue="day" onChange={onChange} />)

await user.click(screen.getByRole('radio', { name: 'Month' }))

expect(onChange).toHaveBeenCalledWith('month')
expect(screen.getByRole('radio', { name: 'Month' })).toHaveAttribute('aria-checked', 'true')
})

it('defaults to the first enabled option', () => {
render(<Segmented options={options} />)

expect(screen.getByRole('radio', { name: 'Day' })).toHaveAttribute('aria-checked', 'true')

render(<Segmented options={[{ label: 'Off', value: 'off', disabled: true }, ...options]} />)

expect(screen.getAllByRole('radio', { name: 'Day' })[1]).toHaveAttribute('aria-checked', 'true')
})

it('supports arrow, home, and end keyboard navigation while skipping disabled options', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

render(<Segmented options={options} defaultValue="day" onChange={onChange} />)

const day = screen.getByRole('radio', { name: 'Day' })
day.focus()

await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenLastCalledWith('month')
expect(screen.getByRole('radio', { name: 'Month' })).toHaveFocus()

await user.keyboard('{ArrowRight}')
expect(onChange).toHaveBeenLastCalledWith('day')
expect(day).toHaveFocus()

await user.keyboard('{End}')
expect(onChange).toHaveBeenLastCalledWith('month')

await user.keyboard('{Home}')
expect(onChange).toHaveBeenLastCalledWith('day')
})

it('does not update when disabled', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

render(<Segmented options={options} defaultValue="day" disabled onChange={onChange} />)

await user.click(screen.getByRole('radio', { name: 'Month' }))

expect(onChange).not.toHaveBeenCalled()
expect(screen.getByRole('radiogroup')).toHaveAttribute('aria-disabled', 'true')
})
})
124 changes: 110 additions & 14 deletions packages/ui/src/components/form/controls/Segmented/Segmented.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { forwardRef, useRef, type HTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'
import { cn } from '../../../../utils/cn'
import { useControllableState } from '../../../../utils/useControllableState'

Expand All @@ -12,6 +12,7 @@ export interface SegmentedProps extends Omit<HTMLAttributes<HTMLDivElement>, 'on
options: SegmentedOption[]
value?: string
defaultValue?: string
disabled?: boolean
onChange?: (value: string) => void
}

Expand All @@ -20,31 +21,126 @@ export const Segmented = forwardRef<HTMLDivElement, SegmentedProps>(function Seg
className,
options,
value,
defaultValue = options[0]?.value ?? '',
defaultValue = options.find((item) => !item.disabled)?.value ?? options[0]?.value ?? '',
disabled,
onChange,
'aria-label': ariaLabel = 'Segmented options',
...props
},
ref,
) {
const buttonRefs = useRef<Array<HTMLButtonElement | null>>([])
const [current, setCurrent] = useControllableState<string>({
value,
defaultValue,
onChange,
})

const enabledOptions = options.filter((item) => !item.disabled)
const tabbableValue = enabledOptions.some((item) => item.value === current)
? current
: (enabledOptions[0]?.value ?? '')

const selectValue = (nextValue: string, shouldFocus = true) => {
if (disabled) {
return
}

const nextOption = options.find((item) => item.value === nextValue)
if (!nextOption || nextOption.disabled) {
return
}

setCurrent(nextValue)

if (shouldFocus) {
const nextIndex = options.findIndex((item) => item.value === nextValue)
buttonRefs.current[nextIndex]?.focus()
}
}

const selectRelative = (step: number) => {
if (enabledOptions.length === 0) {
return
}

const currentIndex = enabledOptions.findIndex((item) => item.value === current)
const baseIndex = currentIndex >= 0 ? currentIndex : 0
const nextIndex = (baseIndex + step + enabledOptions.length) % enabledOptions.length

selectValue(enabledOptions[nextIndex].value)
}

const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (disabled) {
return
}

if (['ArrowRight', 'ArrowDown'].includes(event.key)) {
event.preventDefault()
selectRelative(1)
return
}

if (['ArrowLeft', 'ArrowUp'].includes(event.key)) {
event.preventDefault()
selectRelative(-1)
return
}

if (event.key === 'Home') {
event.preventDefault()
selectValue(enabledOptions[0]?.value ?? '')
return
}

if (event.key === 'End') {
event.preventDefault()
selectValue(enabledOptions[enabledOptions.length - 1]?.value ?? '')
}
}

return (
<div ref={ref} className={cn('inline-flex rounded-lg border border-slate-200 bg-white p-1 dark:border-slate-700 dark:bg-slate-900', className)} {...props}>
{options.map((item) => (
<button
key={item.value}
type="button"
disabled={item.disabled}
onClick={() => setCurrent(item.value)}
className={cn('rounded-md px-3 py-1.5 text-sm transition', current === item.value ? 'bg-brand-500 text-white' : 'text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800', item.disabled && 'opacity-50')}
>
{item.label}
</button>
))}
<div
ref={ref}
role="radiogroup"
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
className={cn(
'inline-flex rounded-lg border border-slate-200 bg-white p-1 dark:border-slate-700 dark:bg-slate-900',
disabled && 'opacity-60',
className,
)}
{...props}
>
{options.map((item, index) => {
const selected = current === item.value
const itemDisabled = disabled || item.disabled
return (
<button
key={item.value}
ref={(node) => {
buttonRefs.current[index] = node
}}
type="button"
role="radio"
aria-checked={selected}
disabled={itemDisabled}
tabIndex={itemDisabled ? -1 : item.value === tabbableValue ? 0 : -1}
onClick={() => selectValue(item.value, false)}
onKeyDown={handleKeyDown}
className={cn(
'rounded-md px-3 py-1.5 text-sm transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500',
selected
? 'bg-brand-500 text-white'
: 'text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800',
itemDisabled && 'opacity-50',
)}
>
{item.label}
</button>
)
})}
</div>
)
})
Loading