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/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/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 df2a86a81..c6d0cccb2 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'; @@ -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 @@ -195,6 +196,7 @@ function Picker( // ========================= Refs ========================= const selectorRef = usePickerRef(ref); + const triggerRef = React.useRef(null); // ========================= Util ========================= function pickerParam(values: T | T[]) { @@ -358,6 +360,9 @@ function Picker( */ const triggerConfirm = () => { triggerSubmitChange(getCalendarValue()); + raf(() => { + selectorRef.current?.focus(); + }); triggerOpen(false, { force: true }); }; @@ -476,6 +481,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); @@ -531,6 +545,7 @@ function Picker( onHover={onPanelHover} // Submit needConfirm={needConfirm} + onPanelKeyDown={onPanelKeyDown} onSubmit={triggerConfirm} onOk={triggerOk} // Preset @@ -579,8 +594,10 @@ function Picker( }; const onSelectorKeyDown: SelectorProps['onKeyDown'] = (event, preventDefault) => { - if (event.key === 'Tab') { - triggerConfirm(); + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + triggerOpen(true); } onKeyDown?.(event, preventDefault); @@ -645,6 +662,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/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 34b6fe011..61caa9ae5 100644 --- a/src/PickerPanel/PanelBody.tsx +++ b/src/PickerPanel/PanelBody.tsx @@ -1,8 +1,10 @@ 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'; +import { useEvent } from '@rc-component/util'; export interface PanelBodyProps { rowNum: number; @@ -25,6 +27,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 +44,7 @@ export default function PanelBody(props: PanelBod headerCells, cellSelection = true, disabledDate, + onChange, } = props; const { @@ -64,6 +68,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 +81,69 @@ export default function PanelBody(props: PanelBod (singleValue) => singleValue && isSame(generateConfig, locale, date, singleValue, type), ); + // ============================== Event Handlers =============================== + + const moveFocus = (offset: number) => { + const nextDate = getCellDate(focusDateTime, offset); + const isNextDateDisabled = mergedDisabledDate?.(nextDate, { type }); + + // TODO: Handle Disabled Date feature + if (isNextDateDisabled) { + return; + } + + setFocusDateTime(nextDate); + + const focusElement = + cellRefs.current[ + formatValue(nextDate, { + locale, + format: 'YYYY-MM-DD', + generateConfig, + }) + ]; + if (focusElement) { + requestAnimationFrame(() => { + focusElement.focus(); + }); + } + + // Changes month when arrow hits last calendar day of that month + if (type && !isSame(generateConfig, locale, focusDateTime, nextDate, type)) { + return onChange?.(nextDate); + } + }; + + const onKeyDown = useEvent((event) => { + const isFocusDateTimeDisabled = mergedDisabledDate?.(focusDateTime, { type }); + + 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': + if (!isFocusDateTimeDisabled) { + onSelect(focusDateTime); + } + + break; + case 'Tab': + onChange?.(focusDateTime); + + default: + return; + } + }); + // =============================== Body =============================== const rows: React.ReactNode[] = []; @@ -118,8 +189,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/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/TimePanel/TimePanelBody/TimeColumn.tsx b/src/PickerPanel/TimePanel/TimePanelBody/TimeColumn.tsx index 4e6e53739..918133f37 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/lib/hooks/useLayoutEffect'; import * as React from 'react'; import { usePanelContext } from '../../context'; import useScrollTo from './useScrollTo'; +import { useEvent } from '@rc-component/util'; const SCROLL_DELAY = 300; @@ -39,6 +40,10 @@ export default function TimeColumn(props: TimeUnitColum // ========================== Refs ========================== const ulRef = React.useRef(null); + const cellRefs = React.useRef>({}); + + // ========================== State ========================== + const [focusedValue, setFocusedValue] = React.useState(value); // ========================= Scroll ========================= const checkDelayRef = React.useRef(); @@ -92,13 +97,67 @@ export default function TimeColumn(props: TimeUnitColum } }; + // ========================= Event Handlers ========================= + + const moveFocus = (offset: number) => { + const currentValueIndex = units.findIndex((unit) => unit.value === focusedValue); + const currentValueWithOffset = currentValueIndex + offset; + const nextValueIndex = + currentValueWithOffset < 0 ? units.length - 1 : currentValueWithOffset % units.length; + const nextValue = units[nextValueIndex].value; + const isNextValueDisabled = units[nextValueIndex].disabled; + + if (isNextValueDisabled) { + return; + } + + setFocusedValue(nextValue); + + const focusElement = cellRefs.current[nextValue]; + if (focusElement) { + requestAnimationFrame(() => { + focusElement.focus(); + }); + } + }; + + const onKeyDown = useEvent((event) => { + switch (event.key) { + case 'ArrowDown': + moveFocus(1); + break; + case 'ArrowUp': + moveFocus(-1); + break; + case 'Enter': + onChange(focusedValue); + break; + + default: + return; + } + }); + // ========================= Render ========================= const columnPrefixCls = `${panelPrefixCls}-column`; return (
    {units.map(({ label, value: unitValue, disabled }) => { - const inner =
    {label}
    ; + const isValueFocused = focusedValue === unitValue; + + const inner = ( +
    { + cellRefs.current[unitValue] = element; + }} + onKeyDown={onKeyDown} + > + {label} +
    + ); return (
  • ( getCellDate={getCellDate} getCellText={getCellText} getCellClassName={getCellClassName} + onChange={onPickerValueChange} /> 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, + })); + + useLockFocus(visible, () => triggerPopupRef.current?.popupElement ?? null); + return ( (PickerTrigger); + +export default RefPickerTrigger;