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
Original file line number Diff line number Diff line change
@@ -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(<TimePicker aria-label="Start time" />)

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(<TimePicker aria-label="Start time" />)

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(<TimePicker aria-label="Start time" onChange={onChange} />)

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(<TimePicker aria-label="Start time" disabled />)

const trigger = screen.getByRole('combobox', { name: 'Start time' })
expect(trigger).toHaveAttribute('aria-disabled', 'true')

await user.click(trigger)

expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
90 changes: 70 additions & 20 deletions packages/ui/src/components/form/pickers/TimePicker/TimePicker.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLAttributes<HTMLDivElement>, 'onChange'> {
Expand All @@ -20,6 +20,9 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(function T
disabled = false,
placeholder = '选择时间',
onChange,
onKeyDown,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
size = 'md',
...props
},
Expand All @@ -28,78 +31,125 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(function T
const [internal, setInternal] = useState(defaultValue)
const [open, setOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const panelId = useId()
const val = controlledValue ?? internal

const update = (v: string) => {
setInternal(v)
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 (
<div ref={wrapperRef} className={cn('relative inline-block', className)} {...props}>
<div
ref={ref}
role="combobox"
tabIndex={disabled ? -1 : 0}
aria-label={ariaLabel ?? (ariaLabelledBy ? undefined : placeholder)}
aria-labelledby={ariaLabelledBy}
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={open ? panelId : undefined}
aria-disabled={disabled || undefined}
className={cn(
'inline-flex w-40 cursor-pointer items-center rounded-md border border-slate-300 bg-white px-3 text-sm transition dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100',
'nova-focus-ring inline-flex w-40 cursor-pointer items-center rounded-md border border-slate-300 bg-white px-3 text-sm transition dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100',
heightCls,
disabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => !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()
}
}}
>
<span className={val ? '' : 'text-slate-400'}>{val || placeholder}</span>
<span className="ml-auto text-slate-400">🕐</span>
</div>
{open ? (
<div className="absolute z-50 mt-1 flex rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800">
<div className="h-48 w-14 overflow-auto border-r border-slate-200 py-1 dark:border-slate-700">
<div
id={panelId}
role="dialog"
aria-label="Time picker panel"
className="absolute z-50 mt-1 flex rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
>
<div
role="group"
aria-label="Hours"
className="h-48 w-14 overflow-auto border-r border-slate-200 py-1 dark:border-slate-700"
>
{hours.map((h) => (
<div
<button
key={h}
type="button"
aria-pressed={selH === h}
className={cn(
'cursor-pointer px-2 py-1 text-center text-sm hover:bg-brand-50 dark:hover:bg-brand-900/20',
'block w-full cursor-pointer px-2 py-1 text-center text-sm hover:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:hover:bg-brand-900/20',
selH === h && 'bg-brand-500 text-white hover:bg-brand-600',
)}
onMouseDown={() => {
onClick={() => {
const m = selM ?? 0
update(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`)
update(formatTime(h, m))
}}
>
{String(h).padStart(2, '0')}
</div>
</button>
))}
</div>
<div className="h-48 w-14 overflow-auto py-1">
<div role="group" aria-label="Minutes" className="h-48 w-14 overflow-auto py-1">
{minutes.map((m) => (
<div
<button
key={m}
type="button"
aria-pressed={selM === m}
className={cn(
'cursor-pointer px-2 py-1 text-center text-sm hover:bg-brand-50 dark:hover:bg-brand-900/20',
'block w-full cursor-pointer px-2 py-1 text-center text-sm hover:bg-brand-50 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:hover:bg-brand-900/20',
selM === m && 'bg-brand-500 text-white hover:bg-brand-600',
)}
onMouseDown={() => {
const h = selH ?? 0
update(`${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`)
setOpen(false)
onClick={() => {
const h = selH ?? hours[0] ?? 0
update(formatTime(h, m))
closePanel()
}}
>
{String(m).padStart(2, '0')}
</div>
</button>
))}
</div>
</div>
Expand Down
Loading