diff --git a/docs/components/rate.md b/docs/components/rate.md index da12554..e285655 100644 --- a/docs/components/rate.md +++ b/docs/components/rate.md @@ -15,6 +15,10 @@ } `" /> +## 键盘操作 + +组件默认支持方向键调整评分,`Home` 清空评分(`allowClear=false` 时回到 1),`End` 跳到最高分。 + ## API | 属性 | 说明 | 类型 | 默认值 | @@ -23,4 +27,6 @@ | value | 当前值 | `number` | - | | defaultValue | 默认值 | `number` | - | | allowClear | 允许清除 | `boolean` | `true` | +| keyboard | 允许键盘操作 | `boolean` | `true` | +| disabled | 禁用评分 | `boolean` | `false` | | onChange | 值变化回调 | `(value: number) => void` | - | diff --git a/packages/ui/src/components/form/controls/Rate/Rate.test.tsx b/packages/ui/src/components/form/controls/Rate/Rate.test.tsx new file mode 100644 index 0000000..62181b0 --- /dev/null +++ b/packages/ui/src/components/form/controls/Rate/Rate.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { Rate } from './Rate' + +describe('Rate', () => { + it('exposes radio group semantics for each score', () => { + render() + + expect(screen.getByRole('radiogroup', { name: 'Satisfaction' })).toBeInTheDocument() + expect(screen.getByRole('radio', { name: '3 of 5' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('radio', { name: '4 of 5' })).toHaveAttribute('aria-checked', 'false') + }) + + it('clears the current value when allowClear is enabled', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render() + + await user.click(screen.getByRole('radio', { name: '3 of 5' })) + + expect(onChange).toHaveBeenCalledWith(0) + expect(screen.getByRole('radio', { name: '3 of 5' })).toHaveAttribute('aria-checked', 'false') + }) + + it('supports arrow, home, and end keyboard navigation', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render() + + const secondStar = screen.getByRole('radio', { name: '2 of 5' }) + secondStar.focus() + + await user.keyboard('{ArrowRight}') + expect(onChange).toHaveBeenLastCalledWith(3) + expect(screen.getByRole('radio', { name: '3 of 5' })).toHaveAttribute('aria-checked', 'true') + + await user.keyboard('{ArrowLeft}') + expect(onChange).toHaveBeenLastCalledWith(2) + expect(screen.getByRole('radio', { name: '2 of 5' })).toHaveAttribute('aria-checked', 'true') + + await user.keyboard('{End}') + expect(onChange).toHaveBeenLastCalledWith(5) + expect(screen.getByRole('radio', { name: '5 of 5' })).toHaveAttribute('aria-checked', 'true') + + await user.keyboard('{Home}') + expect(onChange).toHaveBeenLastCalledWith(0) + expect(screen.getByRole('radio', { name: '1 of 5' })).toHaveFocus() + }) + + it('does not change value when disabled', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render() + + await user.click(screen.getByRole('radio', { name: '3 of 5' })) + + expect(onChange).not.toHaveBeenCalled() + expect(screen.getByRole('radio', { name: '2 of 5' })).toHaveAttribute('aria-checked', 'true') + }) +}) diff --git a/packages/ui/src/components/form/controls/Rate/Rate.tsx b/packages/ui/src/components/form/controls/Rate/Rate.tsx index d6f42e8..bb29949 100644 --- a/packages/ui/src/components/form/controls/Rate/Rate.tsx +++ b/packages/ui/src/components/form/controls/Rate/Rate.tsx @@ -1,4 +1,4 @@ -import { forwardRef, type HTMLAttributes } from 'react' +import { forwardRef, useRef, type HTMLAttributes, type KeyboardEvent } from 'react' import { cn } from '../../../../utils/cn' import { useControllableState } from '../../../../utils/useControllableState' @@ -8,6 +8,7 @@ export interface RateProps extends Omit, 'onChang defaultValue?: number disabled?: boolean allowClear?: boolean + keyboard?: boolean onChange?: (value: number) => void } @@ -19,36 +20,99 @@ export const Rate = forwardRef(function Rate( defaultValue = 0, disabled, allowClear = true, + keyboard = true, onChange, + 'aria-label': ariaLabel = 'Rating', ...props }, ref, ) { + const starRefs = useRef>([]) const [current, setCurrent] = useControllableState({ value, defaultValue, onChange, }) + const minValue = allowClear ? 0 : 1 + + const setRate = (nextValue: number) => { + if (disabled) { + return + } + + const clampedValue = Math.min(count, Math.max(minValue, nextValue)) + setCurrent(clampedValue) + + const focusIndex = clampedValue > 0 ? clampedValue - 1 : 0 + starRefs.current[focusIndex]?.focus() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (!keyboard || disabled) { + return + } + + if (['ArrowRight', 'ArrowUp'].includes(event.key)) { + event.preventDefault() + setRate(current + 1) + return + } + + if (['ArrowLeft', 'ArrowDown'].includes(event.key)) { + event.preventDefault() + setRate(current - 1) + return + } + + if (event.key === 'Home') { + event.preventDefault() + setRate(minValue) + return + } + + if (event.key === 'End') { + event.preventDefault() + setRate(count) + } + } + return ( -
+
{Array.from({ length: count }, (_, index) => { const star = index + 1 const active = star <= current return (