From 1894ca6f32b3689ac122ed6c8b7014746e07ef49 Mon Sep 17 00:00:00 2001 From: Wojciech Maj Date: Sun, 28 May 2023 20:43:48 +0200 Subject: [PATCH] Rewrite class components to hooks (#858) --- src/Calendar.spec.tsx | 78 ++-- src/Calendar.tsx | 871 ++++++++++++++++++++---------------------- src/Tile.tsx | 146 +++---- 3 files changed, 508 insertions(+), 587 deletions(-) diff --git a/src/Calendar.spec.tsx b/src/Calendar.spec.tsx index 905e8d09..d878407f 100644 --- a/src/Calendar.spec.tsx +++ b/src/Calendar.spec.tsx @@ -5,6 +5,18 @@ import { getMonthStart } from '@wojtekmaj/date-utils'; import Calendar from './Calendar'; +import type { Action, Value, View } from './shared/types'; + +type CalendarImperativeHandle = { + activeStartDate: Date; + drillDown: (nextActiveStartDate: Date, event: React.MouseEvent) => void; + drillUp: () => void; + onChange: (rawNextValue: Date, event: React.MouseEvent) => void; + setActiveStartDate: (nextActiveStartDate: Date, action: Action) => void; + value: Value; + view: View; +}; + const { format } = new Intl.DateTimeFormat('en-US', { day: 'numeric', month: 'long', @@ -54,7 +66,7 @@ describe('Calendar', () => { }); it('uses given value when passed value using value prop', () => { - const instance = createRef(); + const instance = createRef(); render(); @@ -66,7 +78,7 @@ describe('Calendar', () => { }); it('uses given value when passed value using defaultValue prop', () => { - const instance = createRef(); + const instance = createRef(); render(); @@ -78,7 +90,7 @@ describe('Calendar', () => { }); it('renders given view when passed view using view prop', () => { - const instance = createRef(); + const instance = createRef(); render(); @@ -90,7 +102,7 @@ describe('Calendar', () => { }); it('renders given view when passed view using defaultView prop', () => { - const instance = createRef(); + const instance = createRef(); render(); @@ -102,7 +114,7 @@ describe('Calendar', () => { }); it('renders given active start date when passed active start date using activeStartDate prop', () => { - const instance = createRef(); + const instance = createRef(); render(); @@ -114,7 +126,7 @@ describe('Calendar', () => { }); it('renders given active start date when passed active start date using activeStartDate prop', () => { - const instance = createRef(); + const instance = createRef(); render(); @@ -129,7 +141,7 @@ describe('Calendar', () => { const value = new Date(2018, 1, 15); const newValue = new Date(2018, 0, 15); const newActiveStartDate = new Date(2018, 0, 1); - const instance = createRef(); + const instance = createRef(); const { rerender } = render(); @@ -146,7 +158,7 @@ describe('Calendar', () => { const value = new Date(2018, 1, 15); const newValue = new Date(2018, 0, 15); const newActiveStartDate = new Date(2018, 0, 1); - const instance = createRef(); + const instance = createRef(); render(); @@ -168,7 +180,7 @@ describe('Calendar', () => { it('changes Calendar view given new activeStartDate value', () => { const activeStartDate = new Date(2017, 0, 1); const newActiveStartDate = new Date(2018, 0, 1); - const instance = createRef(); + const instance = createRef(); const { rerender } = render(); @@ -468,7 +480,7 @@ describe('Calendar', () => { it('refuses to drill up when already on minimum allowed detail', () => { const onDrillUp = vi.fn(); - const instance = createRef(); + const instance = createRef(); render(); @@ -555,7 +567,7 @@ describe('Calendar', () => { it('refuses to drill down when already on minimum allowed detail', () => { const onDrillDown = vi.fn(); - const instance = createRef(); + const instance = createRef(); render(); @@ -573,7 +585,7 @@ describe('Calendar', () => { describe('handles active start date change properly', () => { it('changes active start date when allowed', () => { - const instance = createRef(); + const instance = createRef(); render(); @@ -596,7 +608,7 @@ describe('Calendar', () => { const value = new Date(2019, 0, 15); const newActiveStartDate = new Date(2018, 0, 1); const onActiveStartDateChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( { const activeStartDate = new Date(2017, 0, 1); const newActiveStartDate = new Date(2018, 0, 1); const onActiveStartDateChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( { const activeStartDate = new Date(2017, 0, 1); const newActiveStartDate = new Date(2017, 0, 1); const onActiveStartDateChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( { const value = new Date(2017, 0, 1); const newActiveStartDate = new Date(2017, 0, 1); const onActiveStartDateChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( { describe('calls onChange properly', () => { it('calls onChange function returning the beginning of selected period by default', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render(); @@ -808,7 +820,7 @@ describe('Calendar', () => { it('calls onChange function returning the beginning of the selected period when returnValue is set to "start"', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render(); @@ -827,7 +839,7 @@ describe('Calendar', () => { it('calls onChange function returning the end of the selected period when returnValue is set to "end"', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render(); @@ -846,7 +858,7 @@ describe('Calendar', () => { it('calls onChange function returning the beginning of selected period when returnValue is set to "range"', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render(); @@ -868,7 +880,7 @@ describe('Calendar', () => { it('calls onChange function returning the beginning of selected period, but no earlier than minDate', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( { it('calls onChange function returning the beginning of selected period, but no later than maxDate', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( { it('calls onChange function returning the end of selected period, but no earlier than minDate', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( { it('calls onChange function returning the end of selected period, but no later than maxDate', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( { it('does not call onChange function returning a range when selected one piece of a range by default', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render(); @@ -995,7 +1007,7 @@ describe('Calendar', () => { it('does not call onChange function returning a range when selected one piece of a range given allowPartialRange = false', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( { it('calls onChange function returning a partial range when selected one piece of a range given allowPartialRange = true', () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render( , @@ -1044,7 +1056,7 @@ describe('Calendar', () => { it('calls onChange function returning a range when selected two pieces of a range', async () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render(); @@ -1058,8 +1070,10 @@ describe('Calendar', () => { onChangeInternal(new Date(2018, 0, 1), event); }); + const { onChange: onChangeInternal2 } = instance.current; + act(() => { - onChangeInternal(new Date(2018, 6, 1), event); + onChangeInternal2(new Date(2018, 6, 1), event); }); expect(onChange).toHaveBeenCalledTimes(1); @@ -1071,7 +1085,7 @@ describe('Calendar', () => { it('calls onChange function returning a range when selected reversed two pieces of a range', async () => { const onChange = vi.fn(); - const instance = createRef(); + const instance = createRef(); render(); @@ -1085,8 +1099,10 @@ describe('Calendar', () => { onChangeInternal(new Date(2018, 6, 1), event); }); + const { onChange: onChangeInternal2 } = instance.current; + act(() => { - onChangeInternal(new Date(2018, 0, 1), event); + onChangeInternal2(new Date(2018, 0, 1), event); }); expect(onChange).toHaveBeenCalledTimes(1); diff --git a/src/Calendar.tsx b/src/Calendar.tsx index d26f7210..5ce1338d 100644 --- a/src/Calendar.tsx +++ b/src/Calendar.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; @@ -56,17 +56,6 @@ defaultMinDate.setFullYear(1, 0, 1); defaultMinDate.setHours(0, 0, 0, 0); const defaultMaxDate = new Date(8.64e15); -const defaultProps = { - goToRangeStartOnSelect: true, - maxDate: defaultMaxDate, - maxDetail: 'month', - minDate: defaultMinDate, - minDetail: 'century', - returnValue: 'start', - showNavigation: true, - showNeighboringMonth: true, -}; - export type CalendarProps = { activeStartDate?: Date; allowPartialRange?: boolean; @@ -124,15 +113,6 @@ export type CalendarProps = { view?: View; }; -type CalendarPropsWithDefaults = typeof defaultProps & CalendarProps; - -type CalendarState = { - activeStartDate?: Date | null; - hover?: Date | null; - value?: Value; - view?: View; -}; - function toDate(value: Date | string): Date { if (value instanceof Date) { return value; @@ -309,88 +289,81 @@ function areDatesEqual(date1?: Date | null, date2?: Date | null) { return date1 instanceof Date && date2 instanceof Date && date1.getTime() === date2.getTime(); } -const isActiveStartDate = PropTypes.instanceOf(Date); - -const isValue = PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]); - -const isValueOrValueArray = PropTypes.oneOfType([isValue, rangeOf(isValue)]); - -export default class Calendar extends Component { - static defaultProps = defaultProps; - - static propTypes = { - activeStartDate: isActiveStartDate, - allowPartialRange: PropTypes.bool, - calendarType: isCalendarType, - className: isClassName, - defaultActiveStartDate: isActiveStartDate, - defaultValue: isValueOrValueArray, - defaultView: isView, - formatDay: PropTypes.func, - formatLongDate: PropTypes.func, - formatMonth: PropTypes.func, - formatMonthYear: PropTypes.func, - formatShortWeekday: PropTypes.func, - formatWeekday: PropTypes.func, - formatYear: PropTypes.func, - goToRangeStartOnSelect: PropTypes.bool, - inputRef: isRef, - locale: PropTypes.string, - maxDate: isMaxDate, - maxDetail: PropTypes.oneOf(allViews), - minDate: isMinDate, - minDetail: PropTypes.oneOf(allViews), - navigationAriaLabel: PropTypes.string, - navigationAriaLive: PropTypes.oneOf(['off', 'polite', 'assertive']), - navigationLabel: PropTypes.func, - next2AriaLabel: PropTypes.string, - next2Label: PropTypes.node, - nextAriaLabel: PropTypes.string, - nextLabel: PropTypes.node, - onActiveStartDateChange: PropTypes.func, - onChange: PropTypes.func, - onClickDay: PropTypes.func, - onClickDecade: PropTypes.func, - onClickMonth: PropTypes.func, - onClickWeekNumber: PropTypes.func, - onClickYear: PropTypes.func, - onDrillDown: PropTypes.func, - onDrillUp: PropTypes.func, - onViewChange: PropTypes.func, - prev2AriaLabel: PropTypes.string, - prev2Label: PropTypes.node, - prevAriaLabel: PropTypes.string, - prevLabel: PropTypes.node, - returnValue: PropTypes.oneOf(['start', 'end', 'range']), - selectRange: PropTypes.bool, - showDoubleView: PropTypes.bool, - showFixedNumberOfWeeks: PropTypes.bool, - showNavigation: PropTypes.bool, - showNeighboringMonth: PropTypes.bool, - showWeekNumbers: PropTypes.bool, - tileClassName: PropTypes.oneOfType([PropTypes.func, isClassName]), - tileContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), - tileDisabled: PropTypes.func, - value: isValueOrValueArray, - view: isView, - }; - - state: Readonly = { - activeStartDate: this.props.defaultActiveStartDate, - hover: null, - value: Array.isArray(this.props.defaultValue) - ? (this.props.defaultValue.map((el) => (el !== null ? toDate(el) : el)) as [ - Date | null, - Date | null, - ]) - : this.props.defaultValue !== null && this.props.defaultValue !== undefined - ? toDate(this.props.defaultValue) - : this.props.defaultValue, - view: this.props.defaultView, - }; - - get activeStartDate() { - const { +const Calendar = forwardRef(function Calendar(props: CalendarProps, ref) { + const { + activeStartDate: activeStartDateProps, + allowPartialRange, + calendarType, + className, + defaultActiveStartDate, + defaultValue, + defaultView, + formatDay, + formatLongDate, + formatMonth, + formatMonthYear, + formatShortWeekday, + formatWeekday, + formatYear, + goToRangeStartOnSelect = true, + inputRef, + locale, + maxDate = defaultMaxDate, + maxDetail = 'month', + minDate = defaultMinDate, + minDetail = 'century', + navigationAriaLabel, + navigationAriaLive, + navigationLabel, + next2AriaLabel, + next2Label, + nextAriaLabel, + nextLabel, + onActiveStartDateChange, + onChange: onChangeProps, + onClickDay, + onClickDecade, + onClickMonth, + onClickWeekNumber, + onClickYear, + onDrillDown, + onDrillUp, + onViewChange, + prev2AriaLabel, + prev2Label, + prevAriaLabel, + prevLabel, + returnValue = 'start', + selectRange, + showDoubleView, + showFixedNumberOfWeeks, + showNavigation = true, + showNeighboringMonth = true, + showWeekNumbers, + tileClassName, + tileContent, + tileDisabled, + value: valueProps, + view: viewProps, + } = props; + + const [activeStartDateState, setActiveStartDateState] = useState( + defaultActiveStartDate, + ); + const [hoverState, setHoverState] = useState(null); + const [valueState, setValueState] = useState( + Array.isArray(defaultValue) + ? (defaultValue.map((el) => (el !== null ? toDate(el) : el)) as [Date | null, Date | null]) + : defaultValue !== null && defaultValue !== undefined + ? toDate(defaultValue) + : defaultValue, + ); + const [viewState, setViewState] = useState(defaultView); + + const activeStartDate = + activeStartDateProps || + activeStartDateState || + getInitialActiveStartDate({ activeStartDate: activeStartDateProps, defaultActiveStartDate, defaultValue, @@ -399,33 +372,11 @@ export default class Calendar extends Component { maxDetail, minDate, minDetail, - value, - view, - } = this.props as CalendarPropsWithDefaults; - const { activeStartDate: activeStartDateState } = this.state; - - return ( - activeStartDateProps || - activeStartDateState || - getInitialActiveStartDate({ - activeStartDate: activeStartDateProps, - defaultActiveStartDate, - defaultValue, - defaultView, - maxDate, - maxDetail, - minDate, - minDetail, - value, - view, - }) - ); - } - - get value(): Value { - const { selectRange, value: valueProps } = this.props as CalendarPropsWithDefaults; - const { value: valueState } = this.state; + value: valueProps, + view: viewProps, + }); + const value: Value = (() => { const rawValue = (() => { // In the middle of range selection, use value from state if (selectRange && getIsSingleValue(valueState)) { @@ -444,169 +395,139 @@ export default class Calendar extends Component { : rawValue !== null ? toDate(rawValue) : rawValue; - } - - get valueType() { - const { maxDetail } = this.props as CalendarPropsWithDefaults; - - return getValueType(maxDetail); - } - - get view() { - const { minDetail, maxDetail, view: viewProps } = this.props as CalendarPropsWithDefaults; - const { view: viewState } = this.state; - - return getView(viewProps || viewState, minDetail, maxDetail); - } + })(); - get views() { - const { minDetail, maxDetail } = this.props as CalendarPropsWithDefaults; + const valueType = getValueType(maxDetail); - return getLimitedViews(minDetail, maxDetail); - } + const view = getView(viewProps || viewState, minDetail, maxDetail); - get hover() { - const { selectRange } = this.props as CalendarPropsWithDefaults; - const { hover } = this.state; + const views = getLimitedViews(minDetail, maxDetail); - return selectRange ? hover : null; - } + const hover = selectRange ? hoverState : null; - get drillDownAvailable() { - const { view, views } = this; + const drillDownAvailable = views.indexOf(view) < views.length - 1; - return views.indexOf(view) < views.length - 1; - } + const drillUpAvailable = views.indexOf(view) > 0; - get drillUpAvailable() { - const { view, views } = this; + const getProcessedValue = useCallback( + (value: Date) => { + const processFunction = (() => { + switch (returnValue) { + case 'start': + return getDetailValueFrom; + case 'end': + return getDetailValueTo; + case 'range': + return getDetailValueArray; + default: + throw new Error('Invalid returnValue.'); + } + })(); - return views.indexOf(view) > 0; - } + return processFunction({ + maxDate, + maxDetail, + minDate, + value, + }); + }, + [maxDate, maxDetail, minDate, returnValue], + ); + + const setActiveStartDate = useCallback( + (nextActiveStartDate: Date, action: Action) => { + setActiveStartDateState(nextActiveStartDate); + + const args: OnArgs = { + action, + activeStartDate: nextActiveStartDate, + value, + view, + }; - /** - * Gets current value in a desired format. - */ - getProcessedValue(value: Date) { - const { minDate, maxDate, maxDetail, returnValue } = this.props as CalendarPropsWithDefaults; - - const processFunction = (() => { - switch (returnValue) { - case 'start': - return getDetailValueFrom; - case 'end': - return getDetailValueTo; - case 'range': - return getDetailValueArray; - default: - throw new Error('Invalid returnValue.'); + if (onActiveStartDateChange && !areDatesEqual(activeStartDate, nextActiveStartDate)) { + onActiveStartDateChange(args); } - })(); - - return processFunction({ - value, - minDate, - maxDate, - maxDetail, - }); - } - - /** - * Called when the user uses navigation buttons. - */ - setActiveStartDate = (nextActiveStartDate: Date, action: Action) => { - const { onActiveStartDateChange } = this.props as CalendarPropsWithDefaults; - - this.setState({ - activeStartDate: nextActiveStartDate, - }); + }, + [activeStartDate, onActiveStartDateChange, value, view], + ); + + const onClickTile = useCallback( + (value: Date, event: React.MouseEvent) => { + const callback = (() => { + switch (view) { + case 'century': + return onClickDecade; + case 'decade': + return onClickYear; + case 'year': + return onClickMonth; + case 'month': + return onClickDay; + default: + throw new Error(`Invalid view: ${view}.`); + } + })(); - const args: OnArgs = { - action, - activeStartDate: nextActiveStartDate, - value: this.value, - view: this.view, - }; + if (callback) callback(value, event); + }, + [onClickDay, onClickDecade, onClickMonth, onClickYear, view], + ); - if (onActiveStartDateChange && !areDatesEqual(this.activeStartDate, nextActiveStartDate)) { - onActiveStartDateChange(args); - } - }; - - onClickTile = (value: Date, event: React.MouseEvent) => { - const { view } = this; - const { onClickDay, onClickDecade, onClickMonth, onClickYear } = this - .props as CalendarPropsWithDefaults; - - const callback = (() => { - switch (view) { - case 'century': - return onClickDecade; - case 'decade': - return onClickYear; - case 'year': - return onClickMonth; - case 'month': - return onClickDay; - default: - throw new Error(`Invalid view: ${view}.`); + const drillDown = useCallback( + (nextActiveStartDate: Date, event: React.MouseEvent) => { + if (!drillDownAvailable) { + return; } - })(); - - if (callback) callback(value, event); - }; - drillDown = (nextActiveStartDate: Date, event: React.MouseEvent) => { - if (!this.drillDownAvailable) { - return; - } - - this.onClickTile(nextActiveStartDate, event); + onClickTile(nextActiveStartDate, event); - const { view, views } = this; - const { onActiveStartDateChange, onDrillDown, onViewChange } = this - .props as CalendarPropsWithDefaults; + const nextView = views[views.indexOf(view) + 1]; - const nextView = views[views.indexOf(view) + 1]; - - if (!nextView) { - throw new Error('Attempted to drill down from the lowest view.'); - } + if (!nextView) { + throw new Error('Attempted to drill down from the lowest view.'); + } - this.setState({ - activeStartDate: nextActiveStartDate, - view: nextView, - }); + setActiveStartDateState(nextActiveStartDate); + setViewState(nextView); - const args: OnArgs = { - action: 'drillDown', - activeStartDate: nextActiveStartDate, - value: this.value, - view: nextView, - }; + const args: OnArgs = { + action: 'drillDown', + activeStartDate: nextActiveStartDate, + value, + view: nextView, + }; - if (onActiveStartDateChange && !areDatesEqual(this.activeStartDate, nextActiveStartDate)) { - onActiveStartDateChange(args); - } + if (onActiveStartDateChange && !areDatesEqual(activeStartDate, nextActiveStartDate)) { + onActiveStartDateChange(args); + } - if (onViewChange && view !== nextView) { - onViewChange(args); - } + if (onViewChange && view !== nextView) { + onViewChange(args); + } - if (onDrillDown) { - onDrillDown(args); - } - }; + if (onDrillDown) { + onDrillDown(args); + } + }, + [ + activeStartDate, + drillDownAvailable, + onActiveStartDateChange, + onClickTile, + onDrillDown, + onViewChange, + value, + view, + views, + ], + ); - drillUp = () => { - if (!this.drillUpAvailable) { + const drillUp = useCallback(() => { + if (!drillUpAvailable) { return; } - const { activeStartDate, view, views } = this; - const { onActiveStartDateChange, onDrillUp, onViewChange } = this - .props as CalendarPropsWithDefaults; - const nextView = views[views.indexOf(view) - 1]; if (!nextView) { @@ -615,15 +536,13 @@ export default class Calendar extends Component { const nextActiveStartDate = getBegin(nextView, activeStartDate); - this.setState({ - activeStartDate: nextActiveStartDate, - view: nextView, - }); + setActiveStartDateState(nextActiveStartDate); + setViewState(nextView); const args: OnArgs = { action: 'drillUp', activeStartDate: nextActiveStartDate, - value: this.value, + value, view: nextView, }; @@ -638,147 +557,151 @@ export default class Calendar extends Component { if (onDrillUp) { onDrillUp(args); } - }; - - onChange = (value: Date, event: React.MouseEvent) => { - const { value: previousValue } = this; - const { - allowPartialRange, - goToRangeStartOnSelect, - maxDate, - maxDetail, - minDate, - minDetail, - onActiveStartDateChange, - onChange, - selectRange, - view, - } = this.props as CalendarPropsWithDefaults; - - this.onClickTile(value, event); - - const isFirstValueInRange = selectRange && !getIsSingleValue(previousValue); - - let nextValue: Value; - if (selectRange) { - // Range selection turned on - const { valueType } = this; - - if (isFirstValueInRange) { - // Value has 0 or 2 elements - either way we're starting a new array - // First value - nextValue = getBegin(valueType, value); - } else { - if (!previousValue) { - throw new Error('previousValue is required'); - } - - if (Array.isArray(previousValue)) { - throw new Error('previousValue must not be an array'); - } - - // Second value - nextValue = getValueRange(valueType, previousValue, value); - } - } else { - // Range selection turned off - nextValue = this.getProcessedValue(value); - } - - const nextActiveStartDate = - // Range selection turned off - !selectRange || - // Range selection turned on, first value - isFirstValueInRange || - // Range selection turned on, second value, goToRangeStartOnSelect toggled on - goToRangeStartOnSelect - ? getActiveStartDate({ - maxDate, - maxDetail, - minDate, - minDetail, - value: nextValue, - view, - }) - : null; - - event.persist(); - - this.setState({ - activeStartDate: nextActiveStartDate, - value: nextValue, - }); - - const args: OnArgs = { - action: 'onChange', - activeStartDate: nextActiveStartDate, - value: nextValue, - view: this.view, - }; - - if (onActiveStartDateChange && !areDatesEqual(this.activeStartDate, nextActiveStartDate)) { - onActiveStartDateChange(args); - } - - if (onChange) { - if (!event) { - throw new Error('event is required'); - } - + }, [ + activeStartDate, + drillUpAvailable, + onActiveStartDateChange, + onDrillUp, + onViewChange, + value, + view, + views, + ]); + + const onChange = useCallback( + (rawNextValue: Date, event: React.MouseEvent) => { + const previousValue = value; + + onClickTile(rawNextValue, event); + + const isFirstValueInRange = selectRange && !getIsSingleValue(previousValue); + + let nextValue: Value; if (selectRange) { - const isSingleValue = getIsSingleValue(nextValue); + // Range selection turned on + + if (isFirstValueInRange) { + // Value has 0 or 2 elements - either way we're starting a new array + // First value + nextValue = getBegin(valueType, rawNextValue); + } else { + if (!previousValue) { + throw new Error('previousValue is required'); + } - if (!isSingleValue) { - onChange(nextValue || null, event); - } else if (allowPartialRange) { - if (Array.isArray(nextValue)) { - throw new Error('value must not be an array'); + if (Array.isArray(previousValue)) { + throw new Error('previousValue must not be an array'); } - onChange([nextValue || null, null], event); + // Second value + nextValue = getValueRange(valueType, previousValue, rawNextValue); } } else { - onChange(nextValue || null, event); + // Range selection turned off + nextValue = getProcessedValue(rawNextValue); } - } - }; - onMouseOver = (value: Date) => { - this.setState((prevState) => { - if (prevState.hover && prevState.hover.getTime() === value.getTime()) { - return null; + const nextActiveStartDate = + // Range selection turned off + !selectRange || + // Range selection turned on, first value + isFirstValueInRange || + // Range selection turned on, second value, goToRangeStartOnSelect toggled on + goToRangeStartOnSelect + ? getActiveStartDate({ + maxDate, + maxDetail, + minDate, + minDetail, + value: nextValue, + view, + }) + : null; + + event.persist(); + + setActiveStartDateState(nextActiveStartDate); + setValueState(nextValue); + + const args: OnArgs = { + action: 'onChange', + activeStartDate: nextActiveStartDate, + value: nextValue, + view, + }; + + if (onActiveStartDateChange && !areDatesEqual(activeStartDate, nextActiveStartDate)) { + onActiveStartDateChange(args); } - return { hover: value }; - }); - }; + if (onChangeProps) { + if (selectRange) { + const isSingleValue = getIsSingleValue(nextValue); - onMouseLeave = () => { - this.setState({ hover: null }); - }; + if (!isSingleValue) { + onChangeProps(nextValue || null, event); + } else if (allowPartialRange) { + if (Array.isArray(nextValue)) { + throw new Error('value must not be an array'); + } - renderContent(next?: boolean) { - const { activeStartDate: currentActiveStartDate, onMouseOver, valueType, value, view } = this; - const { - calendarType, - locale, + onChangeProps([nextValue || null, null], event); + } + } else { + onChangeProps(nextValue || null, event); + } + } + }, + [ + activeStartDate, + allowPartialRange, + getProcessedValue, + goToRangeStartOnSelect, maxDate, + maxDetail, minDate, + minDetail, + onActiveStartDateChange, + onChangeProps, + onClickTile, selectRange, - tileClassName, - tileContent, - tileDisabled, - } = this.props as CalendarPropsWithDefaults; - const { hover } = this; + value, + valueType, + view, + ], + ); - const activeStartDate = next - ? getBeginNext(view, currentActiveStartDate) - : getBegin(view, currentActiveStartDate); + function onMouseOver(nextHover: Date) { + setHoverState(nextHover); + } - const onClick = this.drillDownAvailable ? this.drillDown : this.onChange; + function onMouseLeave() { + setHoverState(null); + } - const commonProps = { + useImperativeHandle( + ref, + () => ({ activeStartDate, + drillDown, + drillUp, + onChange, + setActiveStartDate, + value, + view, + }), + [activeStartDate, drillDown, drillUp, onChange, setActiveStartDate, value, view], + ); + + function renderContent(next?: boolean) { + const currentActiveStartDate = next + ? getBeginNext(view, activeStartDate) + : getBegin(view, activeStartDate); + + const onClick = drillDownAvailable ? drillDown : onChange; + + const commonProps = { + activeStartDate: currentActiveStartDate, hover, locale, maxDate, @@ -794,36 +717,17 @@ export default class Calendar extends Component { switch (view) { case 'century': { - const { formatYear } = this.props as CalendarPropsWithDefaults; - return ; } case 'decade': { - const { formatYear } = this.props as CalendarPropsWithDefaults; - return ; } case 'year': { - const { formatMonth, formatMonthYear } = this.props as CalendarPropsWithDefaults; - return ( ); } case 'month': { - const { - formatDay, - formatLongDate, - formatShortWeekday, - formatWeekday, - onClickWeekNumber, - showDoubleView, - showFixedNumberOfWeeks, - showNeighboringMonth, - showWeekNumbers, - } = this.props as CalendarPropsWithDefaults; - const { onMouseLeave } = this; - return ( { } } - renderNavigation() { - const { showNavigation } = this.props as CalendarPropsWithDefaults; - + function renderNavigation() { if (!showNavigation) { return null; } - const { activeStartDate, view, views } = this; - const { - formatMonthYear, - formatYear, - locale, - maxDate, - minDate, - navigationAriaLabel, - navigationAriaLive, - navigationLabel, - next2AriaLabel, - next2Label, - nextAriaLabel, - nextLabel, - prev2AriaLabel, - prev2Label, - prevAriaLabel, - prevLabel, - showDoubleView, - } = this.props as CalendarPropsWithDefaults; - return ( { prev2Label={prev2Label} prevAriaLabel={prevAriaLabel} prevLabel={prevLabel} - setActiveStartDate={this.setActiveStartDate} + setActiveStartDate={setActiveStartDate} showDoubleView={showDoubleView} view={view} views={views} @@ -905,32 +786,92 @@ export default class Calendar extends Component { ); } - render() { - const { className, inputRef, selectRange, showDoubleView } = this - .props as CalendarPropsWithDefaults; - const { onMouseLeave, value } = this; - const valueArray = Array.isArray(value) ? value : [value]; - - return ( + const valueArray = Array.isArray(value) ? value : [value]; + + return ( +
+ {renderNavigation()}
- {this.renderNavigation()} -
- {this.renderContent()} - {showDoubleView ? this.renderContent(true) : null} -
+ {renderContent()} + {showDoubleView ? renderContent(true) : null}
- ); - } -} +
+ ); +}); + +const isActiveStartDate = PropTypes.instanceOf(Date); + +const isValue = PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]); + +const isValueOrValueArray = PropTypes.oneOfType([isValue, rangeOf(isValue)]); + +Calendar.propTypes = { + activeStartDate: isActiveStartDate, + allowPartialRange: PropTypes.bool, + calendarType: isCalendarType, + className: isClassName, + defaultActiveStartDate: isActiveStartDate, + defaultValue: isValueOrValueArray, + defaultView: isView, + formatDay: PropTypes.func, + formatLongDate: PropTypes.func, + formatMonth: PropTypes.func, + formatMonthYear: PropTypes.func, + formatShortWeekday: PropTypes.func, + formatWeekday: PropTypes.func, + formatYear: PropTypes.func, + goToRangeStartOnSelect: PropTypes.bool, + inputRef: isRef, + locale: PropTypes.string, + maxDate: isMaxDate, + maxDetail: PropTypes.oneOf(allViews), + minDate: isMinDate, + minDetail: PropTypes.oneOf(allViews), + navigationAriaLabel: PropTypes.string, + navigationAriaLive: PropTypes.oneOf(['off', 'polite', 'assertive']), + navigationLabel: PropTypes.func, + next2AriaLabel: PropTypes.string, + next2Label: PropTypes.node, + nextAriaLabel: PropTypes.string, + nextLabel: PropTypes.node, + onActiveStartDateChange: PropTypes.func, + onChange: PropTypes.func, + onClickDay: PropTypes.func, + onClickDecade: PropTypes.func, + onClickMonth: PropTypes.func, + onClickWeekNumber: PropTypes.func, + onClickYear: PropTypes.func, + onDrillDown: PropTypes.func, + onDrillUp: PropTypes.func, + onViewChange: PropTypes.func, + prev2AriaLabel: PropTypes.string, + prev2Label: PropTypes.node, + prevAriaLabel: PropTypes.string, + prevLabel: PropTypes.node, + returnValue: PropTypes.oneOf(['start', 'end', 'range']), + selectRange: PropTypes.bool, + showDoubleView: PropTypes.bool, + showFixedNumberOfWeeks: PropTypes.bool, + showNavigation: PropTypes.bool, + showNeighboringMonth: PropTypes.bool, + showWeekNumbers: PropTypes.bool, + tileClassName: PropTypes.oneOfType([PropTypes.func, isClassName]), + tileContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + tileDisabled: PropTypes.func, + value: isValueOrValueArray, + view: isView, +}; + +export default Calendar; diff --git a/src/Tile.tsx b/src/Tile.tsx index 1fc3b412..77f38102 100644 --- a/src/Tile.tsx +++ b/src/Tile.tsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { useMemo } from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; @@ -32,99 +32,63 @@ type TileProps = { view: View; }; -type TileState = { - activeStartDateProps?: TileProps['activeStartDate']; - tileClassName?: ClassName; - tileClassNameProps?: TileProps['tileClassName']; - tileContent?: React.ReactNode; - tileContentProps?: TileProps['tileContent']; -}; - -function datesAreDifferent(date1?: Date | null, date2?: Date | null) { - return ( - (date1 && !date2) || - (!date1 && date2) || - (date1 && date2 && date1.getTime() !== date2.getTime()) - ); -} - -export default class Tile extends Component { - static propTypes = { - ...tileProps, - children: PropTypes.node.isRequired, - formatAbbr: PropTypes.func, - maxDateTransform: PropTypes.func.isRequired, - minDateTransform: PropTypes.func.isRequired, - }; - - static getDerivedStateFromProps(nextProps: TileProps, prevState: TileState) { - const { activeStartDate, date, tileClassName, tileContent, view } = nextProps; - - const nextState: TileState = {}; - +export default function Tile(props: TileProps) { + const { + activeStartDate, + children, + classes, + date, + formatAbbr, + locale, + maxDate, + maxDateTransform, + minDate, + minDateTransform, + onClick, + onMouseOver, + style, + tileClassName: tileClassNameProps, + tileContent: tileContentProps, + tileDisabled, + view, + } = props; + + const tileClassName = useMemo(() => { const args = { activeStartDate, date, view }; - if ( - tileClassName !== prevState.tileClassNameProps || - datesAreDifferent(activeStartDate, prevState.activeStartDateProps) - ) { - nextState.tileClassName = - typeof tileClassName === 'function' ? tileClassName(args) : tileClassName; - nextState.tileClassNameProps = tileClassName; - } + return typeof tileClassNameProps === 'function' ? tileClassNameProps(args) : tileClassNameProps; + }, [activeStartDate, date, tileClassNameProps, view]); - if ( - tileContent !== prevState.tileContentProps || - datesAreDifferent(activeStartDate, prevState.activeStartDateProps) - ) { - nextState.tileContent = typeof tileContent === 'function' ? tileContent(args) : tileContent; - nextState.tileContentProps = tileContent; - } - - nextState.activeStartDateProps = activeStartDate; - - return nextState; - } - - state: Readonly = {}; + const tileContent = useMemo(() => { + const args = { activeStartDate, date, view }; - render() { - const { - activeStartDate, - children, - classes, - date, - formatAbbr, - locale, - maxDate, - maxDateTransform, - minDate, - minDateTransform, - onClick, - onMouseOver, - style, - tileDisabled, - view, - } = this.props; - const { tileClassName, tileContent } = this.state; + return typeof tileContentProps === 'function' ? tileContentProps(args) : tileContentProps; + }, [activeStartDate, date, tileContentProps, view]); - return ( - - ); - } + return ( + + ); } + +Tile.propTypes = { + ...tileProps, + children: PropTypes.node.isRequired, + formatAbbr: PropTypes.func, + maxDateTransform: PropTypes.func.isRequired, + minDateTransform: PropTypes.func.isRequired, +};