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
6 changes: 6 additions & 0 deletions docs/components/rate.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
}
`" />

## 键盘操作

组件默认支持方向键调整评分,`Home` 清空评分(`allowClear=false` 时回到 1),`End` 跳到最高分。

## API

| 属性 | 说明 | 类型 | 默认值 |
Expand All @@ -23,4 +27,6 @@
| value | 当前值 | `number` | - |
| defaultValue | 默认值 | `number` | - |
| allowClear | 允许清除 | `boolean` | `true` |
| keyboard | 允许键盘操作 | `boolean` | `true` |
| disabled | 禁用评分 | `boolean` | `false` |
| onChange | 值变化回调 | `(value: number) => void` | - |
65 changes: 65 additions & 0 deletions packages/ui/src/components/form/controls/Rate/Rate.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Rate defaultValue={3} aria-label="Satisfaction" />)

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(<Rate defaultValue={3} onChange={onChange} />)

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(<Rate defaultValue={2} onChange={onChange} />)

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(<Rate defaultValue={2} disabled onChange={onChange} />)

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')
})
})
76 changes: 70 additions & 6 deletions packages/ui/src/components/form/controls/Rate/Rate.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -8,6 +8,7 @@ export interface RateProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChang
defaultValue?: number
disabled?: boolean
allowClear?: boolean
keyboard?: boolean
onChange?: (value: number) => void
}

Expand All @@ -19,36 +20,99 @@ export const Rate = forwardRef<HTMLDivElement, RateProps>(function Rate(
defaultValue = 0,
disabled,
allowClear = true,
keyboard = true,
onChange,
'aria-label': ariaLabel = 'Rating',
...props
},
ref,
) {
const starRefs = useRef<Array<HTMLButtonElement | null>>([])
const [current, setCurrent] = useControllableState<number>({
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<HTMLButtonElement>) => {
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 (
<div ref={ref} className={cn('inline-flex items-center gap-1', className)} {...props}>
<div
ref={ref}
role="radiogroup"
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
className={cn('inline-flex items-center gap-1', className)}
{...props}
>
{Array.from({ length: count }, (_, index) => {
const star = index + 1
const active = star <= current
return (
<button
key={star}
ref={(node) => {
starRefs.current[index] = node
}}
type="button"
role="radio"
aria-checked={current === star}
aria-label={`${star} of ${count}`}
tabIndex={disabled ? -1 : current === star || (current === 0 && star === 1) ? 0 : -1}
disabled={disabled}
onClick={() => {
if (allowClear && current === star) {
setCurrent(0)
setRate(0)
} else {
setCurrent(star)
setRate(star)
}
}}
className={cn('text-xl leading-none', active ? 'text-amber-400' : 'text-slate-300 dark:text-slate-600')}
aria-label={`Rate ${star}`}
onKeyDown={handleKeyDown}
className={cn(
'rounded text-xl leading-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500',
active ? 'text-amber-400' : 'text-slate-300 dark:text-slate-600',
)}
>
</button>
Expand Down
Loading