diff --git a/src/PickerPanel.tsx b/src/PickerPanel.tsx index 5aef42b95..60ee18dc6 100644 --- a/src/PickerPanel.tsx +++ b/src/PickerPanel.tsx @@ -38,6 +38,7 @@ import { MonthCellRender } from './panels/MonthPanel/MonthBody'; import RangeContext from './RangeContext'; import getExtraFooter from './utils/getExtraFooter'; import getRanges from './utils/getRanges'; +import { getLowerBoundTime, setTime } from './utils/timeUtil'; export interface PickerPanelSharedProps { prefixCls?: string; @@ -146,13 +147,29 @@ function PickerPanel(props: PickerPanelProps) { onOk, components, direction, + hourStep = 1, + minuteStep = 1, + secondStep = 1, } = props as MergedPickerPanelProps; const needConfirmButton: boolean = (picker === 'date' && !!showTime) || picker === 'time'; + const isHourStepValid = 24 % hourStep === 0; + const isMinuteStepValid = 60 % minuteStep === 0; + const isSecondStepValid = 60 % secondStep === 0; + if (process.env.NODE_ENV !== 'production') { warning(!value || generateConfig.isValidate(value), 'Invalidate date pass to `value`.'); warning(!value || generateConfig.isValidate(value), 'Invalidate date pass to `defaultValue`.'); + warning(isHourStepValid, `\`hourStep\` ${hourStep} is invalid. It should be a factor of 24.`); + warning( + isMinuteStepValid, + `\`minuteStep\` ${minuteStep} is invalid. It should be a factor of 60.`, + ); + warning( + isSecondStepValid, + `\`secondStep\` ${secondStep} is invalid. It should be a factor of 60.`, + ); } // ============================ State ============================= @@ -434,6 +451,26 @@ function PickerPanel(props: PickerPanelProps) { let extraFooter: React.ReactNode; let rangesNode: React.ReactNode; + const onNow = () => { + const now = generateConfig.getNow(); + const lowerBoundTime = getLowerBoundTime( + generateConfig.getHour(now), + generateConfig.getMinute(now), + generateConfig.getSecond(now), + isHourStepValid ? hourStep : 1, + isMinuteStepValid ? minuteStep : 1, + isSecondStepValid ? secondStep : 1, + ); + const adjustedNow = setTime( + generateConfig, + now, + lowerBoundTime[0], // hour + lowerBoundTime[1], // minute + lowerBoundTime[2], // second + ); + triggerSelect(adjustedNow, 'submit'); + }; + if (!hideRanges) { extraFooter = getExtraFooter(prefixCls, mergedMode, renderExtraFooter); rangesNode = getRanges({ @@ -443,11 +480,7 @@ function PickerPanel(props: PickerPanelProps) { okDisabled: !mergedValue || (disabledDate && disabledDate(mergedValue)), locale, showNow, - onNow: - needConfirmButton && - (() => { - triggerSelect(generateConfig.getNow(), 'submit'); - }), + onNow: needConfirmButton && onNow, onOk: () => { if (mergedValue) { triggerSelect(mergedValue, 'submit', true); diff --git a/src/panels/TimePanel/TimeBody.tsx b/src/panels/TimePanel/TimeBody.tsx index 983dca855..041f8fbae 100644 --- a/src/panels/TimePanel/TimeBody.tsx +++ b/src/panels/TimePanel/TimeBody.tsx @@ -1,9 +1,20 @@ import * as React from 'react'; +import useMemo from 'rc-util/lib/hooks/useMemo'; import { GenerateConfig } from '../../generate'; import { Locale, OnSelect } from '../../interface'; import TimeUnitColumn, { Unit } from './TimeUnitColumn'; import { leftPad } from '../../utils/miscUtil'; import { SharedTimeProps } from '.'; +import { setTime as utilSetTime } from '../../utils/timeUtil'; + +function shouldUnitsUpdate(prevUnits: Unit[], nextUnits: Unit[]) { + if (prevUnits.length !== nextUnits.length) return true; + // if any unit's disabled status is different, the units should be re-evaluted + for (let i = 0; i < prevUnits.length; i += 1) { + if (prevUnits[i].disabled !== nextUnits[i].disabled) return true; + } + return false; +} function generateUnits( start: number, @@ -83,37 +94,60 @@ function TimeBody(props: TimeBodyProps) { const mergedMinute = Math.max(0, newMinute); const mergedSecond = Math.max(0, newSecond); - newDate = generateConfig.setSecond(newDate, mergedSecond); - newDate = generateConfig.setMinute(newDate, mergedMinute); - newDate = generateConfig.setHour( + newDate = utilSetTime( + generateConfig, newDate, !use12Hours || !isNewPM ? mergedHour : mergedHour + 12, + mergedMinute, + mergedSecond, ); return newDate; }; // ========================= Unit ========================= - const hours = generateUnits( - 0, - use12Hours ? 11 : 23, - hourStep, - disabledHours && disabledHours(), - ); + const rawHours = generateUnits(0, 23, hourStep, disabledHours && disabledHours()); + + const memorizedRawHours = useMemo(() => rawHours, rawHours, shouldUnitsUpdate); // Should additional logic to handle 12 hours - if (use12Hours && hour !== -1) { - isPM = hour >= 12; + if (use12Hours) { + isPM = hour >= 12; // -1 means should display AM hour %= 12; - hours[0].label = '12'; } - const minutes = generateUnits( - 0, - 59, - minuteStep, - disabledMinutes && disabledMinutes(hour), - ); + const [AMDisabled, PMDisabled] = React.useMemo(() => { + if (!use12Hours) { + return [false, false]; + } + const AMPMDisabled = [true, true]; + memorizedRawHours.forEach(({ disabled, value: hourValue }) => { + if (disabled) return; + if (hourValue >= 12) { + AMPMDisabled[1] = false; + } else { + AMPMDisabled[0] = false; + } + }); + return AMPMDisabled; + }, [use12Hours, memorizedRawHours]); + + const hours = React.useMemo(() => { + if (!use12Hours) return memorizedRawHours; + return memorizedRawHours + .filter(isPM ? hourMeta => hourMeta.value >= 12 : hourMeta => hourMeta.value < 12) + .map(hourMeta => { + const hourValue = hourMeta.value % 12; + const hourLabel = hourValue === 0 ? '12' : leftPad(hourValue, 2); + return { + ...hourMeta, + label: hourLabel, + value: hourValue, + }; + }); + }, [use12Hours, memorizedRawHours]); + + const minutes = generateUnits(0, 59, minuteStep, disabledMinutes && disabledMinutes(hour)); const seconds = generateUnits( 0, @@ -207,7 +241,10 @@ function TimeBody(props: TimeBodyProps) { use12Hours === true, , PMIndex, - [{ label: 'AM', value: 0 }, { label: 'PM', value: 1 }], + [ + { label: 'AM', value: 0, disabled: AMDisabled }, + { label: 'PM', value: 1, disabled: PMDisabled }, + ], num => { onSelect(setTime(!!num, hour, minute, second), 'mouse'); }, diff --git a/src/panels/TimePanel/TimeUnitColumn.tsx b/src/panels/TimePanel/TimeUnitColumn.tsx index 4da503cf9..6439d9e5b 100644 --- a/src/panels/TimePanel/TimeUnitColumn.tsx +++ b/src/panels/TimePanel/TimeUnitColumn.tsx @@ -6,7 +6,7 @@ import PanelContext from '../../PanelContext'; export interface Unit { label: React.ReactText; value: number; - disabled?: boolean; + disabled: boolean; } export interface TimeUnitColumnProps { diff --git a/src/utils/timeUtil.ts b/src/utils/timeUtil.ts new file mode 100644 index 000000000..4325e6634 --- /dev/null +++ b/src/utils/timeUtil.ts @@ -0,0 +1,34 @@ +import { GenerateConfig } from '../generate'; + +export function setTime( + generateConfig: GenerateConfig, + date: DateType, + hour: number, + minute: number, + second: number, +): DateType { + let nextTime = generateConfig.setHour(date, hour); + nextTime = generateConfig.setMinute(nextTime, minute); + nextTime = generateConfig.setSecond(nextTime, second); + return nextTime; +} + +export function getLowerBoundTime( + hour: number, + minute: number, + second: number, + hourStep: number, + minuteStep: number, + secondStep: number, +): [number, number, number] { + const lowerBoundHour = Math.floor(hour / hourStep) * hourStep; + if (lowerBoundHour < hour) { + return [lowerBoundHour, 60 - minuteStep, 60 - secondStep]; + } + const lowerBoundMinute = Math.floor(minute / minuteStep) * minuteStep; + if (lowerBoundMinute < minute) { + return [lowerBoundHour, lowerBoundMinute, 60 - secondStep]; + } + const lowerBoundSecond = Math.floor(second / secondStep) * secondStep; + return [lowerBoundHour, lowerBoundMinute, lowerBoundSecond]; +} diff --git a/tests/panel.spec.tsx b/tests/panel.spec.tsx index b38eb080f..0cb5c245a 100644 --- a/tests/panel.spec.tsx +++ b/tests/panel.spec.tsx @@ -344,24 +344,100 @@ describe('Picker.Panel', () => { }); }); - it('time with use12Hours', () => { - const onChange = jest.fn(); - const wrapper = mount( - , - ); + describe('time with use12Hours', () => { + it('should work', () => { + const onChange = jest.fn(); + const wrapper = mount( + , + ); - wrapper - .find('.rc-picker-time-panel-column') - .last() - .find('li') - .last() - .simulate('click'); - expect(isSame(onChange.mock.calls[0][0], '2000-01-01 12:01:02', 'second')).toBeTruthy(); + wrapper + .find('.rc-picker-time-panel-column') + .last() + .find('li') + .last() + .simulate('click'); + expect(isSame(onChange.mock.calls[0][0], '2000-01-01 12:01:02', 'second')).toBeTruthy(); + }); + + it('should display hour from 12 at AM', () => { + const wrapper = mount( + , + ); + + const startHour = wrapper + .find('.rc-picker-time-panel-column') + .first() + .find('li') + .first() + .text(); + expect(startHour).toEqual('12'); + }); + + it('should display hour from 12 at AM', () => { + const wrapper = mount( + , + ); + + const startHour = wrapper + .find('.rc-picker-time-panel-column') + .first() + .find('li') + .first() + .text(); + expect(startHour).toEqual('12'); + }); + + it('should disable AM when 00 ~ 11 is disabled', () => { + const wrapper = mount( + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]} + />, + ); + + const disabledAMItem = wrapper + .find('.rc-picker-time-panel-column') + .last() + .find('li') + .first() + .find('.rc-picker-time-panel-cell-disabled'); + expect(disabledAMItem.length).toEqual(1); + }); + + it('should disable PM when 12 ~ 23 is disabled', () => { + const wrapper = mount( + [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]} + />, + ); + + const disabledPMItem = wrapper + .find('.rc-picker-time-panel-column') + .last() + .find('li') + .last() + .find('.rc-picker-time-panel-cell-disabled'); + expect(disabledPMItem.length).toEqual(1); + }); }); it('time disabled columns', () => { diff --git a/tests/picker.spec.tsx b/tests/picker.spec.tsx index cf57bffd9..9055ff750 100644 --- a/tests/picker.spec.tsx +++ b/tests/picker.spec.tsx @@ -543,6 +543,46 @@ describe('Picker.Basic', () => { }); }); + describe('time step', () => { + it('work with now', () => { + MockDate.set(getMoment('1990-09-03 00:09:00').toDate()); + const onSelect = jest.fn(); + const wrapper = mount(); + wrapper.openPicker(); + wrapper.find('.rc-picker-now > a').simulate('click'); + expect(isSame(onSelect.mock.calls[0][0], '1990-09-03 00:00:59', 'second')).toBeTruthy(); + MockDate.set(getMoment('1990-09-03 00:00:00').toDate()); + }); + it('should show warning when hour step is invalid', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(spy).not.toBeCalled(); + const wrapper = mount(); + wrapper.openPicker(); + expect(spy).toBeCalledWith('Warning: `hourStep` 9 is invalid. It should be a factor of 24.'); + spy.mockRestore(); + }); + it('should show warning when minute step is invalid', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(spy).not.toBeCalled(); + const wrapper = mount(); + wrapper.openPicker(); + expect(spy).toBeCalledWith( + 'Warning: `minuteStep` 9 is invalid. It should be a factor of 60.', + ); + spy.mockRestore(); + }); + it('should show warning when second step is invalid', () => { + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(spy).not.toBeCalled(); + const wrapper = mount(); + wrapper.openPicker(); + expect(spy).toBeCalledWith( + 'Warning: `secondStep` 9 is invalid. It should be a factor of 60.', + ); + spy.mockRestore(); + }); + }); + it('pass data- & aria- & role', () => { const wrapper = mount(); diff --git a/tests/util.spec.tsx b/tests/util.spec.tsx index 0e678c7b4..ad812bff8 100644 --- a/tests/util.spec.tsx +++ b/tests/util.spec.tsx @@ -1,4 +1,5 @@ import momentGenerateConfig from '../src/generate/moment'; +import { getLowerBoundTime, setTime } from '../src/utils/timeUtil'; import { toArray } from '../src/utils/miscUtil'; import { isSameTime, isSameDecade } from '../src/utils/dateUtil'; import { getMoment } from './util/commonUtil'; @@ -29,4 +30,38 @@ describe('Picker.Util', () => { isSameDecade(momentGenerateConfig, getMoment('1995-01-01'), getMoment('1999-01-01')), ).toBeTruthy(); }); + + describe('getLowerBoundTime', () => { + it('basic case', () => { + expect(getLowerBoundTime(23, 59, 59, 1, 1, 1)).toEqual([23, 59, 59]); + }); + it('case to lower hour #1', () => { + expect(getLowerBoundTime(1, 4, 5, 4, 15, 15)).toEqual([0, 45, 45]); + }); + it('case to lower hour #2', () => { + expect(getLowerBoundTime(3, 4, 5, 4, 15, 15)).toEqual([0, 45, 45]); + }); + it('case to same hour, lower minute #1', () => { + expect(getLowerBoundTime(1, 31, 5, 1, 15, 15)).toEqual([1, 30, 45]); + }); + it('case to same hour, lower minute #2', () => { + expect(getLowerBoundTime(1, 44, 5, 1, 15, 15)).toEqual([1, 30, 45]); + }); + it('case to same hour, same minute, lower second #1', () => { + expect(getLowerBoundTime(1, 44, 5, 1, 1, 15)).toEqual([1, 44, 0]); + }); + it('case to same hour, same minute, lower second #2', () => { + expect(getLowerBoundTime(1, 44, 14, 1, 1, 15)).toEqual([1, 44, 0]); + }); + }); + + describe('setTime', () => { + expect( + isSameTime( + momentGenerateConfig, + setTime(momentGenerateConfig, getMoment('1995-01-01 00:00:00'), 8, 7, 6), + getMoment('1995-01-01 08:07:06'), + ), + ).toBeTruthy(); + }); });