diff --git a/docs/components/segmented.md b/docs/components/segmented.md index 0f412fa..814328a 100644 --- a/docs/components/segmented.md +++ b/docs/components/segmented.md @@ -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` | diff --git a/packages/ui/src/components/form/controls/Segmented/Segmented.test.tsx b/packages/ui/src/components/form/controls/Segmented/Segmented.test.tsx new file mode 100644 index 0000000..ea88dee --- /dev/null +++ b/packages/ui/src/components/form/controls/Segmented/Segmented.test.tsx @@ -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() + + 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() + + 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() + + expect(screen.getByRole('radio', { name: 'Day' })).toHaveAttribute('aria-checked', 'true') + + render() + + 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() + + 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() + + await user.click(screen.getByRole('radio', { name: 'Month' })) + + expect(onChange).not.toHaveBeenCalled() + expect(screen.getByRole('radiogroup')).toHaveAttribute('aria-disabled', 'true') + }) +}) diff --git a/packages/ui/src/components/form/controls/Segmented/Segmented.tsx b/packages/ui/src/components/form/controls/Segmented/Segmented.tsx index f164319..9d16ccc 100644 --- a/packages/ui/src/components/form/controls/Segmented/Segmented.tsx +++ b/packages/ui/src/components/form/controls/Segmented/Segmented.tsx @@ -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' @@ -12,6 +12,7 @@ export interface SegmentedProps extends Omit, 'on options: SegmentedOption[] value?: string defaultValue?: string + disabled?: boolean onChange?: (value: string) => void } @@ -20,31 +21,126 @@ export const Segmented = forwardRef(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>([]) const [current, setCurrent] = useControllableState({ 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) => { + 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 ( - - {options.map((item) => ( - 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} - - ))} + + {options.map((item, index) => { + const selected = current === item.value + const itemDisabled = disabled || item.disabled + return ( + { + 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} + + ) + })} ) })