From dcdbc60a244b3ba527b7e444c4030c4eb92b1e5a Mon Sep 17 00:00:00 2001 From: Divyesh Agrawal Date: Fri, 3 Apr 2026 08:27:28 +0530 Subject: [PATCH 1/2] [Feat]: Add Accessibility Support --- docs/examples/basic.tsx | 2 +- src/PickerInput/SinglePicker.tsx | 20 +++++- src/PickerPanel/DatePanel/index.tsx | 1 + src/PickerPanel/PanelBody.tsx | 105 +++++++++++++++++++++++++++- src/PickerPanel/PanelHeader.tsx | 8 +-- src/PickerPanel/index.tsx | 3 +- src/PickerTrigger/index.tsx | 55 ++++++++++----- 7 files changed, 166 insertions(+), 28 deletions(-) diff --git a/docs/examples/basic.tsx b/docs/examples/basic.tsx index 8c6e1ed68..d6815fe6b 100644 --- a/docs/examples/basic.tsx +++ b/docs/examples/basic.tsx @@ -63,7 +63,7 @@ export default () => { container: 'popup-c', }, }} - open + open={false} styles={{ popup: { container: { diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index df2a86a81..64fbf9d85 100644 --- a/src/PickerInput/SinglePicker.tsx +++ b/src/PickerInput/SinglePicker.tsx @@ -16,7 +16,7 @@ import type { SharedTimeProps, ValueDate, } from '../interface'; -import PickerTrigger from '../PickerTrigger'; +import PickerTrigger, { RefTriggerProps } from '../PickerTrigger'; import { pickTriggerProps } from '../PickerTrigger/util'; import { toArray } from '../utils/miscUtil'; import PickerContext from './context'; @@ -195,6 +195,7 @@ function Picker( // ========================= Refs ========================= const selectorRef = usePickerRef(ref); + const triggerRef = React.useRef(null); // ========================= Util ========================= function pickerParam(values: T | T[]) { @@ -579,8 +580,22 @@ function Picker( }; const onSelectorKeyDown: SelectorProps['onKeyDown'] = (event, preventDefault) => { - if (event.key === 'Tab') { + if (event.key === 'Enter') { + triggerOpen(true); + + return; + } + + if (event.key === 'Esc') { triggerConfirm(); + + return; + } + + if (event.key === 'Tab') { + if (mergedOpen) { + // event.preventDefault(); + } } onKeyDown?.(event, preventDefault); @@ -645,6 +660,7 @@ function Picker( // Visible visible={mergedOpen} onClose={onPopupClose} + ref={triggerRef} > (props: DatePane getCellClassName={getCellClassName} prefixColumn={prefixColumn} cellSelection={!isWeek} + onChange={onPickerValueChange} /> diff --git a/src/PickerPanel/PanelBody.tsx b/src/PickerPanel/PanelBody.tsx index 34b6fe011..07d2cf6ea 100644 --- a/src/PickerPanel/PanelBody.tsx +++ b/src/PickerPanel/PanelBody.tsx @@ -1,8 +1,9 @@ import { clsx } from 'clsx'; import * as React from 'react'; import type { DisabledDate } from '../interface'; -import { formatValue, isInRange, isSame } from '../utils/dateUtil'; +import { formatValue, isInRange, isSame, isSameMonth } from '../utils/dateUtil'; import { PickerHackContext, usePanelContext } from './context'; +import { offsetPanelDate } from '@/PickerInput/hooks/useRangePickerValue'; export interface PanelBodyProps { rowNum: number; @@ -25,6 +26,7 @@ export interface PanelBodyProps { prefixColumn?: (date: DateType) => React.ReactNode; rowClassName?: (date: DateType) => string; cellSelection?: boolean; + onChange?: (date: DateType) => void; } export default function PanelBody(props: PanelBodyProps) { @@ -41,6 +43,7 @@ export default function PanelBody(props: PanelBod headerCells, cellSelection = true, disabledDate, + onChange, } = props; const { @@ -64,6 +67,10 @@ export default function PanelBody(props: PanelBod const cellPrefixCls = `${prefixCls}-cell`; + const [focusDateTime, setFocusDateTime] = React.useState(values?.[values.length - 1] ?? now); + + const cellRefs = React.useRef>({}); + // ============================= Context ============================== const { onCellDblClick } = React.useContext(PickerHackContext); @@ -73,6 +80,81 @@ export default function PanelBody(props: PanelBod (singleValue) => singleValue && isSame(generateConfig, locale, date, singleValue, type), ); + // ============================== Event Handlers =============================== + + const moveFocus = (offset: number) => { + const nextDate = generateConfig.addDate(focusDateTime, offset); + setFocusDateTime(nextDate); + + const focusElement = + cellRefs.current[ + formatValue(nextDate, { + locale, + format: 'YYYY-MM-DD', + generateConfig, + }) + ]; + if (focusElement) { + requestAnimationFrame(() => { + focusElement.focus(); + }); + } + + if (type && !isSame(generateConfig, locale, focusDateTime, nextDate, type)) { + return onChange?.(nextDate); + } + }; + + const onKeyDown = React.useCallback( + (event) => { + switch (event.key) { + case 'ArrowRight': + moveFocus(1); + break; + case 'ArrowLeft': + moveFocus(-1); + break; + case 'ArrowDown': + moveFocus(7); + break; + case 'ArrowUp': + moveFocus(-7); + break; + case 'Enter': + onSelect(focusDateTime); + break; + + case 'Esc': + break; + + case 'Tab': + onChange?.(focusDateTime); + + default: + return; + } + + event.preventDefault(); + }, + [focusDateTime, generateConfig, onSelect], + ); + + React.useEffect(() => { + const focusElement = + cellRefs.current[ + formatValue(focusDateTime, { + locale, + format: 'YYYY-MM-DD', + generateConfig, + }) + ]; + if (focusElement) { + requestAnimationFrame(() => { + focusElement.focus(); + }); + } + }, []); + // =============================== Body =============================== const rows: React.ReactNode[] = []; @@ -118,8 +200,27 @@ export default function PanelBody(props: PanelBod }) : undefined; + const isCurrentDateFocused = isSame(generateConfig, locale, currentDate, focusDateTime, type); + // Render - const inner =
{getCellText(currentDate)}
; + const inner = ( +
{ + cellRefs.current[ + formatValue(currentDate, { + locale, + format: 'YYYY-MM-DD', + generateConfig, + }) + ] = element; + }} + > + {getCellText(currentDate)} +
+ ); rowNode.push( (props: HeaderProps) { type="button" aria-label={locale.previousYear} onClick={() => onSuperOffset(-1)} - tabIndex={-1} + tabIndex={0} className={clsx( superPrevBtnCls, disabledSuperOffsetPrev && `${superPrevBtnCls}-disabled`, @@ -142,7 +142,7 @@ function PanelHeader(props: HeaderProps) { type="button" aria-label={locale.previousMonth} onClick={() => onOffset(-1)} - tabIndex={-1} + tabIndex={0} className={clsx(prevBtnCls, disabledOffsetPrev && `${prevBtnCls}-disabled`)} disabled={disabledOffsetPrev} style={hidePrev ? HIDDEN_STYLE : {}} @@ -156,7 +156,7 @@ function PanelHeader(props: HeaderProps) { type="button" aria-label={locale.nextMonth} onClick={() => onOffset(1)} - tabIndex={-1} + tabIndex={0} className={clsx(nextBtnCls, disabledOffsetNext && `${nextBtnCls}-disabled`)} disabled={disabledOffsetNext} style={hideNext ? HIDDEN_STYLE : {}} @@ -169,7 +169,7 @@ function PanelHeader(props: HeaderProps) { type="button" aria-label={locale.nextYear} onClick={() => onSuperOffset(1)} - tabIndex={-1} + tabIndex={0} className={clsx( superNextBtnCls, disabledSuperOffsetNext && `${superNextBtnCls}-disabled`, diff --git a/src/PickerPanel/index.tsx b/src/PickerPanel/index.tsx index 1e44948ed..edac08440 100644 --- a/src/PickerPanel/index.tsx +++ b/src/PickerPanel/index.tsx @@ -423,8 +423,9 @@ function PickerPanel(
void; }; -function PickerTrigger({ - popupElement, - popupStyle, - popupClassName, - popupAlign, - transitionName, - getPopupContainer, - children, - range, - placement, - builtinPlacements = BUILT_IN_PLACEMENTS, - direction, +export type RefTriggerProps = { getPopupElement: () => HTMLDivElement | undefined }; + +function PickerTrigger(props: PickerTriggerProps, ref: React.ForwardedRef) { + const { + popupElement, + popupStyle, + popupClassName, + popupAlign, + transitionName, + getPopupContainer, + children, + range, + placement, + builtinPlacements = BUILT_IN_PLACEMENTS, + direction, + + // Visible + visible, + onClose, + } = props; - // Visible - visible, - onClose, -}: PickerTriggerProps) { const { prefixCls } = React.useContext(PickerContext); const dropdownPrefixCls = `${prefixCls}-dropdown`; const realPlacement = getRealPlacement(placement, direction === 'rtl'); + // ======================= Ref ======================= + const triggerPopupRef = React.useRef(null); + + React.useImperativeHandle(ref, () => ({ + getPopupElement: () => triggerPopupRef.current?.popupElement, + })); + + console.log('visible', visible); + + useLockFocus(visible, () => triggerPopupRef.current?.popupElement ?? null); + return ( (PickerTrigger); + +export default RefPickerTrigger; From 1b0852b38ae7dc3f4056d408965f2e1fd715dbe4 Mon Sep 17 00:00:00 2001 From: Divyesh Agrawal Date: Fri, 10 Apr 2026 06:41:50 +0530 Subject: [PATCH 2/2] [Feat]: Fix issues in the focus delegation and update props --- assets/index.less | 4 ++ src/PickerInput/Popup/index.tsx | 3 ++ src/PickerInput/RangePicker.tsx | 11 ++++ src/PickerInput/SinglePicker.tsx | 26 +++++----- src/PickerPanel/DecadePanel/index.tsx | 1 + src/PickerPanel/MonthPanel/index.tsx | 1 + src/PickerPanel/PanelBody.tsx | 72 +++++++++----------------- src/PickerPanel/QuarterPanel/index.tsx | 1 + src/PickerPanel/YearPanel/index.tsx | 1 + 9 files changed, 59 insertions(+), 61 deletions(-) diff --git a/assets/index.less b/assets/index.less index d5db8413b..739162b4a 100644 --- a/assets/index.less +++ b/assets/index.less @@ -122,6 +122,10 @@ &:hover { background: fade(blue, 30%); } + + &:focus { + border: 1px solid blue; + } } &-in-view { diff --git a/src/PickerInput/Popup/index.tsx b/src/PickerInput/Popup/index.tsx index f02810390..4264985c0 100644 --- a/src/PickerInput/Popup/index.tsx +++ b/src/PickerInput/Popup/index.tsx @@ -45,6 +45,7 @@ export interface PopupProps; + onPanelKeyDown?: React.KeyboardEventHandler; classNames?: SharedPickerProps['classNames']; styles?: SharedPickerProps['styles']; @@ -71,6 +72,7 @@ export default function Popup(props: PopupProps(props: PopupProps(config: T | [T, T] | null | undefined, defaultConfig: T): [T, T] { const singleConfig = config ?? defaultConfig; @@ -526,6 +527,15 @@ function RangePicker( lastOperation('panel'); }; + const onPanelKeyDown = useEvent((event: React.KeyboardEvent) => { + if (event.key === 'Escape' || event.key === 'Esc') { + triggerOpen(false, { force: true }); + raf(() => { + selectorRef.current?.focus(); + }); + } + }); + // >>> Calendar const onPanelSelect: PickerPanelProps['onChange'] = (date: DateType) => { const clone: RangeValueType = fillIndex(calendarValue, activeIndex, date); @@ -597,6 +607,7 @@ function RangePicker( onFocus={onPanelFocus} onBlur={onSharedBlur} onPanelMouseDown={onPanelMouseDown} + onPanelKeyDown={onPanelKeyDown} // Mode picker={picker} mode={mergedMode} diff --git a/src/PickerInput/SinglePicker.tsx b/src/PickerInput/SinglePicker.tsx index 64fbf9d85..dad7934ed 100644 --- a/src/PickerInput/SinglePicker.tsx +++ b/src/PickerInput/SinglePicker.tsx @@ -33,6 +33,7 @@ import useShowNow from './hooks/useShowNow'; import Popup from './Popup'; import SingleSelector from './Selector/SingleSelector'; import useSemantic from '../hooks/useSemantic'; +import raf from '@rc-component/util/lib/raf'; // TODO: isInvalidateDate with showTime.disabledTime should not provide `range` prop @@ -477,6 +478,15 @@ function Picker( triggerOpen(false); }; + const onPanelKeyDown = useEvent((event: React.KeyboardEvent) => { + if (event.key === 'Escape' || event.key === 'Esc') { + triggerOpen(false, { force: true }); + raf(() => { + selectorRef.current?.focus(); + }); + } + }); + // >>> cellRender const onInternalCellRender = useCellRender(cellRender, dateRender, monthCellRender); @@ -532,6 +542,7 @@ function Picker( onHover={onPanelHover} // Submit needConfirm={needConfirm} + onPanelKeyDown={onPanelKeyDown} onSubmit={triggerConfirm} onOk={triggerOk} // Preset @@ -581,23 +592,12 @@ function Picker( const onSelectorKeyDown: SelectorProps['onKeyDown'] = (event, preventDefault) => { if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); triggerOpen(true); return; } - - if (event.key === 'Esc') { - triggerConfirm(); - - return; - } - - if (event.key === 'Tab') { - if (mergedOpen) { - // event.preventDefault(); - } - } - onKeyDown?.(event, preventDefault); }; diff --git a/src/PickerPanel/DecadePanel/index.tsx b/src/PickerPanel/DecadePanel/index.tsx index 748015d9f..c83abd4ca 100644 --- a/src/PickerPanel/DecadePanel/index.tsx +++ b/src/PickerPanel/DecadePanel/index.tsx @@ -117,6 +117,7 @@ export default function DecadePanel( getCellDate={getCellDate} getCellText={getCellText} getCellClassName={getCellClassName} + onChange={onPickerValueChange} />
diff --git a/src/PickerPanel/MonthPanel/index.tsx b/src/PickerPanel/MonthPanel/index.tsx index cfd22079f..190320f14 100644 --- a/src/PickerPanel/MonthPanel/index.tsx +++ b/src/PickerPanel/MonthPanel/index.tsx @@ -113,6 +113,7 @@ export default function MonthPanel( getCellDate={getCellDate} getCellText={getCellText} getCellClassName={getCellClassName} + onChange={onPickerValueChange} /> diff --git a/src/PickerPanel/PanelBody.tsx b/src/PickerPanel/PanelBody.tsx index 07d2cf6ea..c0e0c845e 100644 --- a/src/PickerPanel/PanelBody.tsx +++ b/src/PickerPanel/PanelBody.tsx @@ -4,6 +4,7 @@ import type { DisabledDate } from '../interface'; import { formatValue, isInRange, isSame, isSameMonth } from '../utils/dateUtil'; import { PickerHackContext, usePanelContext } from './context'; import { offsetPanelDate } from '@/PickerInput/hooks/useRangePickerValue'; +import { useEvent } from '@rc-component/util'; export interface PanelBodyProps { rowNum: number; @@ -105,55 +106,30 @@ export default function PanelBody(props: PanelBod } }; - const onKeyDown = React.useCallback( - (event) => { - switch (event.key) { - case 'ArrowRight': - moveFocus(1); - break; - case 'ArrowLeft': - moveFocus(-1); - break; - case 'ArrowDown': - moveFocus(7); - break; - case 'ArrowUp': - moveFocus(-7); - break; - case 'Enter': - onSelect(focusDateTime); - break; - - case 'Esc': - break; - - case 'Tab': - onChange?.(focusDateTime); - - default: - return; - } - - event.preventDefault(); - }, - [focusDateTime, generateConfig, onSelect], - ); - - React.useEffect(() => { - const focusElement = - cellRefs.current[ - formatValue(focusDateTime, { - locale, - format: 'YYYY-MM-DD', - generateConfig, - }) - ]; - if (focusElement) { - requestAnimationFrame(() => { - focusElement.focus(); - }); + const onKeyDown = useEvent((event) => { + switch (event.key) { + case 'ArrowRight': + moveFocus(1); + break; + case 'ArrowLeft': + moveFocus(-1); + break; + case 'ArrowDown': + moveFocus(7); + break; + case 'ArrowUp': + moveFocus(-7); + break; + case 'Enter': + onSelect(focusDateTime); + break; + case 'Tab': + onChange?.(focusDateTime); + + default: + return; } - }, []); + }); // =============================== Body =============================== const rows: React.ReactNode[] = []; diff --git a/src/PickerPanel/QuarterPanel/index.tsx b/src/PickerPanel/QuarterPanel/index.tsx index 86542087a..8cd26aa83 100644 --- a/src/PickerPanel/QuarterPanel/index.tsx +++ b/src/PickerPanel/QuarterPanel/index.tsx @@ -80,6 +80,7 @@ export default function QuarterPanel( getCellDate={getCellDate} getCellText={getCellText} getCellClassName={getCellClassName} + onChange={onPickerValueChange} /> diff --git a/src/PickerPanel/YearPanel/index.tsx b/src/PickerPanel/YearPanel/index.tsx index ae7e42811..f72681035 100644 --- a/src/PickerPanel/YearPanel/index.tsx +++ b/src/PickerPanel/YearPanel/index.tsx @@ -125,6 +125,7 @@ export default function YearPanel( getCellDate={getCellDate} getCellText={getCellText} getCellClassName={getCellClassName} + onChange={onPickerValueChange} />