diff --git a/src/PickerInput/Popup/PresetPanel.tsx b/src/PickerInput/Popup/PresetPanel.tsx index d145cdcc5..416d1c9d2 100644 --- a/src/PickerInput/Popup/PresetPanel.tsx +++ b/src/PickerInput/Popup/PresetPanel.tsx @@ -23,21 +23,23 @@ export default function PresetPanel( return (
-
diff --git a/src/PickerPanel/DecadePanel/index.tsx b/src/PickerPanel/DecadePanel/index.tsx index 748015d9f..390736cc7 100644 --- a/src/PickerPanel/DecadePanel/index.tsx +++ b/src/PickerPanel/DecadePanel/index.tsx @@ -61,6 +61,13 @@ export default function DecadePanel( }; }; + const getCellAttributes = (date: DateType): React.TdHTMLAttributes => { + if (isSameDecade(generateConfig, date, generateConfig.getNow())) { + return { 'aria-current': 'date' }; + } + return {}; + }; + // ======================== Disabled ======================== const mergedDisabledDate: DisabledDate = disabledDate ? (currentDate, disabledInfo) => { @@ -81,7 +88,7 @@ export default function DecadePanel( : null; // ========================= Header ========================= - const yearNode = `${formatValue(startYearDate, { + const yearLabel = `${formatValue(startYearDate, { locale, format: locale.yearFormat, generateConfig, @@ -102,8 +109,9 @@ export default function DecadePanel( // Limitation getStart={getStartYear} getEnd={getEndYear} + labels={{ superPrev: locale.previousCentury, superNext: locale.nextCentury }} > - {yearNode} + {yearLabel} {/* Body */} @@ -113,10 +121,12 @@ export default function DecadePanel( colNum={3} rowNum={4} baseDate={baseDate} + tableLabel={yearLabel} // Body getCellDate={getCellDate} getCellText={getCellText} getCellClassName={getCellClassName} + getCellAttributes={getCellAttributes} /> diff --git a/src/PickerPanel/MonthPanel/index.tsx b/src/PickerPanel/MonthPanel/index.tsx index cfd22079f..d84517275 100644 --- a/src/PickerPanel/MonthPanel/index.tsx +++ b/src/PickerPanel/MonthPanel/index.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import type { DisabledDate, SharedPanelProps } from '../../interface'; -import { formatValue } from '../../utils/dateUtil'; +import { formatValue, isSameMonth } from '../../utils/dateUtil'; import { PanelContext, useInfo } from '../context'; import PanelBody from '../PanelBody'; import PanelHeader from '../PanelHeader'; @@ -23,6 +23,11 @@ export default function MonthPanel( // ========================== Base ========================== const [info] = useInfo(props, 'month'); const baseDate = generateConfig.setMonth(pickerValue, 0); + const yearLabel = formatValue(pickerValue, { + locale, + format: locale.yearFormat, + generateConfig, + }); // ========================= Month ========================== const monthsLocale: string[] = @@ -52,6 +57,13 @@ export default function MonthPanel( [`${prefixCls}-cell-in-view`]: true, }); + const getCellAttributes = (date: DateType): React.TdHTMLAttributes => { + if (isSameMonth(generateConfig, date, generateConfig.getNow())) { + return { 'aria-current': 'date' }; + } + return {}; + }; + // ======================== Disabled ======================== const mergedDisabledDate: DisabledDate = disabledDate ? (currentDate, disabledInfo) => { @@ -75,14 +87,9 @@ export default function MonthPanel( onClick={() => { onModeChange('year'); }} - tabIndex={-1} className={`${prefixCls}-year-btn`} > - {formatValue(pickerValue, { - locale, - format: locale.yearFormat, - generateConfig, - })} + {yearLabel} ); @@ -97,6 +104,7 @@ export default function MonthPanel( // Limitation getStart={(date) => generateConfig.setMonth(date, 0)} getEnd={(date) => generateConfig.setMonth(date, 11)} + labels={{ superPrev: locale.previousYear, superNext: locale.nextYear }} > {yearNode} @@ -109,10 +117,12 @@ export default function MonthPanel( colNum={3} rowNum={4} baseDate={baseDate} + tableLabel={yearLabel} // Body getCellDate={getCellDate} getCellText={getCellText} getCellClassName={getCellClassName} + getCellAttributes={getCellAttributes} /> diff --git a/src/PickerPanel/PanelBody.tsx b/src/PickerPanel/PanelBody.tsx index 34b6fe011..739a6dd9c 100644 --- a/src/PickerPanel/PanelBody.tsx +++ b/src/PickerPanel/PanelBody.tsx @@ -1,8 +1,8 @@ import { clsx } from 'clsx'; import * as React from 'react'; import type { DisabledDate } from '../interface'; -import { formatValue, isInRange, isSame } from '../utils/dateUtil'; -import { PickerHackContext, usePanelContext } from './context'; +import { formatValue, isInRange, isSame, isSameDate } from '../utils/dateUtil'; +import { PanelFocusContext, PickerHackContext, usePanelContext } from './context'; export interface PanelBodyProps { rowNum: number; @@ -15,6 +15,7 @@ export interface PanelBodyProps { getCellDate: (date: DateType, offset: number) => DateType; getCellText: (date: DateType) => React.ReactNode; getCellClassName: (date: DateType) => Record; + getCellAttributes: (date: DateType) => React.TdHTMLAttributes; disabledDate?: DisabledDate; @@ -25,6 +26,7 @@ export interface PanelBodyProps { prefixColumn?: (date: DateType) => React.ReactNode; rowClassName?: (date: DateType) => string; cellSelection?: boolean; + tableLabel?: string; } export default function PanelBody(props: PanelBodyProps) { @@ -38,9 +40,11 @@ export default function PanelBody(props: PanelBod titleFormat, getCellText, getCellClassName, + getCellAttributes, headerCells, cellSelection = true, disabledDate, + tableLabel, } = props; const { @@ -66,6 +70,38 @@ export default function PanelBody(props: PanelBod // ============================= Context ============================== const { onCellDblClick } = React.useContext(PickerHackContext); + const { focusedDate, onCellFocusedDateChange, focusTrigger } = + React.useContext(PanelFocusContext); + + // ============================== Focus =============================== + // Roving tabindex: the active cell carries `tabIndex=0`. We move DOM focus to + // it imperatively after arrow-key navigation, and when a mode change requests + // focus via `focusTrigger`. The active cell registers its node via a callback + // ref, so the effect never needs to query the DOM — and it stays correct even + // when navigation re-bases the grid to a new month and the active date lands + // on the same cell position. + const focusedCellNodeRef = React.useRef(null); + const pendingFocusRef = React.useRef(false); + + // A mode change bumps `focusTrigger` (after this panel has mounted). Detect it + // during render so focus is requested deterministically, regardless of how the + // mount and the trigger update interleave. + const lastFocusTriggerRef = React.useRef(focusTrigger); + if (focusTrigger !== lastFocusTriggerRef.current) { + lastFocusTriggerRef.current = focusTrigger; + pendingFocusRef.current = true; + } + + const registerFocusedCell = React.useCallback((node: HTMLElement | null) => { + focusedCellNodeRef.current = node; + }, []); + + React.useEffect(() => { + if (pendingFocusRef.current) { + pendingFocusRef.current = false; + focusedCellNodeRef.current?.focus(); + } + }, [focusedDate, focusTrigger]); // ============================== Value =============================== const matchValues = (date: DateType) => @@ -118,13 +154,33 @@ export default function PanelBody(props: PanelBod }) : undefined; + // Week panel uses isSameWeek which matches all 7 days in a row — use isSameDate + // so only the specific navigated cell gets tabIndex=0. + const isFocused = + !!focusedDate && + (type === 'week' + ? isSameDate(generateConfig, currentDate, focusedDate) + : isSame(generateConfig, locale, currentDate, focusedDate, type)); + const isSelected = + !hoverRangeValue && + // WeekPicker use row instead + type !== 'week' && + matchValues(currentDate); + // Render const inner =
{getCellText(currentDate)}
; rowNode.push( @@ -133,11 +189,7 @@ export default function PanelBody(props: PanelBod [`${cellPrefixCls}-in-range`]: inRange && !rangeStart && !rangeEnd, [`${cellPrefixCls}-range-start`]: rangeStart, [`${cellPrefixCls}-range-end`]: rangeEnd, - [`${prefixCls}-cell-selected`]: - !hoverRangeValue && - // WeekPicker use row instead - type !== 'week' && - matchValues(currentDate), + [`${prefixCls}-cell-selected`]: isSelected, ...getCellClassName(currentDate), })} style={styles.item} @@ -161,6 +213,37 @@ export default function PanelBody(props: PanelBod onHover?.(null); } }} + onKeyDown={(e) => { + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + pendingFocusRef.current = true; + onCellFocusedDateChange(getCellDate(currentDate, -1)); + break; + case 'ArrowRight': + e.preventDefault(); + pendingFocusRef.current = true; + onCellFocusedDateChange(getCellDate(currentDate, 1)); + break; + case 'ArrowUp': + e.preventDefault(); + pendingFocusRef.current = true; + onCellFocusedDateChange(getCellDate(currentDate, -colNum)); + break; + case 'ArrowDown': + e.preventDefault(); + pendingFocusRef.current = true; + onCellFocusedDateChange(getCellDate(currentDate, colNum)); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (!disabled) { + onSelect(currentDate); + } + break; + } + }} > {cellRender ? cellRender(currentDate, { @@ -176,7 +259,7 @@ export default function PanelBody(props: PanelBod } rows.push( - + {rowNode} , ); @@ -185,9 +268,14 @@ export default function PanelBody(props: PanelBod // ============================== Render ============================== return (
- +
{headerCells && ( - + {headerCells} )} diff --git a/src/PickerPanel/PanelHeader.tsx b/src/PickerPanel/PanelHeader.tsx index 407489a59..f94919619 100644 --- a/src/PickerPanel/PanelHeader.tsx +++ b/src/PickerPanel/PanelHeader.tsx @@ -7,6 +7,13 @@ const HIDDEN_STYLE: React.CSSProperties = { visibility: 'hidden', }; +interface PanelHeaderLabels { + superPrev?: string; + prev?: string; + next?: string; + superNext?: string; +} + export interface HeaderProps { offset?: (distance: number, date: DateType) => DateType; superOffset?: (distance: number, date: DateType) => DateType; @@ -16,6 +23,8 @@ export interface HeaderProps { getStart?: (date: DateType) => DateType; getEnd?: (date: DateType) => DateType; + labels?: PanelHeaderLabels; + children?: React.ReactNode; } @@ -28,6 +37,8 @@ function PanelHeader(props: HeaderProps) { getStart, getEnd, + labels, + children, } = props; @@ -124,9 +135,8 @@ function PanelHeader(props: HeaderProps) { {superOffset && ( ); @@ -65,6 +72,7 @@ export default function QuarterPanel( // Limitation getStart={(date) => generateConfig.setMonth(date, 0)} getEnd={(date) => generateConfig.setMonth(date, 11)} + labels={{ superPrev: locale.previousYear, superNext: locale.nextYear }} > {yearNode} @@ -76,10 +84,12 @@ export default function QuarterPanel( colNum={4} rowNum={1} baseDate={baseDate} + tableLabel={yearLabel} // Body getCellDate={getCellDate} getCellText={getCellText} getCellClassName={getCellClassName} + getCellAttributes={getCellAttributes} /> diff --git a/src/PickerPanel/YearPanel/index.tsx b/src/PickerPanel/YearPanel/index.tsx index ae7e42811..9b242745a 100644 --- a/src/PickerPanel/YearPanel/index.tsx +++ b/src/PickerPanel/YearPanel/index.tsx @@ -35,6 +35,17 @@ export default function YearPanel( const endYearDate = getEndYear(pickerValue); const baseDate = generateConfig.addYear(startYearDate, -1); + const startYearLabel = formatValue(startYearDate, { + locale, + format: locale.yearFormat, + generateConfig, + }); + const endYearLabel = formatValue(endYearDate, { + locale, + format: locale.yearFormat, + generateConfig, + }); + const decadeLabel = `${startYearLabel}-${endYearLabel}`; // ========================= Cells ========================== const getCellDate = (date: DateType, offset: number) => { @@ -58,6 +69,13 @@ export default function YearPanel( }; }; + const getCellAttributes = (date: DateType): React.TdHTMLAttributes => { + if (isSameYear(generateConfig, date, generateConfig.getNow())) { + return { 'aria-current': 'date' }; + } + return {}; + }; + // ======================== Disabled ======================== const mergedDisabledDate: DisabledDate = disabledDate ? (currentDate, disabledInfo) => { @@ -81,20 +99,9 @@ export default function YearPanel( onClick={() => { onModeChange('decade'); }} - tabIndex={-1} className={`${prefixCls}-decade-btn`} > - {formatValue(startYearDate, { - locale, - format: locale.yearFormat, - generateConfig, - })} - - - {formatValue(endYearDate, { - locale, - format: locale.yearFormat, - generateConfig, - })} + {decadeLabel} ); @@ -109,6 +116,7 @@ export default function YearPanel( // Limitation getStart={getStartYear} getEnd={getEndYear} + labels={{ superPrev: locale.previousDecade, superNext: locale.nextDecade }} > {yearNode} @@ -121,10 +129,12 @@ export default function YearPanel( colNum={3} rowNum={4} baseDate={baseDate} + tableLabel={decadeLabel} // Body getCellDate={getCellDate} getCellText={getCellText} getCellClassName={getCellClassName} + getCellAttributes={getCellAttributes} /> diff --git a/src/PickerPanel/context.ts b/src/PickerPanel/context.ts index fc2a40d1d..63e0efd5f 100644 --- a/src/PickerPanel/context.ts +++ b/src/PickerPanel/context.ts @@ -131,6 +131,20 @@ export interface PickerHackContextProps { */ export const PickerHackContext = React.createContext({}); +// ============================== Focus ============================== +export interface PanelFocusContextProps { + focusedDate: DateType | null; + onCellFocusedDateChange: (date: DateType) => void; + /** Increments whenever the panel mode changes, telling PanelBody to focus its active cell */ + focusTrigger: number; +} + +export const PanelFocusContext = React.createContext({ + focusedDate: null, + onCellFocusedDateChange: () => {}, + focusTrigger: 0, +}); + if (process.env.NODE_ENV !== 'production') { PickerHackContext.displayName = 'PickerHackContext'; } diff --git a/src/PickerPanel/index.tsx b/src/PickerPanel/index.tsx index 51e75516e..698fc973b 100644 --- a/src/PickerPanel/index.tsx +++ b/src/PickerPanel/index.tsx @@ -20,7 +20,7 @@ import PickerContext from '../PickerInput/context'; import useCellRender from '../PickerInput/hooks/useCellRender'; import { isSame } from '../utils/dateUtil'; import { pickProps, toArray } from '../utils/miscUtil'; -import { PickerHackContext, SharedPanelContext } from './context'; +import { PanelFocusContext, PickerHackContext, SharedPanelContext } from './context'; import DatePanel from './DatePanel'; import DateTimePanel from './DateTimePanel'; import DecadePanel from './DecadePanel'; @@ -192,12 +192,19 @@ function PickerPanel( } = props; // ======================== Context ======================== + const pickerContext = React.useContext(PickerContext); const { prefixCls: contextPrefixCls, classNames: pickerClassNames, styles: pickerStyles, } = React.useContext(PickerContext) || {}; + // When rendered inside a picker popup, the surrounding picker owns + // mode-change focus (see `usePopupFocus`), which correctly distinguishes a + // drill-down from a field switch. Only the standalone `` drives + // focus on mode change itself. + const standalone = !pickerContext; + // ======================== prefixCls ======================== const mergedPrefixCls = contextPrefixCls || prefixCls || 'rc-picker'; @@ -290,6 +297,50 @@ function PickerPanel( } }, [mergedValue[0]]); + // ========================= Focus ========================== + const [focusedCellDate, setFocusedCellDate] = React.useState( + () => mergedValue[0] || mergedPickerValue || now, + ); + + React.useEffect(() => { + if (mergedValue[0]) { + setFocusedCellDate(mergedValue[0]); + } + }, [mergedValue[0]]); + + const onCellFocusedDateChange = useEvent((date: DateType) => { + setFocusedCellDate(date); + setInternalPickerValue(date); + onPickerValueChange?.(date); + }); + + // Bump this counter to tell PanelBody to programmatically focus its active cell. + const [focusTrigger, setFocusTrigger] = React.useState(0); + + const prevModeRef = React.useRef(null); + React.useLayoutEffect(() => { + if (prevModeRef.current !== null && prevModeRef.current !== mergedMode) { + // Sync the focused cell to the new panel's coordinate system so the + // correct cell gets tabIndex=0. + setFocusedCellDate(mergedPickerValue); + + // Grab DOM focus only when standalone. Inside a picker popup the + // surrounding picker owns focus (see `usePopupFocus`), which correctly + // tells a drill-down apart from a field switch; bumping here would steal + // focus into the panel when the user merely switches fields. + if (standalone) { + setFocusTrigger((t) => t + 1); + } + } + prevModeRef.current = mergedMode; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mergedMode]); + + const panelFocusContext = React.useMemo( + () => ({ focusedDate: focusedCellDate, onCellFocusedDateChange, focusTrigger }), + [focusedCellDate, onCellFocusedDateChange, focusTrigger], + ); + // Both trigger when manually pickerValue or mode change const triggerPanelChange = (viewDate?: DateType, nextMode?: PanelMode) => { onPanelChange?.(viewDate || pickerValue, nextMode || mergedMode); @@ -423,36 +474,39 @@ function PickerPanel( return ( -
- { - setPickerValue(nextPickerValue, true); - }} - value={mergedValue[0]} - onSelect={onPanelValueSelect} - values={mergedValue} - // Render - cellRender={onInternalCellRender} - // Hover - hoverRangeValue={hoverRangeDate} - hoverValue={hoverValue} - /> -
+ +
+ { + setPickerValue(nextPickerValue, true); + setFocusedCellDate(nextPickerValue); + }} + value={mergedValue[0]} + onSelect={onPanelValueSelect} + values={mergedValue} + // Render + cellRender={onInternalCellRender} + // Hover + hoverRangeValue={hoverRangeDate} + hoverValue={hoverValue} + /> +
+
); diff --git a/tests/__snapshots__/panel.spec.tsx.snap b/tests/__snapshots__/panel.spec.tsx.snap index 7fda69091..d51967311 100644 --- a/tests/__snapshots__/panel.spec.tsx.snap +++ b/tests/__snapshots__/panel.spec.tsx.snap @@ -15,7 +15,6 @@ exports[`Picker.Panel append cell with cellRender in date 1`] = `
- + - + - + - + - + - + - +