diff --git a/packages/ui/src/components/form/pickers/TimePicker/TimePicker.test.tsx b/packages/ui/src/components/form/pickers/TimePicker/TimePicker.test.tsx new file mode 100644 index 0000000..5749cfc --- /dev/null +++ b/packages/ui/src/components/form/pickers/TimePicker/TimePicker.test.tsx @@ -0,0 +1,71 @@ +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { TimePicker } from './TimePicker' + +describe('TimePicker', () => { + it('exposes an accessible trigger and panel relationship', async () => { + const user = userEvent.setup() + + render() + + const trigger = screen.getByRole('combobox', { name: 'Start time' }) + expect(trigger).toHaveAttribute('aria-expanded', 'false') + + await user.click(trigger) + + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(trigger).toHaveAttribute('aria-controls', screen.getByRole('dialog').id) + expect(screen.getByRole('group', { name: 'Hours' })).toBeInTheDocument() + expect(screen.getByRole('group', { name: 'Minutes' })).toBeInTheDocument() + }) + + it('opens and closes from the keyboard', async () => { + const user = userEvent.setup() + + render() + + const trigger = screen.getByRole('combobox', { name: 'Start time' }) + trigger.focus() + + await user.keyboard('{Enter}') + expect(screen.getByRole('dialog')).toBeInTheDocument() + + await user.keyboard('{Escape}') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(trigger).toHaveAttribute('aria-expanded', 'false') + }) + + it('selects hour and minute using native buttons', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + + render() + + await user.click(screen.getByRole('combobox', { name: 'Start time' })) + await user.click( + within(screen.getByRole('group', { name: 'Hours' })).getByRole('button', { name: '09' }), + ) + await user.click( + within(screen.getByRole('group', { name: 'Minutes' })).getByRole('button', { name: '30' }), + ) + + expect(onChange).toHaveBeenLastCalledWith('09:30') + expect(screen.getByRole('combobox', { name: 'Start time' })).toHaveTextContent('09:30') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('does not open while disabled', async () => { + const user = userEvent.setup() + + render() + + const trigger = screen.getByRole('combobox', { name: 'Start time' }) + expect(trigger).toHaveAttribute('aria-disabled', 'true') + + await user.click(trigger) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/packages/ui/src/components/form/pickers/TimePicker/TimePicker.tsx b/packages/ui/src/components/form/pickers/TimePicker/TimePicker.tsx index d7b370b..ae4ce7b 100644 --- a/packages/ui/src/components/form/pickers/TimePicker/TimePicker.tsx +++ b/packages/ui/src/components/form/pickers/TimePicker/TimePicker.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useState, useRef, useEffect, type HTMLAttributes } from 'react' +import { forwardRef, useEffect, useId, useRef, useState, type HTMLAttributes } from 'react' import { cn } from '../../../../utils/cn' export interface TimePickerProps extends Omit, 'onChange'> { @@ -20,6 +20,9 @@ export const TimePicker = forwardRef(function T disabled = false, placeholder = '选择既间', onChange, + onKeyDown, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, size = 'md', ...props }, @@ -28,6 +31,7 @@ export const TimePicker = forwardRef(function T const [internal, setInternal] = useState(defaultValue) const [open, setOpen] = useState(false) const wrapperRef = useRef(null) + const panelId = useId() const val = controlledValue ?? internal const update = (v: string) => { @@ -35,71 +39,117 @@ export const TimePicker = forwardRef(function T onChange?.(v) } + const closePanel = () => setOpen(false) + + const togglePanel = () => { + if (!disabled) setOpen((current) => !current) + } + useEffect(() => { if (!open) return const handler = (e: MouseEvent) => { - if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) setOpen(false) + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) closePanel() } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }) - const hours = Array.from({ length: format === '24' ? 24 : 12 }, (_, i) => format === '24' ? i : i + 1) + const hours = Array.from({ length: format === '24' ? 24 : 12 }, (_, i) => + format === '24' ? i : i + 1, + ) const minutes = Array.from({ length: 60 }, (_, i) => i) const [selH, selM] = val ? val.split(':').map(Number) : [null, null] const heightCls = { 'h-8': size === 'sm', 'h-10': size === 'md', 'h-11': size === 'lg' } + const formatTime = (hour: number, minute: number) => + `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` return (
!disabled && setOpen(!open)} + onClick={togglePanel} + onKeyDown={(event) => { + onKeyDown?.(event) + if (event.defaultPrevented || disabled) return + + if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') { + event.preventDefault() + setOpen(true) + return + } + + if (event.key === 'Escape') { + event.preventDefault() + closePanel() + } + }} > {val || placeholder} πŸ•
{open ? ( -
-
+