From a41b12b217af0cf0f311a89c7703c2a64996f37e Mon Sep 17 00:00:00 2001 From: Pareder Date: Tue, 25 Aug 2026 12:49:32 +0300 Subject: [PATCH 1/2] feat: Improve Time panel keyboard navigation and semantics --- .../TimePanel/TimePanelBody/TimeColumn.tsx | 124 +- src/interface.tsx | 5 + src/locale/en_GB.ts | 5 + src/locale/en_US.ts | 5 + src/locale/zh_CN.ts | 5 + src/locale/zh_TW.ts | 6 + tests/__snapshots__/panel.spec.tsx.snap | 2178 +++++++++++++++++ 7 files changed, 2325 insertions(+), 3 deletions(-) diff --git a/src/PickerPanel/TimePanel/TimePanelBody/TimeColumn.tsx b/src/PickerPanel/TimePanel/TimePanelBody/TimeColumn.tsx index 4058f278c..8f8b7497a 100644 --- a/src/PickerPanel/TimePanel/TimePanelBody/TimeColumn.tsx +++ b/src/PickerPanel/TimePanel/TimePanelBody/TimeColumn.tsx @@ -3,6 +3,7 @@ import { useLayoutEffect } from '@rc-component/util'; import * as React from 'react'; import { usePanelContext } from '../../context'; import useScrollTo from './useScrollTo'; +import type { Locale } from '../../../interface'; const SCROLL_DELAY = 300; @@ -12,11 +13,13 @@ export type Unit = { disabled?: boolean; }; +type TimeUnitType = 'hour' | 'minute' | 'second' | 'millisecond' | 'meridiem'; + export interface TimeUnitColumnProps { units: Unit[]; value: number | string; optionalValue?: number | string; - type: 'hour' | 'minute' | 'second' | 'millisecond' | 'meridiem'; + type: TimeUnitType; onChange: (value: number | string) => void; onHover: (value: number | string) => void; onDblClick?: VoidFunction; @@ -28,6 +31,48 @@ function flattenUnits(units: Unit[]) { return units.map(({ value, label, disabled }) => [value, label, disabled].join(',')).join(';'); } +const LIST_LABEL_MAP: Record string> = { + hour: (locale) => locale.hourSelect, + minute: (locale) => locale.minuteSelect, + second: (locale) => locale.secondSelect, + millisecond: (locale) => locale.millisecondSelect, + meridiem: (locale) => locale.meridiemSelect, +}; + +// `en_US` → `en-US`, `sr_Cyrl_RS` → `sr-Cyrl-RS` +const toBCP47 = (code: string) => code.replace(/_/g, '-'); + +const LIST_ITEM_LABEL_MAP: Record< + TimeUnitType, + (value: string | number, locale: Locale) => string +> = { + hour: (value, locale) => + value.toLocaleString(toBCP47(locale.locale), { + style: 'unit', + unit: 'hour', + unitDisplay: 'long', + }), + minute: (value, locale) => + value.toLocaleString(toBCP47(locale.locale), { + style: 'unit', + unit: 'minute', + unitDisplay: 'long', + }), + second: (value, locale) => + value.toLocaleString(toBCP47(locale.locale), { + style: 'unit', + unit: 'second', + unitDisplay: 'long', + }), + millisecond: (value, locale) => + value.toLocaleString(toBCP47(locale.locale), { + style: 'unit', + unit: 'millisecond', + unitDisplay: 'long', + }), + meridiem: (value) => value.toString(), +}; + export default function TimeColumn(props: TimeUnitColumnProps) { const { units, value, optionalValue, type, onChange, onHover, onDblClick, changeOnScroll } = props; @@ -92,20 +137,92 @@ export default function TimeColumn(props: TimeUnitColum } }; + // ========================= Focus ========================= + const activeValue = value ?? optionalValue; + + // Tracks keyboard-navigation cursor separately from the committed value. + const [focusedValue, setFocusedValue] = React.useState(null); + + // Reset cursor when the committed value changes (e.g. click or external update). + React.useEffect(() => { + setFocusedValue(null); + }, [value]); + + const tabFocusValue = focusedValue ?? activeValue; + + // The active option registers its node via a callback ref; after keyboard + // navigation we move DOM focus to it without querying the DOM. + const focusedLiNodeRef = React.useRef(null); + const pendingFocusRef = React.useRef(false); + + const registerFocusedLi = React.useCallback((node: HTMLElement | null) => { + focusedLiNodeRef.current = node; + }, []); + + React.useEffect(() => { + if (pendingFocusRef.current) { + pendingFocusRef.current = false; + focusedLiNodeRef.current?.focus(); + } + }, [focusedValue]); + + // ========================= Keyboard ========================= + const onCellKeyDown = (e: React.KeyboardEvent) => { + const enabledUnits = units.filter((u) => !u.disabled); + const currentIdx = enabledUnits.findIndex((u) => u.value === tabFocusValue); + + if (e.key === 'ArrowDown') { + e.preventDefault(); + pendingFocusRef.current = true; + const next = currentIdx < enabledUnits.length - 1 ? currentIdx + 1 : 0; + setFocusedValue(enabledUnits[next]?.value); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + pendingFocusRef.current = true; + const prev = currentIdx > 0 ? currentIdx - 1 : enabledUnits.length - 1; + setFocusedValue(enabledUnits[prev]?.value); + } else if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const target = enabledUnits.find((u) => u.value === tabFocusValue); + if (target) { + onChange(target.value); + } + } + }; + // ========================= Render ========================= const columnPrefixCls = `${panelPrefixCls}-column`; return ( -
    +
      { + if (!ulRef.current?.contains(e.relatedTarget as Node)) { + setFocusedValue(null); + } + }} + > {units.map(({ label, value: unitValue, disabled }) => { const inner =
      {label}
      ; + const isSelected = value === unitValue; return (
    • { @@ -124,6 +241,7 @@ export default function TimeColumn(props: TimeUnitColum onMouseLeave={() => { onHover(null); }} + onKeyDown={onCellKeyDown} data-value={unitValue} > {cellRender diff --git a/src/interface.tsx b/src/interface.tsx index 63a690d06..6caeeb956 100644 --- a/src/interface.tsx +++ b/src/interface.tsx @@ -71,6 +71,11 @@ export type Locale = { monthSelect: string; yearSelect: string; decadeSelect: string; + hourSelect?: string; + minuteSelect?: string; + secondSelect?: string; + millisecondSelect?: string; + meridiemSelect?: string; previousYear: string; nextYear: string; diff --git a/src/locale/en_GB.ts b/src/locale/en_GB.ts index 5dbb1a860..1b346946e 100644 --- a/src/locale/en_GB.ts +++ b/src/locale/en_GB.ts @@ -17,6 +17,11 @@ const locale: Locale = { monthSelect: 'Choose a month', yearSelect: 'Choose a year', decadeSelect: 'Choose a decade', + hourSelect: 'Select an hour', + minuteSelect: 'Select a minute', + secondSelect: 'Select a second', + millisecondSelect: 'Select a millisecond', + meridiemSelect: 'Select a meridiem', previousMonth: 'Previous month', nextMonth: 'Next month', diff --git a/src/locale/en_US.ts b/src/locale/en_US.ts index 583591d68..a35d1a7f8 100644 --- a/src/locale/en_US.ts +++ b/src/locale/en_US.ts @@ -18,6 +18,11 @@ const locale: Locale = { monthSelect: 'Choose a month', yearSelect: 'Choose a year', decadeSelect: 'Choose a decade', + hourSelect: 'Select an hour', + minuteSelect: 'Select a minute', + secondSelect: 'Select a second', + millisecondSelect: 'Select a millisecond', + meridiemSelect: 'Select a meridiem', previousMonth: 'Previous month', nextMonth: 'Next month', diff --git a/src/locale/zh_CN.ts b/src/locale/zh_CN.ts index d489be71d..b37486d6b 100644 --- a/src/locale/zh_CN.ts +++ b/src/locale/zh_CN.ts @@ -20,6 +20,11 @@ const locale: Locale = { monthSelect: '选择月份', yearSelect: '选择年份', decadeSelect: '选择年代', + hourSelect: '选择时', + minuteSelect: '选择分', + secondSelect: '选择秒', + millisecondSelect: '选择毫秒', + meridiemSelect: '选择上午/下午', previousYear: '上一年', nextYear: '下一年', diff --git a/src/locale/zh_TW.ts b/src/locale/zh_TW.ts index 2aef5c28b..d7a5b787e 100644 --- a/src/locale/zh_TW.ts +++ b/src/locale/zh_TW.ts @@ -21,6 +21,12 @@ const locale: Locale = { monthSelect: '選擇月份', yearSelect: '選擇年份', decadeSelect: '選擇年代', + hourSelect: '選擇時', + minuteSelect: '選擇分', + secondSelect: '選擇秒', + millisecondSelect: '選擇毫秒', + meridiemSelect: '選擇上午/下午', + yearFormat: 'YYYY年', previousYear: '上一年', diff --git a/tests/__snapshots__/panel.spec.tsx.snap b/tests/__snapshots__/panel.spec.tsx.snap index 7fda69091..94fc0f680 100644 --- a/tests/__snapshots__/panel.spec.tsx.snap +++ b/tests/__snapshots__/panel.spec.tsx.snap @@ -1286,12 +1286,19 @@ exports[`Picker.Panel append cell with cellRender in time 1`] = ` class="rc-picker-content" >
          • Date: Tue, 25 Aug 2026 13:11:10 +0300 Subject: [PATCH 2/2] Add tests --- tests/time.spec.tsx | 238 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 236 insertions(+), 2 deletions(-) diff --git a/tests/time.spec.tsx b/tests/time.spec.tsx index d8418a945..95654b510 100644 --- a/tests/time.spec.tsx +++ b/tests/time.spec.tsx @@ -1,8 +1,8 @@ -import { fireEvent, render } from '@testing-library/react'; +import { createEvent, fireEvent, render } from '@testing-library/react'; import { resetWarned } from '@rc-component/util'; import React from 'react'; import dayjs from 'dayjs'; -import { DayPicker, getDay, openPicker, selectCell } from './util/commonUtil'; +import { DayPicker, DayPickerPanel, getDay, openPicker, selectCell } from './util/commonUtil'; describe('Picker.Time', () => { beforeEach(() => { @@ -114,4 +114,238 @@ describe('Picker.Time', () => { fireEvent.mouseEnter(getColCell(4, 1)); expect(container.querySelector('input')).toHaveValue('1990-09-03 01:02:03.000 AM'); }); + + describe('TimeColumn focus', () => { + const getColumn = (index = 0) => + document.querySelectorAll('.rc-picker-time-panel-column')[index]; + + const getCell = (value: number | string, columnIndex = 0) => + getColumn(columnIndex).querySelector(`li[data-value="${value}"]`); + + /** The single cell of a column that is reachable with `Tab` (roving tabindex) */ + const getTabbable = (columnIndex = 0) => + getColumn(columnIndex).querySelector('li[tabindex="0"]'); + + const keyDown = (cell: HTMLElement, key: string) => { + const event = createEvent.keyDown(cell, { key }); + fireEvent(cell, event); + + return event; + }; + + describe('roving tabindex', () => { + it('only the selected cell of each column is tabbable', () => { + render(); + + [5, 8, 9].forEach((unit, columnIndex) => { + expect(getColumn(columnIndex).querySelectorAll('li[tabindex="0"]')).toHaveLength(1); + expect(getTabbable(columnIndex)).toBe(getCell(unit, columnIndex)); + }); + }); + + it('falls back to the picker value when nothing is selected', () => { + render(); + + // `now` is 00:00:00, so the cursor sits on `0` even though no cell is selected + expect(getTabbable()).toBe(getCell(0)); + expect(document.querySelector('.rc-picker-time-panel-cell-selected')).toBeFalsy(); + }); + }); + + describe('onCellKeyDown', () => { + it('ArrowDown moves the cursor to the next cell', () => { + render(); + + keyDown(getCell(5), 'ArrowDown'); + + expect(getTabbable()).toBe(getCell(6)); + expect(getCell(6)).toHaveFocus(); + expect(getCell(5)).toHaveAttribute('tabindex', '-1'); + }); + + it('ArrowUp moves the cursor to the previous cell', () => { + render(); + + keyDown(getCell(5), 'ArrowUp'); + + expect(getTabbable()).toBe(getCell(4)); + expect(getCell(4)).toHaveFocus(); + }); + + it('moving the cursor does not change the selected value', () => { + const onChange = jest.fn(); + render( + , + ); + + keyDown(getCell(5), 'ArrowDown'); + + expect(onChange).not.toHaveBeenCalled(); + expect(getCell(5)).toHaveClass('rc-picker-time-panel-cell-selected'); + expect(getCell(6)).not.toHaveClass('rc-picker-time-panel-cell-selected'); + }); + + it('ArrowDown wraps from the last cell to the first', () => { + render(); + + keyDown(getCell(23), 'ArrowDown'); + + expect(getTabbable()).toBe(getCell(0)); + expect(getCell(0)).toHaveFocus(); + }); + + it('ArrowUp wraps from the first cell to the last', () => { + render(); + + keyDown(getCell(0), 'ArrowUp'); + + expect(getTabbable()).toBe(getCell(23)); + expect(getCell(23)).toHaveFocus(); + }); + + it('skips disabled cells', () => { + render( + ({ disabledHours: () => [4, 6, 7] })} + />, + ); + + expect(getCell(6)).toHaveAttribute('aria-disabled', 'true'); + + keyDown(getCell(5), 'ArrowDown'); + expect(getTabbable()).toBe(getCell(8)); + expect(getCell(8)).toHaveFocus(); + + keyDown(getCell(8), 'ArrowUp'); + expect(getTabbable()).toBe(getCell(5)); + + keyDown(getCell(5), 'ArrowUp'); + expect(getTabbable()).toBe(getCell(3)); + }); + + it.each([ + ['Enter', 'Enter'], + ['Space', ' '], + ])('%s selects the cell under the cursor', (_, key) => { + const onChange = jest.fn(); + render( + , + ); + + keyDown(getCell(5), 'ArrowDown'); + keyDown(getCell(6), key); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].format('HH:mm:ss')).toEqual('06:00:00'); + expect(getCell(6)).toHaveClass('rc-picker-time-panel-cell-selected'); + }); + + it('ignores keys it does not handle', () => { + const onChange = jest.fn(); + render( + , + ); + + keyDown(getCell(5), 'ArrowDown'); + keyDown(getCell(6), 'a'); + + expect(onChange).not.toHaveBeenCalled(); + expect(getTabbable()).toBe(getCell(6)); + }); + + it.each(['ArrowDown', 'ArrowUp', 'Enter', ' '])( + 'prevents the default behavior of `%s`', + (key) => { + render(); + + expect(keyDown(getCell(5), key).defaultPrevented).toBeTruthy(); + }, + ); + + it('does not prevent the default behavior of other keys', () => { + render(); + + expect(keyDown(getCell(5), 'Tab').defaultPrevented).toBeFalsy(); + }); + }); + + describe('onBlur', () => { + it('resets the cursor when focus leaves the column', () => { + render(); + + keyDown(getCell(5), 'ArrowDown'); + expect(getTabbable()).toBe(getCell(6)); + + fireEvent.blur(getCell(6), { relatedTarget: document.body }); + + expect(getTabbable()).toBe(getCell(5)); + }); + + it('resets the cursor when focus is lost entirely', () => { + render(); + + keyDown(getCell(5), 'ArrowDown'); + fireEvent.blur(getCell(6), { relatedTarget: null }); + + expect(getTabbable()).toBe(getCell(5)); + }); + + it('resets the cursor when focus moves to another column', () => { + render(); + + keyDown(getCell(5), 'ArrowDown'); + fireEvent.blur(getCell(6), { relatedTarget: getCell(8, 1) }); + + expect(getTabbable()).toBe(getCell(5)); + expect(getTabbable(1)).toBe(getCell(8, 1)); + }); + + it('keeps the cursor when focus moves within the same column', () => { + render(); + + keyDown(getCell(5), 'ArrowDown'); + // This is what arrow navigation itself does: focus moves from cell to cell + fireEvent.blur(getCell(6), { relatedTarget: getCell(7) }); + + expect(getTabbable()).toBe(getCell(6)); + }); + }); + + describe('cursor reset', () => { + it('follows the value when a cell is clicked', () => { + render(); + + keyDown(getCell(5), 'ArrowDown'); + expect(getTabbable()).toBe(getCell(6)); + + fireEvent.click(getCell(10)); + + expect(getTabbable()).toBe(getCell(10)); + expect(getCell(10)).toHaveClass('rc-picker-time-panel-cell-selected'); + }); + + it('does not steal focus when the value changes', () => { + render(); + + fireEvent.click(getCell(10)); + + expect(getCell(10)).not.toHaveFocus(); + expect(document.body).toHaveFocus(); + }); + }); + }); });