diff --git a/.eslintrc.js b/.eslintrc.js index f1b8be4f8..9b2ea05f3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,6 +10,7 @@ module.exports = { 'react/sort-comp': 0, '@typescript-eslint/no-explicit-any': 0, 'default-case': 0, + 'no-confusing-arrow': 0, 'jsx-a11y/no-autofocus': 0, 'import/no-extraneous-dependencies': [ 'error', diff --git a/assets/index.less b/assets/index.less index 726862f77..c52685ff5 100644 --- a/assets/index.less +++ b/assets/index.less @@ -1,11 +1,13 @@ @prefix-cls: rc-picker; +@background-color: rgb(255, 240, 255); + .@{prefix-cls} { display: inline-flex; &-panel { border: 1px solid #666; - background: rgb(255, 240, 255); + background: @background-color; display: inline-block; vertical-align: top; @@ -33,6 +35,7 @@ table { text-align: center; + border-collapse: collapse; } } @@ -91,9 +94,36 @@ &-in-range > &-inner { background: fade(blue, 5%); } + &-range-hover-start, + &-range-hover-end, + &-range-hover { + position: relative; + &::after { + content: ''; + position: absolute; + top: 3px; + bottom: 0; + left: 0; + right: 0; + border: 1px solid green; + border-left: 0; + border-right: 0; + pointer-events: none; + } + } + + &-range-hover-start::after { + border-left: 1px solid green!important; + } + &-range-hover-end::after { + border-right: 1px solid green!important; + } + &-today > &-inner { border: 1px solid blue; } + &-range-start > &-inner, + &-range-end > &-inner, &-selected > &-inner { background: fade(blue, 20%); } @@ -179,6 +209,7 @@ > li { padding: 0; margin: 0; + cursor: pointer; .@{prefix-cls}-time-panel-cell-inner { color: #333; @@ -245,11 +276,51 @@ // ======================= Dropdown ======================= &-dropdown { position: absolute; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + box-shadow: 0 0 1px red; &-hidden { display: none; } + + // Panel + @arrow-size: 10px; + .@{prefix-cls}-range-arrow { + position: absolute; + width: @arrow-size; + height: @arrow-size; + z-index: 1; + transform: rotate(-45deg); + top: @arrow-size / 2 + 1px; + left: @arrow-size; + + &::before, + &::after { + content: ''; + position: absolute; + box-sizing: border-box; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + &::before { + width: @arrow-size; + height: @arrow-size; + border: @arrow-size / 2 solid blue; + border-color: blue blue transparent transparent; + } + &::after { + width: @arrow-size - 2px; + height: @arrow-size - 2px; + border: (@arrow-size - 2px) / 2 solid blue; + border-color: @background-color @background-color transparent + transparent; + } + } + + .@{prefix-cls}-panel { + margin: 10px 0; + } } // ======================================================== @@ -257,5 +328,6 @@ // ======================================================== &-range { display: inline-flex; + position: relative; } } diff --git a/examples/common.less b/examples/common.less new file mode 100644 index 000000000..c84387c88 --- /dev/null +++ b/examples/common.less @@ -0,0 +1,7 @@ +h1, +h2, +h3, +h4 { + margin: 0; + line-height: 200%; +} diff --git a/examples/range.tsx b/examples/range.tsx index 0c7d08c2f..8b73d1019 100644 --- a/examples/range.tsx +++ b/examples/range.tsx @@ -4,6 +4,7 @@ import RangePicker from '../src/RangePicker'; import momentGenerateConfig from '../src/generate/moment'; import zhCN from '../src/locale/zh_CN'; import '../assets/index.less'; +import './common.less'; const defaultStartValue = moment('2019-09-03 05:02:03'); const defaultEndValue = moment('2019-11-28 01:02:03'); @@ -43,12 +44,30 @@ export default () => { return (
-

+

Value:{' '} {value ? `${formatDate(value[0])} ~ ${formatDate(value[1])}` : 'null'} -

+
+
+

Basic

+ + {...sharedProps} + value={undefined} + locale={zhCN} + allowClear + ref={rangePickerRef} + /> + + {...sharedProps} + locale={zhCN} + allowClear + ref={rangePickerRef} + showTime + /> +
+

Basic

@@ -56,6 +75,7 @@ export default () => { locale={zhCN} allowClear ref={rangePickerRef} + // style={{ width: 500 }} />
+ +
+

Start disabled

+ + {...sharedProps} + locale={zhCN} + allowClear + disabled={[true, false]} + /> +
+
+

End disabled

+ + {...sharedProps} + locale={zhCN} + allowClear + disabled={[false, true]} />
diff --git a/src/PanelContext.tsx b/src/PanelContext.tsx index 1c8e67548..796bcdfdb 100644 --- a/src/PanelContext.tsx +++ b/src/PanelContext.tsx @@ -10,6 +10,10 @@ export interface PanelContextProps { /** Only work with time panel */ hideHeader?: boolean; panelRef?: React.Ref; + hidePrevBtn?: boolean; + hideNextBtn?: boolean; + onDateMouseEnter?: (date: any) => void; + onDateMouseLeave?: (date: any) => void; } const PanelContext = React.createContext({}); diff --git a/src/Picker.tsx b/src/Picker.tsx index a5f49cfcd..6ec53d179 100644 --- a/src/Picker.tsx +++ b/src/Picker.tsx @@ -12,7 +12,6 @@ */ import * as React from 'react'; -import KeyCode from 'rc-util/lib/KeyCode'; import classNames from 'classnames'; import { AlignType } from 'rc-trigger/lib/interface'; import PickerPanel, { @@ -25,16 +24,15 @@ import { isEqual } from './utils/dateUtil'; import getDataOrAriaProps, { toArray } from './utils/miscUtil'; import PanelContext, { ContextOperationRefProps } from './PanelContext'; import { PickerMode } from './interface'; -import { - getDefaultFormat, - getInputSize, - addGlobalMouseDownEvent, -} from './utils/uiUtil'; +import { getDefaultFormat, getInputSize } from './utils/uiUtil'; +import usePickerInput from './hooks/usePickerInput'; +import useTextValueMapping from './hooks/useTextValueMapping'; +import useMergedState from './hooks/useMergeState'; +import useValueTexts from './hooks/useValueTexts'; export interface PickerRefConfig { focus: () => void; blur: () => void; - open: () => void; } export interface PickerSharedProps extends React.AriaAttributes { @@ -47,6 +45,7 @@ export interface PickerSharedProps extends React.AriaAttributes { autoFocus?: boolean; disabled?: boolean; open?: boolean; + defaultOpen?: boolean; /** Make input readOnly to avoid popup keyboard in mobile */ inputReadOnly?: boolean; @@ -83,20 +82,22 @@ export interface PickerSharedProps extends React.AriaAttributes { name?: string; } +type OmitPanelProps = Omit< + Props, + 'onChange' | 'hideHeader' | 'pickerValue' | 'onPickerValueChange' +>; + export interface PickerBaseProps extends PickerSharedProps, - Omit, 'onChange' | 'hideHeader'> {} + OmitPanelProps> {} export interface PickerDateProps extends PickerSharedProps, - Omit, 'onChange' | 'hideHeader'> {} + OmitPanelProps> {} export interface PickerTimeProps extends PickerSharedProps, - Omit< - PickerPanelTimeProps, - 'onChange' | 'format' | 'hideHeader' - > {} + Omit>, 'format'> {} export type PickerProps = | PickerBaseProps @@ -134,6 +135,7 @@ function InnerPicker(props: PickerProps) { value, defaultValue, open, + defaultOpen, suffixIcon, clearIcon, disabled, @@ -165,61 +167,29 @@ function InnerPicker(props: PickerProps) { const inputDivRef = React.useRef(null); // Real value - const [innerValue, setInnerValue] = React.useState(() => { - if (value !== undefined) { - return value; - } - if (defaultValue !== undefined) { - return defaultValue; - } - return null; + const [mergedValue, setInnerValue] = useMergedState({ + value, + defaultValue, + defaultStateValue: null, }); - const mergedValue = value !== undefined ? value : innerValue; // Selected value - const [ - selectedValue, - setInternalSelectedValue, - ] = React.useState(mergedValue); - - // Text - const [textValue, setTextValue] = React.useState( - selectedValue - ? generateConfig.locale.format( - locale.locale, - selectedValue, - formatList[0], - ) - : '', + const [selectedValue, setSelectedValue] = React.useState( + mergedValue, ); - const [typing, setTyping] = React.useState(false); - - /** Similar as `setTextValue` but accept `DateType` and convert into string */ - const setDateText = (date: DateType | null) => { - setTextValue( - date === null - ? '' - : generateConfig.locale.format(locale.locale, date, formatList[0]), - ); - }; // Operation ref const operationRef: React.MutableRefObject = React.useRef< ContextOperationRefProps >(null); - // Trigger - const [innerOpen, setInnerOpen] = React.useState(false); - let mergedOpen: boolean; - if (disabled) { - mergedOpen = false; - } else { - mergedOpen = typeof open === 'boolean' ? open : innerOpen; - } - - const triggerOpen = (newOpen: boolean) => { - if (mergedOpen !== newOpen) { - setInnerOpen(newOpen); + // Open + const [mergedOpen, triggerInnerOpen] = useMergedState({ + value: open, + defaultValue: defaultOpen, + defaultStateValue: false, + postState: postOpen => (disabled ? false : postOpen), + onChange: newOpen => { if (onOpenChange) { onOpenChange(newOpen); } @@ -227,52 +197,8 @@ function InnerPicker(props: PickerProps) { if (!newOpen && operationRef.current && operationRef.current.onClose) { operationRef.current.onClose(); } - } - }; - - // Focus - const [focused, setFocused] = React.useState(false); - - // ============================= Value ============================= - const isSameTextDate = (text: string, date: DateType | null) => { - if (date === null) { - return !text; - } - - const inputDate = generateConfig.locale.parse( - locale.locale, - text, - formatList, - ); - return isEqual(generateConfig, inputDate, date); - }; - - // =========================== Formatter =========================== - const setSelectedValue = (newDate: DateType | null) => { - if (!isSameTextDate(textValue, newDate)) { - setDateText(newDate); - } - setInternalSelectedValue(newDate); - }; - - const onInputMouseDown: React.MouseEventHandler = () => { - triggerOpen(true); - setTyping(true); - }; - - const onInputChange: React.ChangeEventHandler = e => { - const text = e.target.value; - setTextValue(text); - - const inputDate = generateConfig.locale.parse( - locale.locale, - text, - formatList, - ); - if (inputDate && (!disabledDate || !disabledDate(inputDate))) { - setSelectedValue(inputDate); - } - }; + }, + }); // ============================ Trigger ============================ const triggerChange = (newValue: DateType | null) => { @@ -289,141 +215,86 @@ function InnerPicker(props: PickerProps) { } }; + const triggerOpen = ( + newOpen: boolean, + preventChangeEvent: boolean = false, + ) => { + triggerInnerOpen(newOpen); + if (!newOpen && !preventChangeEvent) { + triggerChange(selectedValue); + } + }; + const forwardKeyDown = (e: React.KeyboardEvent) => { - if ( - !typing && - mergedOpen && - operationRef.current && - operationRef.current.onKeyDown - ) { + if (mergedOpen && operationRef.current && operationRef.current.onKeyDown) { // Let popup panel handle keyboard return operationRef.current.onKeyDown(e); } return false; }; - const onInputKeyDown: React.KeyboardEventHandler = e => { - switch (e.which) { - case KeyCode.ENTER: { - if (!mergedOpen) { - triggerOpen(true); - } else { - triggerChange(selectedValue); - triggerOpen(false); - setTyping(true); - } - return; - } - - case KeyCode.TAB: { - if (typing && mergedOpen && !e.shiftKey) { - setTyping(false); - e.preventDefault(); - } else if (!typing && mergedOpen) { - if (!forwardKeyDown(e) && e.shiftKey) { - setTyping(true); - e.preventDefault(); - } - } - return; - } + // ============================= Text ============================== + const valueTexts = useValueTexts(selectedValue, { + formatList, + generateConfig, + locale, + }); - case KeyCode.ESC: { - triggerChange(mergedValue); - setSelectedValue(mergedValue); - triggerOpen(false); - setTyping(true); - return; + const [text, triggerTextChange, resetText] = useTextValueMapping({ + valueTexts, + onTextChange: newText => { + const inputDate = generateConfig.locale.parse( + locale.locale, + newText, + formatList, + ); + if (inputDate && (!disabledDate || !disabledDate(inputDate))) { + setSelectedValue(inputDate); } - } - - if (!mergedOpen && ![KeyCode.SHIFT].includes(e.which)) { - triggerOpen(true); - } else { - // Let popup panel handle keyboard - forwardKeyDown(e); - } - }; - - const onInputFocus: React.FocusEventHandler = e => { - setTyping(true); - setFocused(true); - - if (onFocus) { - onFocus(e); - } - }; - - /** - * We will prevent blur to handle open event when user click outside, - * since this will repeat trigger `onOpenChange` event. - */ - const preventBlurRef = React.useRef(false); - - const triggerClose = () => { - triggerOpen(false); - setInnerValue(selectedValue); - triggerChange(selectedValue); - }; - - const onInputBlur: React.FocusEventHandler = e => { - if (preventBlurRef.current) { - preventBlurRef.current = false; - return; - } - - triggerClose(); - setFocused(false); + }, + }); - if (onBlur) { - onBlur(e); - } - }; + // ============================= Input ============================= + const [inputProps, { focused, typing }] = usePickerInput({ + open: mergedOpen, + triggerOpen, + forwardKeyDown, + isClickOutside: target => + !!( + panelDivRef.current && + !panelDivRef.current.contains(target as Node) && + inputDivRef.current && + !inputDivRef.current.contains(target as Node) && + onOpenChange + ), + onSubmit: () => { + triggerChange(selectedValue); + triggerOpen(false, true); + resetText(); + }, + onCancel: () => { + triggerOpen(false, true); + setSelectedValue(mergedValue); + resetText(); + }, + onFocus, + onBlur, + }); // ============================= Sync ============================== // Close should sync back with text value React.useEffect(() => { - if (!mergedOpen && !isSameTextDate(textValue, mergedValue)) { - setDateText(mergedValue); + if (!mergedOpen) { + setSelectedValue(mergedValue); } }, [mergedOpen]); // Sync innerValue with control mode React.useEffect(() => { - if (!isEqual(generateConfig, mergedValue, innerValue)) { - // Sync inner & select value - setInnerValue(mergedValue); - setSelectedValue(mergedValue); - } - - // Sync text - if (!isSameTextDate(textValue, mergedValue)) { - setDateText(mergedValue); - } + // Sync select value + setSelectedValue(mergedValue); }, [mergedValue]); - // Global click handler - React.useEffect(() => - addGlobalMouseDownEvent(({ target }: MouseEvent) => { - if ( - mergedOpen && - panelDivRef.current && - !panelDivRef.current.contains(target as Node) && - inputDivRef.current && - !inputDivRef.current.contains(target as Node) && - onOpenChange - ) { - preventBlurRef.current = true; - triggerClose(); - - // Always set back in case `onBlur` prevented by user - window.setTimeout(() => { - preventBlurRef.current = false; - }, 0); - } - }), - ); - // ============================ Private ============================ if (pickerRef) { pickerRef.current = { @@ -437,9 +308,6 @@ function InnerPicker(props: PickerProps) { inputRef.current.blur(); } }, - open: () => { - triggerOpen(true); - }, }; } @@ -449,6 +317,8 @@ function InnerPicker(props: PickerProps) { ...(props as Omit, 'picker' | 'format'>), className: undefined, style: undefined, + pickerValue: undefined, + onPickerValueChange: undefined, }; const panel = ( @@ -523,15 +393,12 @@ function InnerPicker(props: PickerProps) { @@ -560,12 +427,6 @@ class Picker extends React.Component> { } }; - open = () => { - if (this.pickerRef.current) { - this.pickerRef.current.open(); - } - }; - render() { return ( diff --git a/src/PickerPanel.tsx b/src/PickerPanel.tsx index 38319dce6..c1dd70cec 100644 --- a/src/PickerPanel.tsx +++ b/src/PickerPanel.tsx @@ -1,5 +1,12 @@ /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ +/** + * Logic: + * When `mode` === `picker`, + * click will trigger `onSelect` (if value changed trigger `onChange` also). + * Panel change will not trigger `onSelect` but trigger `onPanelChange` + */ + import * as React from 'react'; import classNames from 'classnames'; import KeyCode from 'rc-util/lib/KeyCode'; @@ -26,6 +33,7 @@ import { DateRender } from './panels/DatePanel/DateBody'; import { PickerModeMap } from './utils/uiUtil'; import { MonthCellRender } from './panels/MonthPanel/MonthBody'; import RangeContext, { FooterSelection } from './RangeContext'; +import useMergedState from './hooks/useMergeState'; export interface PickerPanelSharedProps { prefixCls?: string; @@ -43,6 +51,8 @@ export interface PickerPanelSharedProps { value?: DateType | null; defaultValue?: DateType; /** [Legacy] Set default display picker view date */ + pickerValue?: DateType; + /** [Legacy] Set default display picker view date */ defaultPickerValue?: DateType; // Date @@ -61,6 +71,8 @@ export interface PickerPanelSharedProps { /** @private This is internal usage. Do not use in your production env */ hideHeader?: boolean; + /** @private This is internal usage. Do not use in your production env */ + onPickerValueChange?: (date: DateType) => void; } export interface PickerPanelBaseProps @@ -108,6 +120,7 @@ function PickerPanel(props: PickerPanelProps) { generateConfig, value, defaultValue, + pickerValue, defaultPickerValue, mode, picker = 'date', @@ -120,6 +133,7 @@ function PickerPanel(props: PickerPanelProps) { onChange, onPanelChange, onMouseDown, + onPickerValueChange, } = props as MergedPickerPanelProps; if (process.env.NODE_ENV !== 'production') { @@ -138,7 +152,9 @@ function PickerPanel(props: PickerPanelProps) { const panelContext = React.useContext(PanelContext); const { operationRef, panelRef: panelDivRef } = panelContext; - const { extraFooterSelections, inRange } = React.useContext(RangeContext); + const { extraFooterSelections, inRange, panelPosition } = React.useContext( + RangeContext, + ); const panelRef = React.useRef({}); // Handle init logic @@ -158,9 +174,22 @@ function PickerPanel(props: PickerPanelProps) { const mergedValue = value !== undefined ? value : innerValue; // View date control - const [viewDate, setViewDate] = React.useState( - () => defaultPickerValue || mergedValue || generateConfig.getNow(), - ); + const [viewDate, setInnerViewDate] = useMergedState< + DateType | null, + DateType + >({ + value: pickerValue, + defaultValue: defaultPickerValue || mergedValue, + defaultStateValue: null, + postState: date => date || generateConfig.getNow(), + }); + + const setViewDate = (date: DateType) => { + setInnerViewDate(date); + if (onPickerValueChange) { + onPickerValueChange(date); + } + }; // Panel control const getInternalNextMode = (nextMode: PanelMode): PanelMode => { @@ -169,9 +198,6 @@ function PickerPanel(props: PickerPanelProps) { return getNextMode(nextMode); } - if (nextMode === 'date' && showTime) { - return 'datetime'; - } return nextMode; }; @@ -181,8 +207,17 @@ function PickerPanel(props: PickerPanelProps) { } return getInternalNextMode('date'); }); + const mergedMode: PanelMode = mode || innerMode; + // const mergedMode: PanelMode = React.useMemo(() => { + // const newMode = mode || innerMode; + // if (newMode === 'date' && showTime) { + // return 'datetime'; + // } + // return newMode; + // }, [mode || innerMode]); + const onInternalPanelChange = (newMode: PanelMode, viewValue: DateType) => { const nextMode = getInternalNextMode(newMode); setInnerMode(nextMode); @@ -192,15 +227,20 @@ function PickerPanel(props: PickerPanelProps) { } }; - const triggerSelect = (date: DateType) => { - setInnerValue(date); + const triggerSelect = ( + date: DateType, + forceTriggerSelect: boolean = false, + ) => { + if (mergedMode === picker || forceTriggerSelect) { + setInnerValue(date); - if (onSelect) { - onSelect(date); - } + if (onSelect) { + onSelect(date); + } - if (onChange && !isEqual(generateConfig, date, mergedValue)) { - onChange(date); + if (onChange && !isEqual(generateConfig, date, mergedValue)) { + onChange(date); + } } }; @@ -255,7 +295,7 @@ function PickerPanel(props: PickerPanelProps) { // ============================ Effect ============================ React.useEffect(() => { if (value && !initRef.current) { - setViewDate(value); + setInnerViewDate(value); } }, [value]); @@ -327,18 +367,6 @@ function PickerPanel(props: PickerPanelProps) { ); break; - case 'datetime': - panelNode = ( - { - setViewDate(date); - triggerSelect(date); - }} - /> - ); - break; - case 'time': delete pickerProps.showTime; panelNode = ( @@ -354,15 +382,27 @@ function PickerPanel(props: PickerPanelProps) { break; default: - panelNode = ( - - {...pickerProps} - onSelect={date => { - setViewDate(date); - triggerSelect(date); - }} - /> - ); + if (showTime) { + panelNode = ( + { + setViewDate(date); + triggerSelect(date); + }} + /> + ); + } else { + panelNode = ( + + {...pickerProps} + onSelect={date => { + setViewDate(date); + triggerSelect(date); + }} + /> + ); + } } // ============================ Footer ============================ @@ -381,7 +421,7 @@ function PickerPanel(props: PickerPanelProps) { { - triggerSelect(generateConfig.getNow()); + triggerSelect(generateConfig.getNow(), true); }} > {locale.today} @@ -398,7 +438,7 @@ function PickerPanel(props: PickerPanelProps) { mergedSelections.push({ label: locale.now, onClick: () => { - triggerSelect(generateConfig.getNow()); + triggerSelect(generateConfig.getNow(), true); }, }); } @@ -421,6 +461,8 @@ function PickerPanel(props: PickerPanelProps) { ...panelContext, hideHeader: 'hideHeader' in props ? hideHeader : panelContext.hideHeader, + hidePrevBtn: inRange && panelPosition === 'right', + hideNextBtn: inRange && panelPosition === 'left', }} >
, NullableDateType]; + hoverRangedValue?: RangeValue; inRange?: boolean; + panelPosition?: 'left' | 'right' | false; } const RangeContext = React.createContext({}); diff --git a/src/RangePicker.tsx b/src/RangePicker.tsx index 8ed74f1b2..8a98365de 100644 --- a/src/RangePicker.tsx +++ b/src/RangePicker.tsx @@ -1,41 +1,79 @@ -/** - * TODO: - * - Highlight range when hover the ranges value - * - Click ranges value will go to the related panel - */ - import * as React from 'react'; import classNames from 'classnames'; -import Picker, { +import { + DisabledTimes, + PanelMode, + PickerMode, + RangeValue, + EventValue, +} from './interface'; +import { PickerBaseProps, PickerDateProps, PickerTimeProps, PickerRefConfig, } from './Picker'; +import { SharedTimeProps } from './panels/TimePanel'; +import useMergedState from './hooks/useMergeState'; +import PickerTrigger from './PickerTrigger'; +import PickerPanel from './PickerPanel'; +import usePickerInput from './hooks/usePickerInput'; +import getDataOrAriaProps, { + toArray, + getValue, + updateValues, +} from './utils/miscUtil'; +import { getDefaultFormat, getInputSize } from './utils/uiUtil'; +import PanelContext, { ContextOperationRefProps } from './PanelContext'; import { - NullableDateType, - DisabledTimes, - DisabledTime, - PickerMode, - PanelMode, - OnPanelChange, -} from './interface'; -import { toArray } from './utils/miscUtil'; + isEqual, + getClosingViewDate, + isSameMonth, + isSameYear, +} from './utils/dateUtil'; +import useValueTexts from './hooks/useValueTexts'; +import useTextValueMapping from './hooks/useTextValueMapping'; +import { GenerateConfig } from './generate'; +import { PickerPanelProps } from '.'; import RangeContext from './RangeContext'; -import { isSameDate } from './utils/dateUtil'; -import { getDefaultFormat } from './utils/uiUtil'; -import { SharedTimeProps } from './panels/TimePanel'; +import useRangeDisabled from './hooks/useRangeDisabled'; + +function reorderValues( + values: RangeValue, + generateConfig: GenerateConfig, +): RangeValue { + if ( + values && + values[0] && + values[1] && + generateConfig.isAfter(values[0], values[1]) + ) { + return [values[1], values[0]]; + } -type EventValue = DateType | null; -type RangeValue = [EventValue, EventValue] | null; + return values; +} -function canTriggerChange( - dates: [EventValue, EventValue], - allowEmpty?: [boolean, boolean], +function canValueTrigger( + value: EventValue, + index: number, + disabledList: [boolean, boolean], + allowEmpty?: [boolean, boolean] | null, ): boolean { - const passStart = dates[0] || (allowEmpty && allowEmpty[0]); - const passEnd = dates[1] || (allowEmpty && allowEmpty[1]); - return !!(passStart && passEnd); + if (value) { + return true; + } + + if (allowEmpty && allowEmpty[index]) { + return true; + } + + // If another one is disabled, this can be trigger + if (disabledList[(index + 1) % 2]) { + return true; + } + + return false; } export interface RangePickerSharedProps { @@ -43,6 +81,7 @@ export interface RangePickerSharedProps { defaultValue?: RangeValue; defaultPickerValue?: [DateType, DateType]; placeholder?: [string, string]; + disabled?: boolean | [boolean, boolean]; disabledTime?: ( date: EventValue, type: 'start' | 'end', @@ -54,7 +93,6 @@ export interface RangePickerSharedProps { >; separator?: React.ReactNode; allowEmpty?: [boolean, boolean]; - selectable?: [boolean, boolean]; mode?: [PanelMode, PanelMode]; onChange?: ( values: RangeValue, @@ -78,6 +116,7 @@ type OmitPickerProps = Omit< | 'defaultValue' | 'defaultPickerValue' | 'placeholder' + | 'disabled' | 'disabledTime' | 'showToday' | 'showTime' @@ -85,6 +124,8 @@ type OmitPickerProps = Omit< | 'onChange' | 'onSelect' | 'onPanelChange' + | 'pickerValue' + | 'onPickerValueChange' >; export interface RangePickerBaseProps @@ -120,365 +161,659 @@ interface MergedRangePickerProps picker?: PickerMode; } -function InternalRangePicker( - props: RangePickerProps & { - pickerRef: React.Ref; - }, -) { +function InnerRangePicker(props: RangePickerProps) { const { prefixCls = 'rc-picker', - className, style, - value, - defaultValue, - defaultPickerValue, - separator = '~', - mode, - picker, - pickerRef, - locale, + className, + popupStyle, + dropdownClassName, + transitionName, + dropdownAlign, + getPopupContainer, generateConfig, + locale, placeholder, + autoFocus, + disabled, + format, + picker = 'date', showTime, use12Hours, - disabledTime, - ranges, - format, + separator = '~', + value, + defaultValue, + defaultPickerValue, + open, + defaultOpen, + disabledDate, allowEmpty, - selectable, - disabled, + allowClear, + suffixIcon, + clearIcon, + pickerRef, + inputReadOnly, + mode, onChange, - onCalendarChange, + onOpenChange, onPanelChange, onFocus, onBlur, - } = props as MergedRangePickerProps & { - pickerRef: React.MutableRefObject; - }; + } = props as MergedRangePickerProps; + + const containerRef = React.useRef(null); + const panelDivRef = React.useRef(null); + const startInputDivRef = React.useRef(null); + const endInputDivRef = React.useRef(null); + const startInputRef = React.useRef(null); + const endInputRef = React.useRef(null); + // ============================= Misc ============================== const formatList = toArray( getDefaultFormat(format, picker, showTime, use12Hours), ); - const [startShowTime, endShowTime] = React.useMemo(() => { - if (showTime && typeof showTime === 'object' && showTime.defaultValue) { - return [ - { ...showTime, defaultValue: showTime.defaultValue[0] }, - { ...showTime, defaultValue: showTime.defaultValue[1] }, - ]; + // Active picker + const [activePickerIndex, setActivePickerIndex] = React.useState<0 | 1>(0); + + // Operation ref + const operationRef: React.MutableRefObject = React.useRef< + ContextOperationRefProps + >(null); + + const mergedDisabled = React.useMemo<[boolean, boolean]>(() => { + if (Array.isArray(disabled)) { + return disabled; } - return [showTime, showTime]; - }, [showTime]); - - const mergedSelectable = React.useMemo< - [boolean | undefined, boolean | undefined] - >(() => [selectable && selectable[0], selectable && selectable[1]], [ - selectable, - ]); - - // ============================= Values ============================= - const [innerValue, setInnerValue] = React.useState>( - () => { - if (value !== undefined) { - return value; - } - if (defaultValue !== undefined) { - return defaultValue; - } - return null; - }, - ); - const mergedValue = value !== undefined ? value : innerValue; + return [disabled || false, disabled || false]; + }, [disabled]); - // Get picker value, should order this internally - const [value1, value2] = React.useMemo(() => { - let val1 = mergedValue ? mergedValue[0] : null; - let val2 = mergedValue ? mergedValue[1] : null; + // ============================= Value ============================= + const [mergedValue, setInnerValue] = useMergedState>({ + value, + defaultValue, + defaultStateValue: null, + postState: values => reorderValues(values, generateConfig), + }); - // Exchange - if (val1 && val2 && generateConfig.isAfter(val1, val2)) { - const tmp = val1; - val1 = val2; - val2 = tmp; + // =========================== View Date =========================== + /** + * End view date is use right panel by default. + * But when they in same month (date picker) or year (month picker), will both use left panel. + */ + function getEndViewDate(viewDate: DateType, values: RangeValue) { + let compareFunc: ( + generateConfig: GenerateConfig, + date1: DateType | null, + date2: DateType | null, + ) => boolean = isSameMonth; + + if (picker === 'month') { + compareFunc = isSameYear; } - return [val1, val2]; - }, [mergedValue]); + if (compareFunc(generateConfig, getValue(values, 0), getValue(values, 1))) { + return viewDate; + } + return getClosingViewDate(viewDate, picker, generateConfig, -1); + } - // Select value: used for click to update ranged value. Must set in pair - const [selectedValues, setSelectedValues] = React.useState< - [DateType | null, DateType | null] | undefined - >(undefined); + // Config view panel + const [viewDates, setViewDates] = useMergedState< + RangeValue, + [DateType, DateType] + >({ + defaultValue: () => + defaultPickerValue || + updateValues( + mergedValue, + (viewDate: DateType) => getEndViewDate(viewDate, mergedValue), + 1, + ), + defaultStateValue: null, + postState: postViewDates => + postViewDates || [ + getValue(mergedValue, 0) || generateConfig.getNow(), + getValue(mergedValue, 0) || generateConfig.getNow(), + ], + }); - React.useEffect(() => { - setSelectedValues([value1, value2]); - }, [value1, value2]); + // ========================= Select Values ========================= + const [selectedValue, setSelectedValue] = useMergedState({ + defaultStateValue: mergedValue, + postState: values => { + let postValues = values; + for (let i = 0; i < 2; i += 1) { + if (mergedDisabled[i] && !getValue(postValues, i)) { + postValues = updateValues(postValues, generateConfig.getNow(), i); + } + } + return postValues; + }, + }); - const onStartSelect = (date: DateType) => { - setSelectedValues([date, value2]); - }; + // ========================== Hover Range ========================== + const [hoverRangedValue, setHoverRangedValue] = React.useState< + RangeValue + >(null); - const onEndSelect = (date: DateType) => { - setSelectedValues([value1, date]); + const onDateMouseEnter = (date: DateType) => { + setHoverRangedValue(updateValues(selectedValue, date, activePickerIndex)); }; - - // ============================= Change ============================= - const formatDate = (date: NullableDateType) => { - if (date) { - return generateConfig.locale.format(locale.locale, date, formatList[0]); - } - return ''; + const onDateMouseLeave = () => { + setHoverRangedValue(updateValues(selectedValue, null, activePickerIndex)); }; - const onInternalChange = ( - values: NullableDateType[], - changedByStartTime: boolean, + // ============================= Modes ============================= + const [mergedModes, setInnerModes] = useMergedState<[PanelMode, PanelMode]>({ + value: mode, + defaultStateValue: [picker, picker], + }); + + const triggerModesChange = ( + modes: [PanelMode, PanelMode], + values: RangeValue, ) => { - const startDate: DateType | null = values[0] || null; - let endDate: DateType | null = values[1] || null; - - // If user change start time is after end time, should reset end time to null - if ( - startDate && - endDate && - !isSameDate(generateConfig, startDate, endDate) && - generateConfig.isAfter(startDate, endDate) && - changedByStartTime - ) { - endDate = null; + setInnerModes(modes); + + if (onPanelChange) { + onPanelChange(values, modes); } + }; - setInnerValue([startDate, endDate]); + // ========================= Disable Date ========================== + const [disabledStartDate, disabledEndDate] = useRangeDisabled({ + selectedValue, + disabled: mergedDisabled, + disabledDate, + generateConfig, + }); + + // ============================= Open ============================== + const [mergedOpen, triggerInnerOpen] = useMergedState({ + value: open, + defaultValue: defaultOpen, + defaultStateValue: false, + postState: postOpen => + mergedDisabled[activePickerIndex] ? false : postOpen, + onChange: newOpen => { + if (onOpenChange) { + onOpenChange(newOpen); + } + + if (!newOpen && operationRef.current && operationRef.current.onClose) { + operationRef.current.onClose(); + } + }, + }); - const startStr = formatDate(startDate); - const endStr = formatDate(endDate); + const startOpen = mergedOpen && activePickerIndex === 0; + const endOpen = mergedOpen && activePickerIndex === 1; - if (onChange && canTriggerChange([startDate, endDate], allowEmpty)) { - onChange([startDate, endDate], [startStr, endStr]); + // ============================= Popup ============================= + // Popup min width + const [popupMinWidth, setPopupMinWidth] = React.useState(0); + React.useEffect(() => { + if (!mergedOpen && containerRef.current) { + setPopupMinWidth(containerRef.current.offsetWidth); } + }, [mergedOpen]); + + // ============================ Trigger ============================ + let triggerOpen: ( + newOpen: boolean, + index: 0 | 1, + preventChangeEvent?: boolean, + ) => void; + + const triggerChange = ( + newValue: RangeValue, + forceInput: boolean = true, + ) => { + const values = reorderValues(newValue, generateConfig); - if (onCalendarChange) { - onCalendarChange([startDate, endDate], [startStr, endStr]); + setSelectedValue(values); + + const startValue = getValue(values, 0); + const endValue = getValue(values, 1); + + const canStartValueTrigger = canValueTrigger( + startValue, + 0, + mergedDisabled, + allowEmpty, + ); + const canEndValueTrigger = canValueTrigger( + endValue, + 1, + mergedDisabled, + allowEmpty, + ); + + const canTrigger = + values === null || (canStartValueTrigger && canEndValueTrigger); + + if (canTrigger) { + // Trigger onChange only when value is validate + setInnerValue(values); + triggerOpen(false, activePickerIndex, true); + + if ( + onChange && + (!isEqual(generateConfig, getValue(mergedValue, 0), startValue) || + !isEqual(generateConfig, getValue(mergedValue, 1), endValue)) + ) { + onChange(values, [ + startValue + ? generateConfig.locale.format( + locale.locale, + startValue, + formatList[0], + ) + : '', + endValue + ? generateConfig.locale.format( + locale.locale, + endValue, + formatList[0], + ) + : '', + ]); + } + } else if (forceInput) { + // Open miss value panel to force user input + const missingValueIndex = canStartValueTrigger ? 1 : 0; + + // Same index means user choice to close picker + if (missingValueIndex === activePickerIndex) { + return; + } + + triggerOpen(true, missingValueIndex); + + // Delay to focus to avoid input blur trigger expired selectedValues + setTimeout(() => { + const inputRef = [startInputRef, endInputRef][missingValueIndex]; + if (inputRef.current) { + inputRef.current.focus(); + } + }, 0); } }; - // ============================== Open ============================== - const startPickerRef = React.useRef>(null); - const endPickerRef = React.useRef>(null); - const lastOpenIdRef = React.useRef(); - - const onStartOpenChange = (open: boolean) => { - if (!open && selectedValues && selectedValues[0]) { - lastOpenIdRef.current = window.setTimeout(() => { - if (endPickerRef.current) { - endPickerRef.current!.focus(); - endPickerRef.current!.open(); - } - }, 100); + triggerOpen = ( + newOpen: boolean, + index: 0 | 1, + preventChangeEvent: boolean = false, + ) => { + if (newOpen) { + setActivePickerIndex(index); + triggerInnerOpen(newOpen); + } else if (activePickerIndex === index) { + triggerInnerOpen(newOpen); + if (!preventChangeEvent) { + triggerChange(selectedValue); + } } + }; - if (props.onOpenChange) { - props.onOpenChange(open); + const forwardKeyDown = (e: React.KeyboardEvent) => { + if (mergedOpen && operationRef.current && operationRef.current.onKeyDown) { + // Let popup panel handle keyboard + return operationRef.current.onKeyDown(e); } + return false; }; - React.useEffect( - () => () => { - window.clearTimeout(lastOpenIdRef.current); - }, - [], + // ============================= Text ============================== + const sharedTextHooksProps = { + formatList, + generateConfig, + locale, + }; + + const startValueTexts = useValueTexts( + getValue(selectedValue, 0), + sharedTextHooksProps, ); - if (pickerRef) { - pickerRef.current = startPickerRef.current as any; - } + const endValueTexts = useValueTexts( + getValue(selectedValue, 1), + sharedTextHooksProps, + ); - // ============================== Mode ============================== - /** - * [Legacy] handle internal `onPanelChange` - */ - const [innerModes, setInnerModes] = React.useState((): [ - PanelMode, - PanelMode, - ] => { - if (mode) { - return mode; - } - if (picker) { - return [picker, picker]; + const onTextChange = (newText: string, index: 0 | 1) => { + const inputDate = generateConfig.locale.parse( + locale.locale, + newText, + formatList, + ); + + const disabledFunc = index === 0 ? disabledStartDate : disabledEndDate; + + if (inputDate && !disabledFunc(inputDate)) { + setSelectedValue(updateValues(selectedValue, inputDate, index)); + setViewDates(updateValues(viewDates, inputDate, index)); } - return showTime ? ['datetime', 'datetime'] : ['date', 'date']; + }; + + const [ + startText, + triggerStartTextChange, + resetStartText, + ] = useTextValueMapping({ + valueTexts: startValueTexts, + onTextChange: newText => onTextChange(newText, 0), + }); + + const [endText, triggerEndTextChange, resetEndText] = useTextValueMapping< + DateType + >({ + valueTexts: endValueTexts, + onTextChange: newText => onTextChange(newText, 1), }); - const [onStartPanelChange, onEndPanelChange] = React.useMemo< - [OnPanelChange | undefined, OnPanelChange | undefined] - >(() => { - const onInternalPanelChange = ( - newValue: DateType, - newMode: PanelMode, - source: 'start' | 'end', - ) => { - const values: [EventValue, EventValue] = [ - ...(mergedValue || []), - ] as [EventValue, EventValue]; - const modes: [PanelMode, PanelMode] = [...innerModes] as [ - PanelMode, - PanelMode, - ]; - - if (source === 'start') { - values[0] = newValue; - modes[0] = newMode; - } else { - values[1] = newValue; - modes[1] = newMode; - } - setInnerModes(modes); - if (onPanelChange) { - onPanelChange(values, modes); + // ============================= Input ============================= + const getSharedInputHookProps = ( + index: 0 | 1, + inputDivRef: React.RefObject, + resetText: () => void, + ) => ({ + forwardKeyDown, + onBlur, + isClickOutside: (target: EventTarget | null) => + !!( + panelDivRef.current && + !panelDivRef.current.contains(target as Node) && + inputDivRef.current && + !inputDivRef.current.contains(target as Node) && + onOpenChange + ), + onFocus: (e: React.FocusEvent) => { + setActivePickerIndex(index); + if (onFocus) { + onFocus(e); } - }; + }, + triggerOpen: (newOpen: boolean) => triggerOpen(newOpen, index), + onSubmit: () => { + triggerChange(selectedValue); + triggerOpen(false, index, true); + resetText(); + }, + onCancel: () => { + triggerOpen(false, index, true); + setSelectedValue(mergedValue); + resetText(); + }, + }); - return [ - (newVal: DateType, newMode: PanelMode) => { - onInternalPanelChange(newVal, newMode, 'start'); - }, - (newVal: DateType, newMode: PanelMode) => { - onInternalPanelChange(newVal, newMode, 'end'); - }, - ]; - }, [onPanelChange, mode, picker]); + const [ + startInputProps, + { focused: startFocused, typing: startTyping }, + ] = usePickerInput({ + ...getSharedInputHookProps(0, startInputDivRef, resetStartText), + open: startOpen, + }); + const [ + endInputProps, + { focused: endFocused, typing: endTyping }, + ] = usePickerInput({ + ...getSharedInputHookProps(1, endInputDivRef, resetEndText), + open: endOpen, + }); + + // ============================= Sync ============================== + // Close should sync back with text value React.useEffect(() => { - if (mode) { - setInnerModes(mode); + if (!mergedOpen) { + setSelectedValue(mergedValue); } - }, [mode]); - - // ============================= Render ============================= - const pickerProps = { - ...props, - defaultValue: undefined, - defaultPickerValue: undefined, - className: undefined, - style: undefined, - placeholder: undefined, - disabledTime: undefined, - onPanelChange: undefined, - }; - - // Time - let disabledStartTime: DisabledTime | undefined; - let disabledEndTime: DisabledTime | undefined; + }, [mergedOpen]); - if (disabledTime) { - disabledStartTime = (date: DateType | null) => disabledTime(date, 'start'); - disabledEndTime = (date: DateType | null) => disabledTime(date, 'end'); - } + // Sync innerValue with control mode + React.useEffect(() => { + // Sync select value + setSelectedValue(mergedValue); + }, [mergedValue]); - // Ranges - let extraFooterSelections: - | { - label: string; - onClick: React.MouseEventHandler; - }[] - | undefined; - if (ranges) { - extraFooterSelections = Object.keys(ranges).map(label => ({ - label, - onClick: () => { - const rangedValue = ranges[label]; - onInternalChange( - typeof rangedValue === 'function' ? rangedValue() : rangedValue, - false, - ); + // ============================ Private ============================ + if (pickerRef) { + pickerRef.current = { + focus: () => { + if (startInputRef.current) { + startInputRef.current.focus(); + } + }, + blur: () => { + if (startInputRef.current) { + startInputRef.current.blur(); + } + if (endInputRef.current) { + endInputRef.current.blur(); + } }, - })); + }; } - // End date should disabled before start date - const { disabledDate } = pickerProps; + // ============================= Panel ============================= + function renderPanel( + panelPosition: 'left' | 'right' | false = false, + panelProps: Partial> = {}, + ) { + let panelHoverRangedValue: RangeValue = null; + if (hoverRangedValue && hoverRangedValue[0] && hoverRangedValue[1]) { + panelHoverRangedValue = hoverRangedValue; + } - const disabledStartDate = (date: DateType) => { - let mergedDisabled = disabledDate ? disabledDate(date) : false; + return ( + + + {...(props as any)} + {...panelProps} + mode={mergedModes[activePickerIndex]} + generateConfig={generateConfig} + style={undefined} + disabledDate={ + activePickerIndex === 0 ? disabledStartDate : disabledEndDate + } + className={classNames({ + [`${prefixCls}-panel-focused`]: !startTyping && !endTyping, + })} + value={getValue(selectedValue, activePickerIndex)} + locale={locale} + tabIndex={-1} + onMouseDown={e => { + e.preventDefault(); + }} + onSelect={date => { + const values = updateValues(selectedValue, date, activePickerIndex); + + if (picker === 'date' && showTime) { + setSelectedValue(values); + } else { + // triggerChange will also update selected values + triggerChange(values); + } + }} + onPanelChange={(date, newMode) => { + triggerModesChange( + updateValues(mergedModes, newMode, activePickerIndex), + updateValues(selectedValue, date, activePickerIndex), + ); + + setViewDates(updateValues(viewDates, date, activePickerIndex)); + }} + onChange={undefined} + defaultValue={undefined} + defaultPickerValue={undefined} + /> + + ); + } - if (mergedSelectable[1] === false && value2) { - mergedDisabled = - !isSameDate(generateConfig, date, value2) && - generateConfig.isAfter(date, value2); + function renderPanels() { + if (picker !== 'time' && !showTime) { + const viewDate = viewDates[activePickerIndex]; + const nextViewDate = getClosingViewDate(viewDate, picker, generateConfig); + const currentMode = mergedModes[activePickerIndex]; + + const showDoublePanel = currentMode === picker; + + return ( + <> + {renderPanel(showDoublePanel ? 'left' : false, { + pickerValue: viewDate, + onPickerValueChange: (newViewDate: DateType) => { + setViewDates( + updateValues(viewDates, newViewDate, activePickerIndex), + ); + }, + })} + {showDoublePanel && + renderPanel('right', { + pickerValue: nextViewDate, + onPickerValueChange: newViewDate => { + setViewDates( + updateValues( + viewDates, + getClosingViewDate(newViewDate, picker, generateConfig, -1), + activePickerIndex, + ), + ); + }, + })} + + ); } + return renderPanel(); + } - return mergedDisabled; - }; + const rangePanel = ( +
+
+ + {renderPanels()} +
+ ); - const disabledEndDate = (date: DateType) => { - let mergedDisabled = disabledDate ? disabledDate(date) : false; + let suffixNode: React.ReactNode; + if (suffixIcon) { + suffixNode = {suffixIcon}; + } - if (!mergedDisabled && value1) { - // Can be the same date - mergedDisabled = - !isSameDate(generateConfig, value1, date) && - generateConfig.isAfter(value1, date); - } + let clearNode: React.ReactNode; + if ( + allowClear && + ((getValue(mergedValue, 0) && !mergedDisabled[0]) || + (getValue(mergedValue, 1) && !mergedDisabled[1])) + ) { + clearNode = ( + { + e.stopPropagation(); + let values = mergedValue; + + if (!mergedDisabled[0]) { + values = updateValues(values, null, 0); + } + if (!mergedDisabled[1]) { + values = updateValues(values, null, 1); + } + + triggerChange(values, false); + }} + className={`${prefixCls}-clear`} + > + {clearIcon || } + + ); + } - return mergedDisabled; + const inputSharedProps = { + size: getInputSize(picker, formatList[0]), }; return ( - -
- - {...pickerProps} - ref={startPickerRef} - prefixCls={prefixCls} - value={value1} - placeholder={placeholder && placeholder[0]} - defaultPickerValue={defaultPickerValue && defaultPickerValue[0]} - {...{ disabledTime: disabledStartTime, showTime: startShowTime }} // Fix ts define - mode={mode && mode[0]} - disabled={disabled || mergedSelectable[0] === false} - disabledDate={disabledStartDate} - onChange={date => { - onInternalChange([date, value2], true); - }} - onSelect={onStartSelect} - onFocus={onFocus} - onBlur={onBlur} - onPanelChange={onStartPanelChange} - onOpenChange={onStartOpenChange} - /> - {separator} - - {...pickerProps} - ref={endPickerRef} - prefixCls={prefixCls} - value={value2} - placeholder={placeholder && placeholder[1]} - defaultPickerValue={defaultPickerValue && defaultPickerValue[1]} - {...{ disabledTime: disabledEndTime, showTime: endShowTime }} // Fix ts define - mode={mode && mode[1]} - disabled={disabled || mergedSelectable[1] === false} - disabledDate={disabledEndDate} - onChange={date => { - onInternalChange([value1, date], false); - }} - onSelect={onEndSelect} - onFocus={onFocus} - onBlur={onBlur} - onPanelChange={onEndPanelChange} - /> -
-
+
+
+ +
+ {separator} +
+ +
+ {suffixNode} + {clearNode} +
+ + ); } @@ -502,7 +837,7 @@ class RangePicker extends React.Component< render() { return ( - + {...this.props} pickerRef={this.pickerRef as React.MutableRefObject} /> diff --git a/src/hooks/useMergeState.ts b/src/hooks/useMergeState.ts new file mode 100644 index 000000000..5a00eb488 --- /dev/null +++ b/src/hooks/useMergeState.ts @@ -0,0 +1,44 @@ +import * as React from 'react'; + +export default function useMergedState({ + value, + defaultValue, + defaultStateValue, + onChange, + postState, +}: { + value?: T; + defaultValue?: T | (() => T); + defaultStateValue: T | (() => T); + onChange?: (value: T, prevValue: T) => void; + postState?: (value: T) => T; +}): [R, (value: T) => void] { + const [innerValue, setInnerValue] = React.useState(() => { + if (value !== undefined) { + return value; + } + if (defaultValue !== undefined) { + return typeof defaultValue === 'function' + ? (defaultValue as any)() + : defaultValue; + } + return typeof defaultStateValue === 'function' + ? (defaultStateValue as any)() + : defaultStateValue; + }); + + let mergedValue = value !== undefined ? value : innerValue; + if (postState) { + mergedValue = postState(mergedValue); + } + + function triggerChange(newValue: T) { + setInnerValue(newValue); + + if (mergedValue !== newValue && onChange) { + onChange(newValue, mergedValue); + } + } + + return [(mergedValue as unknown) as R, triggerChange]; +} diff --git a/src/hooks/usePickerInput.ts b/src/hooks/usePickerInput.ts new file mode 100644 index 000000000..48a47b68c --- /dev/null +++ b/src/hooks/usePickerInput.ts @@ -0,0 +1,121 @@ +import * as React from 'react'; +import KeyCode from 'rc-util/lib/KeyCode'; +import { addGlobalMouseDownEvent } from '../utils/uiUtil'; + +export default function usePickerInput({ + open, + isClickOutside, + triggerOpen, + forwardKeyDown, + onSubmit, + onCancel, + onFocus, + onBlur, +}: { + open: boolean; + isClickOutside: (clickElement: EventTarget | null) => boolean; + triggerOpen: (open: boolean) => void; + forwardKeyDown: (e: React.KeyboardEvent) => boolean; + onSubmit: () => void; + onCancel: () => void; + onFocus?: React.FocusEventHandler; + onBlur?: React.FocusEventHandler; +}): [ + React.DOMAttributes, + { focused: boolean; typing: boolean }, +] { + const [typing, setTyping] = React.useState(false); + const [focused, setFocused] = React.useState(false); + + /** + * We will prevent blur to handle open event when user click outside, + * since this will repeat trigger `onOpenChange` event. + */ + const preventBlurRef = React.useRef(false); + + const inputProps: React.DOMAttributes = { + onMouseDown: () => { + setTyping(true); + triggerOpen(true); + }, + onKeyDown: e => { + switch (e.which) { + case KeyCode.ENTER: { + if (!open) { + triggerOpen(true); + } else { + onSubmit(); + setTyping(true); + } + return; + } + + case KeyCode.TAB: { + if (typing && open && !e.shiftKey) { + setTyping(false); + e.preventDefault(); + } else if (!typing && open) { + if (!forwardKeyDown(e) && e.shiftKey) { + setTyping(true); + e.preventDefault(); + } + } + return; + } + + case KeyCode.ESC: { + setTyping(true); + onCancel(); + return; + } + } + + if (!open && ![KeyCode.SHIFT].includes(e.which)) { + triggerOpen(true); + } else if (!typing) { + // Let popup panel handle keyboard + forwardKeyDown(e); + } + }, + + onFocus: e => { + setTyping(true); + setFocused(true); + + if (onFocus) { + onFocus(e); + } + }, + + onBlur: e => { + if (preventBlurRef.current) { + preventBlurRef.current = false; + return; + } + + triggerOpen(false); + setFocused(false); + + if (onBlur) { + onBlur(e); + } + }, + }; + + // Global click handler + React.useEffect(() => + addGlobalMouseDownEvent(({ target }: MouseEvent) => { + if (open && isClickOutside(target)) { + preventBlurRef.current = true; + triggerOpen(false); + + // Always set back in case `onBlur` prevented by user + window.setTimeout(() => { + preventBlurRef.current = false; + }, 0); + } + }), + ); + + return [inputProps, { focused, typing }]; +} diff --git a/src/hooks/useRangeDisabled.ts b/src/hooks/useRangeDisabled.ts new file mode 100644 index 000000000..75d367a71 --- /dev/null +++ b/src/hooks/useRangeDisabled.ts @@ -0,0 +1,51 @@ +import { RangeValue } from '../interface'; +import { getValue } from '../utils/miscUtil'; +import { GenerateConfig } from '../generate'; +import { isSameDate } from '../utils/dateUtil'; + +export default function useRangeDisabled({ + selectedValue, + disabledDate, + disabled, + generateConfig, +}: { + selectedValue: RangeValue; + disabledDate?: (date: DateType) => boolean; + disabled: [boolean, boolean]; + generateConfig: GenerateConfig; +}) { + const startDate = getValue(selectedValue, 0); + const endDate = getValue(selectedValue, 1); + + function disabledStartDate(date: DateType) { + if (disabledDate && disabledDate(date)) { + return true; + } + + if (disabled[1] && endDate) { + return ( + !isSameDate(generateConfig, date, endDate) && + generateConfig.isAfter(date, endDate) + ); + } + + return false; + } + + function disableEndDate(date: DateType) { + if (disabledDate && disabledDate(date)) { + return true; + } + + if (startDate) { + return ( + !isSameDate(generateConfig, date, startDate) && + generateConfig.isAfter(startDate, date) + ); + } + + return false; + } + + return [disabledStartDate, disableEndDate]; +} diff --git a/src/hooks/useTextValueMapping.ts b/src/hooks/useTextValueMapping.ts new file mode 100644 index 000000000..bb6f35c44 --- /dev/null +++ b/src/hooks/useTextValueMapping.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; + +export default function useTextValueMapping({ + valueTexts, + onTextChange, +}: { + /** Must useMemo, to assume that `valueTexts` only match on the first change */ + valueTexts: string[]; + onTextChange: (text: string) => void; +}): [string, React.ChangeEventHandler, () => void] { + const [text, setInnerText] = React.useState(''); + + function triggerTextChange({ + target: { value }, + }: React.ChangeEvent) { + setInnerText(value); + onTextChange(value); + } + + function resetText() { + setInnerText(valueTexts[0]); + } + + React.useEffect(() => { + if (valueTexts.every(valText => valText !== text)) { + resetText(); + } + }, [valueTexts.join('||')]); + + return [text, triggerTextChange, resetText]; +} diff --git a/src/hooks/useValueTexts.ts b/src/hooks/useValueTexts.ts new file mode 100644 index 000000000..e16bd0b94 --- /dev/null +++ b/src/hooks/useValueTexts.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { GenerateConfig } from '../generate'; +import { Locale } from '../interface'; + +export default function useValueTexts( + value: DateType | null, + { + formatList, + generateConfig, + locale, + }: { + formatList: string[]; + generateConfig: GenerateConfig; + locale: Locale; + }, +) { + return React.useMemo(() => { + if (!value) { + return ['']; + } + return formatList.map(subFormat => + generateConfig.locale.format(locale.locale, value, subFormat), + ); + }, [value]); +} diff --git a/src/interface.ts b/src/interface.ts index 817e55a05..ae038eec0 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -39,14 +39,7 @@ export interface Locale { shortMonths?: string[]; } -export type PanelMode = - | 'time' - | 'datetime' - | 'date' - | 'week' - | 'month' - | 'year' - | 'decade'; +export type PanelMode = 'time' | 'date' | 'week' | 'month' | 'year' | 'decade'; export type PickerMode = Exclude; @@ -97,3 +90,8 @@ export type OnPanelChange = ( value: DateType, mode: PanelMode, ) => void; + +export type EventValue = DateType | null; +export type RangeValue = + | [EventValue, EventValue] + | null; diff --git a/src/panels/DatePanel/DateBody.tsx b/src/panels/DatePanel/DateBody.tsx index 8a80282e9..b45036a3f 100644 --- a/src/panels/DatePanel/DateBody.tsx +++ b/src/panels/DatePanel/DateBody.tsx @@ -10,6 +10,7 @@ import { } from '../../utils/dateUtil'; import { Locale } from '../../interface'; import RangeContext from '../../RangeContext'; +import PanelContext from '../../PanelContext'; export type DateRender = ( currentDate: DateType, @@ -48,7 +49,8 @@ function DateBody({ dateRender, onSelect, }: DateBodyProps) { - const { rangedValue } = React.useContext(RangeContext); + const { rangedValue, hoverRangedValue } = React.useContext(RangeContext); + const { onDateMouseEnter, onDateMouseLeave } = React.useContext(PanelContext); const datePrefixCls = `${prefixCls}-cell`; const weekFirstDay = generateConfig.locale.getWeekFirstDay(locale.locale); @@ -95,10 +97,19 @@ function DateBody({ 'YYYY-MM-DD', )} onClick={() => { - if (disabled) { - return; + if (!disabled) { + onSelect(currentDate); + } + }} + onMouseEnter={() => { + if (!disabled && onDateMouseEnter) { + onDateMouseEnter(currentDate); + } + }} + onMouseLeave={() => { + if (!disabled && onDateMouseLeave) { + onDateMouseLeave(currentDate); } - onSelect(currentDate); }} className={classNames(datePrefixCls, { [`${datePrefixCls}-disabled`]: disabled, @@ -123,6 +134,22 @@ function DateBody({ rangedValue && rangedValue[1], currentDate, ), + [`${datePrefixCls}-range-hover`]: isInRange( + generateConfig, + hoverRangedValue && hoverRangedValue[0], + hoverRangedValue && hoverRangedValue[1], + currentDate, + ), + [`${datePrefixCls}-range-hover-start`]: isSameDate( + generateConfig, + hoverRangedValue && hoverRangedValue[0], + currentDate, + ), + [`${datePrefixCls}-range-hover-end`]: isSameDate( + generateConfig, + hoverRangedValue && hoverRangedValue[1], + currentDate, + ), [`${datePrefixCls}-today`]: isSameDate( generateConfig, today, diff --git a/src/panels/DatePanel/index.tsx b/src/panels/DatePanel/index.tsx index 4da2a649c..65812135a 100644 --- a/src/panels/DatePanel/index.tsx +++ b/src/panels/DatePanel/index.tsx @@ -90,10 +90,10 @@ function DatePanel(props: DatePanelProps) { onMonthChange(1); }} onMonthClick={() => { - onPanelChange('month', value || viewDate); + onPanelChange('month', viewDate); }} onYearClick={() => { - onPanelChange('year', value || viewDate); + onPanelChange('year', viewDate); }} /> {onSuperPrev && ( @@ -41,6 +48,7 @@ function Header({ onClick={onSuperPrev} tabIndex={-1} className={`${prefixCls}-super-prev-btn`} + style={hidePrevBtn ? HIDDEN_STYLE : {}} > {superPrevIcon} @@ -51,6 +59,7 @@ function Header({ onClick={onPrev} tabIndex={-1} className={`${prefixCls}-prev-btn`} + style={hidePrevBtn ? HIDDEN_STYLE : {}} > {prevIcon} @@ -62,6 +71,7 @@ function Header({ onClick={onNext} tabIndex={-1} className={`${prefixCls}-next-btn`} + style={hideNextBtn ? HIDDEN_STYLE : {}} > {nextIcon} @@ -72,6 +82,7 @@ function Header({ onClick={onSuperNext} tabIndex={-1} className={`${prefixCls}-super-next-btn`} + style={hideNextBtn ? HIDDEN_STYLE : {}} > {superNextIcon} diff --git a/src/panels/MonthPanel/MonthBody.tsx b/src/panels/MonthPanel/MonthBody.tsx index c6b8eacd9..bc78c63db 100644 --- a/src/panels/MonthPanel/MonthBody.tsx +++ b/src/panels/MonthPanel/MonthBody.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import { GenerateConfig } from '../../generate'; import { Locale } from '../../interface'; import { isSameMonth } from '../../utils/dateUtil'; +import PanelContext from '../../PanelContext'; export const MONTH_COL_COUNT = 3; const MONTH_ROW_COUNT = 4; @@ -33,6 +34,8 @@ function MonthBody({ monthCellRender, onSelect, }: MonthBodyProps) { + const { onDateMouseEnter, onDateMouseLeave } = React.useContext(PanelContext); + const monthPrefixCls = `${prefixCls}-cell`; const rows: React.ReactNode[] = []; @@ -70,10 +73,19 @@ function MonthBody({ ), })} onClick={() => { - if (disabled) { - return; + if (!disabled) { + onSelect(monthDate); + } + }} + onMouseEnter={() => { + if (!disabled && onDateMouseEnter) { + onDateMouseEnter(monthDate); + } + }} + onMouseLeave={() => { + if (!disabled && onDateMouseLeave) { + onDateMouseLeave(monthDate); } - onSelect(monthDate); }} > {monthCellRender ? ( diff --git a/src/panels/MonthPanel/index.tsx b/src/panels/MonthPanel/index.tsx index 281642a7f..324ad75f1 100644 --- a/src/panels/MonthPanel/index.tsx +++ b/src/panels/MonthPanel/index.tsx @@ -60,7 +60,7 @@ function MonthPanel(props: MonthPanelProps) { onYearChange(1); }} onYearClick={() => { - onPanelChange('year', value || viewDate); + onPanelChange('year', viewDate); }} /> diff --git a/src/panels/YearPanel/YearBody.tsx b/src/panels/YearPanel/YearBody.tsx index 69f3c62c9..b1ff05adf 100644 --- a/src/panels/YearPanel/YearBody.tsx +++ b/src/panels/YearPanel/YearBody.tsx @@ -2,7 +2,8 @@ import * as React from 'react'; import classNames from 'classnames'; import { GenerateConfig } from '../../generate'; import { YEAR_DECADE_COUNT } from '.'; -import { Locale } from '../../interface'; +import { Locale, NullableDateType } from '../../interface'; +import PanelContext from '../../PanelContext'; export const YEAR_COL_COUNT = 3; const YEAR_ROW_COUNT = 4; @@ -11,6 +12,7 @@ export interface YearBodyProps { prefixCls: string; locale: Locale; generateConfig: GenerateConfig; + value: NullableDateType; viewDate: DateType; disabledDate?: (date: DateType) => boolean; onSelect: (value: DateType) => void; @@ -18,15 +20,19 @@ export interface YearBodyProps { function YearBody({ prefixCls, + value, viewDate, locale, generateConfig, disabledDate, onSelect, }: YearBodyProps) { + const { onDateMouseEnter, onDateMouseLeave } = React.useContext(PanelContext); + const yearPrefixCls = `${prefixCls}-cell`; const rows: React.ReactNode[] = []; + const valueYearNumber = value ? generateConfig.getYear(value) : null; const yearNumber = generateConfig.getYear(viewDate); const startYear = Math.floor(yearNumber / YEAR_DECADE_COUNT) * YEAR_DECADE_COUNT; @@ -54,13 +60,23 @@ function YearBody({ [`${yearPrefixCls}-disabled`]: disabled, [`${yearPrefixCls}-in-view`]: startYear <= currentYearNumber && currentYearNumber <= endYear, - [`${yearPrefixCls}-selected`]: currentYearNumber === yearNumber, + [`${yearPrefixCls}-selected`]: + currentYearNumber === valueYearNumber, })} onClick={() => { - if (disabled) { - return; + if (!disabled) { + onSelect(yearDate); + } + }} + onMouseEnter={() => { + if (!disabled && onDateMouseEnter) { + onDateMouseEnter(yearDate); + } + }} + onMouseLeave={() => { + if (!disabled && onDateMouseLeave) { + onDateMouseLeave(yearDate); } - onSelect(yearDate); }} >
{currentYearNumber}
diff --git a/src/panels/YearPanel/index.tsx b/src/panels/YearPanel/index.tsx index 8f9b263ac..23b88ef23 100644 --- a/src/panels/YearPanel/index.tsx +++ b/src/panels/YearPanel/index.tsx @@ -62,7 +62,7 @@ function YearPanel(props: YearPanelProps) { onDecadeChange(1); }} onDecadeClick={() => { - onPanelChange('decade', value || viewDate); + onPanelChange('decade', viewDate); }} /> (value1: T, value2: T): boolean | undefined { return undefined; } +export function isSameYear( + generateConfig: GenerateConfig, + year1: NullableDateType, + year2: NullableDateType, +) { + const equal = isNullEqual(year1, year2); + if (typeof equal === 'boolean') { + return equal; + } + + return generateConfig.getYear(year1!) === generateConfig.getYear(year2!); +} + export function isSameMonth( generateConfig: GenerateConfig, month1: NullableDateType, @@ -25,7 +38,7 @@ export function isSameMonth( } return ( - generateConfig.getYear(month1!) === generateConfig.getYear(month2!) && + isSameYear(generateConfig, month1, month2) && generateConfig.getMonth(month1!) === generateConfig.getMonth(month2!) ); } @@ -136,3 +149,19 @@ export function getWeekStartDate( return value; } } + +export function getClosingViewDate( + viewDate: DateType, + picker: PickerMode, + generateConfig: GenerateConfig, + offset: number = 1, +): DateType { + switch (picker) { + case 'year': + return generateConfig.addYear(viewDate, offset * 10); + case 'month': + return generateConfig.addYear(viewDate, offset); + default: + return generateConfig.addMonth(viewDate, offset); + } +} diff --git a/src/utils/miscUtil.ts b/src/utils/miscUtil.ts index badd058a0..ed2a203cb 100644 --- a/src/utils/miscUtil.ts +++ b/src/utils/miscUtil.ts @@ -37,3 +37,34 @@ export default function getDataOrAriaProps(props: any) { return retProps; } + +export function getValue( + values: null | undefined | [T | null, T | null], + index: number, +): T | null { + return values ? values[index] : null; +} + +type UpdateValue = (prev: T) => T; + +export function updateValues( + values: [T | null, T | null] | null, + value: T | UpdateValue, + index: number, +): R { + const newValues: [T | null, T | null] = [ + getValue(values, 0), + getValue(values, 1), + ]; + + newValues[index] = + typeof value === 'function' + ? (value as UpdateValue)(newValues[index]) + : value; + + if (!newValues[0] && !newValues[1]) { + return (null as unknown) as R; + } + + return (newValues as unknown) as R; +} diff --git a/src/utils/uiUtil.ts b/src/utils/uiUtil.ts index 2d1aa2b78..5030b5ff0 100644 --- a/src/utils/uiUtil.ts +++ b/src/utils/uiUtil.ts @@ -214,4 +214,4 @@ export const PickerModeMap: Record< week: getWeekNextMode, time: null, date: null, -}; +}; \ No newline at end of file