diff --git a/examples/disabledDate.tsx b/examples/disabledDate.tsx new file mode 100644 index 000000000000..7ce49d6d01bb --- /dev/null +++ b/examples/disabledDate.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import type { Moment } from 'moment'; +import moment from 'moment'; +import Picker from '../src/Picker'; +import momentGenerateConfig from '../src/generate/moment'; +import enUS from '../src/locale/en_US'; +import '../assets/index.less'; + +export default () => { + const [value, setValue] = React.useState(undefined); + + const onSelect = (newValue: Moment) => { + console.log('Select:', newValue); + }; + + const onChange = (newValue: Moment | null, formatString?: string) => { + console.log('Change:', newValue, formatString); + setValue(newValue); + }; + + function disabledDateBeforeToday(current: Moment) { + return current <= moment().endOf('day'); + } + + function disabledDateAfterToday(current: Moment) { + return current >= moment().endOf('day'); + } + + function disabledDateAfterTodayAndBeforeLastYear(current) { + return current >= moment().startOf('day') || current < moment().subtract(1, 'years'); + } + + const sharedProps = { + generateConfig: momentGenerateConfig, + value, + onSelect, + onChange, + }; + + return ( +
+

Value: {value ? value.format('YYYY-MM-DD HH:mm:ss') : 'null'}

+

Date Mode

+
+
+

Before Today

+ {...sharedProps} disabledDate={disabledDateBeforeToday} locale={enUS} /> +
+
+

After Today

+ {...sharedProps} disabledDate={disabledDateAfterToday} locale={enUS} /> +
+
+

After Today or Before last year

+ + {...sharedProps} + disabledDate={disabledDateAfterTodayAndBeforeLastYear} + locale={enUS} + /> +
+
+
+ ); +}; diff --git a/src/PanelContext.tsx b/src/PanelContext.tsx index 33f7c3fc0e77..32bccae47fab 100644 --- a/src/PanelContext.tsx +++ b/src/PanelContext.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { OnSelect } from './interface'; +import type { OnSelect, PanelMode } from './interface'; export type ContextOperationRefProps = { onKeyDown?: (e: React.KeyboardEvent) => boolean; @@ -18,6 +18,7 @@ export type PanelContextProps = { onSelect?: OnSelect; hideRanges?: boolean; open?: boolean; + mode?: PanelMode; /** Only used for TimePicker and this is a deprecated prop */ defaultOpenValue?: any; diff --git a/src/Picker.tsx b/src/Picker.tsx index 47281f7847a1..2463473cacdd 100644 --- a/src/Picker.tsx +++ b/src/Picker.tsx @@ -358,6 +358,12 @@ function InnerPicker(props: PickerProps) { }; } + const [hoverValue, onEnter, onLeave] = useHoverValue(text, { + formatList, + generateConfig, + locale, + }); + // ============================= Panel ============================= const panelProps = { // Remove `picker` & `format` here since TimePicker is little different with other panel @@ -384,6 +390,11 @@ function InnerPicker(props: PickerProps) { setSelectedValue(date); }} direction={direction} + onPanelChange={(viewDate, mode) => { + const { onPanelChange } = props; + onLeave(true); + onPanelChange?.(viewDate, mode); + }} /> ); @@ -446,12 +457,6 @@ function InnerPicker(props: PickerProps) { }; const popupPlacement = direction === 'rtl' ? 'bottomRight' : 'bottomLeft'; - const [hoverValue, onEnter, onLeave] = useHoverValue(text, { - formatList, - generateConfig, - locale, - }); - return ( (props: PickerPanelProps) { onViewDateChange: setViewDate, sourceMode, onPanelChange: onInternalPanelChange, - disabledDate: mergedMode !== 'decade' ? disabledDate : undefined, + disabledDate, }; delete pickerProps.onChange; delete pickerProps.onSelect; @@ -518,6 +518,7 @@ function PickerPanel(props: PickerPanelProps) { (props: RangePickerProps) { locale={locale} tabIndex={-1} onPanelChange={(date, newMode) => { + // clear hover value when panel change + if (mergedActivePickerIndex === 0) { + onStartLeave(true); + } + if (mergedActivePickerIndex === 1) { + onEndLeave(true); + } triggerModesChange( updateValues(mergedModes, newMode, mergedActivePickerIndex), updateValues(selectedValue, date, mergedActivePickerIndex), diff --git a/src/panels/DecadePanel/DecadeBody.tsx b/src/panels/DecadePanel/DecadeBody.tsx index 4646aca1a64e..079464ee0272 100644 --- a/src/panels/DecadePanel/DecadeBody.tsx +++ b/src/panels/DecadePanel/DecadeBody.tsx @@ -16,7 +16,7 @@ export type YearBodyProps = { function DecadeBody(props: YearBodyProps) { const DECADE_UNIT_DIFF_DES = DECADE_UNIT_DIFF - 1; - const { prefixCls, viewDate, generateConfig, disabledDate } = props; + const { prefixCls, viewDate, generateConfig } = props; const cellPrefixCls = `${prefixCls}-cell`; @@ -35,13 +35,10 @@ function DecadeBody(props: YearBodyProps) { ); const getCellClassName = (date: DateType) => { - const disabled = disabledDate && disabledDate(date); - const startDecadeNumber = generateConfig.getYear(date); const endDecadeNumber = startDecadeNumber + DECADE_UNIT_DIFF_DES; return { - [`${cellPrefixCls}-disabled`]: disabled, [`${cellPrefixCls}-in-view`]: startDecadeYear <= startDecadeNumber && endDecadeNumber <= endDecadeYear, [`${cellPrefixCls}-selected`]: startDecadeNumber === decadeYearNumber, diff --git a/src/panels/PanelBody.tsx b/src/panels/PanelBody.tsx index ded33c624ae2..fce93d478549 100644 --- a/src/panels/PanelBody.tsx +++ b/src/panels/PanelBody.tsx @@ -4,6 +4,7 @@ import PanelContext from '../PanelContext'; import type { GenerateConfig } from '../generate'; import { getLastDay } from '../utils/timeUtil'; import type { PanelMode } from '../interface'; +import { getCellDateDisabled } from '../utils/dateUtil'; export type PanelBodyProps = { prefixCls: string; @@ -46,7 +47,7 @@ export default function PanelBody({ titleCell, headerCells, }: PanelBodyProps) { - const { onDateMouseEnter, onDateMouseLeave } = React.useContext(PanelContext); + const { onDateMouseEnter, onDateMouseLeave, mode } = React.useContext(PanelContext); const cellPrefixCls = `${prefixCls}-cell`; @@ -60,7 +61,12 @@ export default function PanelBody({ for (let j = 0; j < colNum; j += 1) { const offset = i * colNum + j; const currentDate = getCellDate(baseDate, offset); - const disabled = disabledDate && disabledDate(currentDate); + const disabled = getCellDateDisabled({ + cellDate: currentDate, + mode, + disabledDate, + generateConfig, + }); if (j === 0) { rowStartDate = currentDate; @@ -78,8 +84,11 @@ export default function PanelBody({ title={title} className={classNames(cellPrefixCls, { [`${cellPrefixCls}-disabled`]: disabled, - [`${cellPrefixCls}-start`]: getCellText(currentDate) === 1 || picker === 'year' && Number(title) % 10 === 0, - [`${cellPrefixCls}-end`]: title === getLastDay(generateConfig, currentDate) || picker === 'year' && Number(title) % 10 === 9, + [`${cellPrefixCls}-start`]: + getCellText(currentDate) === 1 || (picker === 'year' && Number(title) % 10 === 0), + [`${cellPrefixCls}-end`]: + title === getLastDay(generateConfig, currentDate) || + (picker === 'year' && Number(title) % 10 === 9), ...getCellClassName(currentDate), })} onClick={() => { diff --git a/src/utils/dateUtil.ts b/src/utils/dateUtil.ts index c77c2439d7d7..677d73416854 100644 --- a/src/utils/dateUtil.ts +++ b/src/utils/dateUtil.ts @@ -1,5 +1,6 @@ +import { DECADE_UNIT_DIFF } from '../panels/DecadePanel/index'; +import type { PanelMode, NullableDateType, PickerMode, Locale, CustomFormat } from '../interface'; import type { GenerateConfig } from '../generate'; -import type { NullableDateType, PickerMode, Locale, CustomFormat } from '../interface'; export const WEEK_DAY_COUNT = 7; @@ -228,3 +229,93 @@ export function parseValue( return generateConfig.locale.parse(locale.locale, value, formatList as string[]); } + +// eslint-disable-next-line consistent-return +export function getCellDateDisabled({ + cellDate, + mode, + disabledDate, + generateConfig, +}: { + cellDate: DateType; + mode: Omit; + generateConfig: GenerateConfig; + disabledDate?: (date: DateType) => boolean; +}): boolean { + if (!disabledDate) return false; + // Whether cellDate is disabled in range + const getDisabledFromRange = ( + currentMode: 'date' | 'month' | 'year', + start: number, + end: number, + ) => { + let current = start; + while (current <= end) { + let date: DateType; + switch (currentMode) { + case 'date': { + date = generateConfig.setDate(cellDate, current); + if (!disabledDate(date)) { + return false; + } + break; + } + case 'month': { + date = generateConfig.setMonth(cellDate, current); + if ( + !getCellDateDisabled({ + cellDate: date, + mode: 'month', + generateConfig, + disabledDate, + }) + ) { + return false; + } + break; + } + case 'year': { + date = generateConfig.setYear(cellDate, current); + if ( + !getCellDateDisabled({ + cellDate: date, + mode: 'year', + generateConfig, + disabledDate, + }) + ) { + return false; + } + break; + } + } + current += 1; + } + return true; + }; + switch (mode) { + case 'date': + case 'week': { + return disabledDate(cellDate); + } + case 'month': { + const startDate = 1; + const endDate = generateConfig.getDate(generateConfig.getEndDate(cellDate)); + return getDisabledFromRange('date', startDate, endDate); + } + case 'quarter': { + const startMonth = Math.floor(generateConfig.getMonth(cellDate) / 3) * 3; + const endMonth = startMonth + 2; + return getDisabledFromRange('month', startMonth, endMonth); + } + case 'year': { + return getDisabledFromRange('month', 0, 11); + } + case 'decade': { + const year = generateConfig.getYear(cellDate); + const startYear = Math.floor(year / DECADE_UNIT_DIFF) * DECADE_UNIT_DIFF; + const endYear = startYear + DECADE_UNIT_DIFF - 1; + return getDisabledFromRange('year', startYear, endYear); + } + } +} diff --git a/tests/picker.spec.tsx b/tests/picker.spec.tsx index 966322ff223e..807cdafac56a 100644 --- a/tests/picker.spec.tsx +++ b/tests/picker.spec.tsx @@ -4,8 +4,8 @@ import { act } from 'react-dom/test-utils'; import { spyElementPrototypes } from 'rc-util/lib/test/domHook'; import KeyCode from 'rc-util/lib/KeyCode'; import { resetWarned } from 'rc-util/lib/warning'; -import { Moment } from 'moment'; -import { PanelMode, PickerMode } from '../src/interface'; +import type { Moment } from 'moment'; +import type { PanelMode, PickerMode } from '../src/interface'; import { mount, getMoment, isSame, MomentPicker } from './util/commonUtil'; describe('Picker.Basic', () => { @@ -879,7 +879,7 @@ describe('Picker.Basic', () => { it('should not open if prevent default is called', () => { const keyDown = jest.fn(({ which }, preventDefault) => { - if(which === 13) preventDefault(); + if (which === 13) preventDefault(); }); const wrapper = mount(); @@ -892,5 +892,68 @@ describe('Picker.Basic', () => { wrapper.keyDown(KeyCode.ENTER); expect(wrapper.isOpen()).toBeFalsy(); }); - }) + }); + + describe('disabledDate', () => { + function disabledDate(current: Moment) { + return current <= getMoment('2020-12-28 00:00:00').endOf('day'); + } + const wrapper = mount(); + // Date Panel + Array.from({ + length: 31 + }).forEach((v, i) => { + const cell = wrapper.findCell(`${i + 1}`); + // >= 29 + if (i >= 28) { + expect(cell.hasClass('rc-picker-cell-disabled')).toBeFalsy(); + } else { + expect(cell.hasClass('rc-picker-cell-disabled')).toBeTruthy(); + } + }); + wrapper.find('.rc-picker-month-btn').simulate('click'); + // Month Panel + Array.from({ + length: 12 + }).forEach((v, i) => { + const cell = wrapper.find('.rc-picker-cell-in-view').at(i); + // >= 12 + if (i >= 11) { + expect(cell.hasClass('rc-picker-cell-disabled')).toBeFalsy(); + } else { + expect(cell.hasClass('rc-picker-cell-disabled')).toBeTruthy(); + } + }); + wrapper.find('.rc-picker-year-btn').simulate('click'); + // Year Panel + Array.from({ + length: 10 + }).forEach((v, i) => { + const cell = wrapper.find('.rc-picker-cell-in-view').at(i); + // >= 2020 + expect(cell.hasClass('rc-picker-cell-disabled')).toBeFalsy(); + }); + // Decade Panel + Array.from({ + length: 8 + }).forEach((v, i) => { + const cell = wrapper.find('.rc-picker-cell-in-view').at(i); + // >= 2020 + expect(cell.hasClass('rc-picker-cell-disabled')).toBeFalsy(); + }); + + const quarterWrapper = mount(); + // quarter Panel + Array.from({ + length: 4 + }).forEach((v, i) => { + const cell = quarterWrapper.find('.rc-picker-cell-in-view').at(i); + // >= 4 + if (i >= 3) { + expect(cell.hasClass('rc-picker-cell-disabled')).toBeFalsy(); + } else { + expect(cell.hasClass('rc-picker-cell-disabled')).toBeTruthy(); + } + }); + }); });