diff --git a/documentation-site/examples/datepicker/composed-range-pickers.tsx b/documentation-site/examples/datepicker/composed-range-pickers.tsx index 03317f4a9f..38c1843307 100644 --- a/documentation-site/examples/datepicker/composed-range-pickers.tsx +++ b/documentation-site/examples/datepicker/composed-range-pickers.tsx @@ -54,7 +54,7 @@ export default function Example() { value={dates[0]} onChange={(time) => { if (time) { - if (isAfter(time, dates[1])) { + if (dates[1] && isAfter(time, dates[1])) { setDates([time, time]); } else { setDates([time, dates[1]]); @@ -108,7 +108,7 @@ export default function Example() { value={dates[1]} onChange={(time) => { if (time) { - if (isBefore(time, dates[0])) { + if (dates[0] && isBefore(time, dates[0])) { setDates([time, time]); } else { setDates([dates[0], time]); diff --git a/package.json b/package.json index 3fe448aa82..31373a7a58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "baseui", - "version": "16.2.1", + "version": "17.0.0", "description": "A React Component library implementing the Base design language", "keywords": [ "react", diff --git a/src/datepicker/__tests__/calendar.test.tsx b/src/datepicker/__tests__/calendar.test.tsx index 8f40515065..b7338e0df2 100644 --- a/src/datepicker/__tests__/calendar.test.tsx +++ b/src/datepicker/__tests__/calendar.test.tsx @@ -63,4 +63,68 @@ describe('Component', () => { fireEvent.click(await getByText(container.parentElement as any as HTMLElement, 'Past Week')); expect(onQuickSelectChange).toHaveBeenCalledWith(expect.objectContaining({ id: 'Past Week' })); }); + + it('constrains time options when selected date matches minDate', () => { + const minDate = new Date('2021-11-10T14:00:00'); + const value = new Date('2021-11-10T15:00:00'); + const { container } = render( + + + + ); + + const timeSelect = queryByTestId(container, 'time-select'); + expect(timeSelect).not.toBeNull(); + + const selectInput = timeSelect!.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = container.parentElement!.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const options = Array.from(listbox!.querySelectorAll('[role="option"]')); + const optionTexts = options.map((o) => o.textContent); + expect(optionTexts).not.toContain('12:00 PM'); + expect(optionTexts).toContain('3:00 PM'); + }); + + it('does not constrain time when selected date differs from minDate', () => { + const minDate = new Date('2021-11-10T14:00:00'); + const value = new Date('2021-11-11T10:00:00'); + const { container } = render( + + + + ); + + const timeSelect = queryByTestId(container, 'time-select'); + expect(timeSelect).not.toBeNull(); + + const selectInput = timeSelect!.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = container.parentElement!.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const options = Array.from(listbox!.querySelectorAll('[role="option"]')); + const optionTexts = options.map((o) => o.textContent); + expect(optionTexts).toContain('12:00 AM'); + expect(optionTexts).toContain('12:00 PM'); + }); }); diff --git a/src/datepicker/__tests__/datepicker-range.test.tsx b/src/datepicker/__tests__/datepicker-range.test.tsx new file mode 100644 index 0000000000..7848f7aeda --- /dev/null +++ b/src/datepicker/__tests__/datepicker-range.test.tsx @@ -0,0 +1,506 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import * as React from 'react'; +import MockDate from 'mockdate'; +import { render, fireEvent, queryByTestId, getByText } from '@testing-library/react'; + +import { TestBaseProvider } from '../../test/test-utils'; +import { Datepicker } from '..'; +import { TimePicker } from '../../timepicker'; + +function ComposedRangePicker({ + initialDates = [] as Array, + minDate, + maxDate, + onDatesChange, +}: { + initialDates?: Array; + minDate?: Date; + maxDate?: Date; + onDatesChange?: (dates: Array) => void; +}) { + const [dates, setDates] = React.useState>(initialDates); + + const update = (next: Array) => { + setDates(next); + onDatesChange?.(next as Array); + }; + + return ( + + update(date as any)} + range + displayValueAtRangeIndex={0} + mask="9999/99/99" + minDate={minDate} + maxDate={maxDate} + timeSelectStart + overrides={{ + CalendarContainer: { props: { 'data-testid': 'start-calendar' } }, + TimeSelectContainer: { props: { 'data-testid': 'start-time-select' } }, + }} + /> + update(date as any)} + range + displayValueAtRangeIndex={1} + mask="9999/99/99" + minDate={minDate} + maxDate={maxDate} + timeSelectEnd + overrides={{ + CalendarContainer: { props: { 'data-testid': 'end-calendar' } }, + TimeSelectContainer: { props: { 'data-testid': 'end-time-select' } }, + }} + /> + + {dates[0] ? dates[0].toISOString() : 'null'} + {dates[1] ? dates[1].toISOString() : 'null'} + + ); +} + +describe('Composed range datepicker', () => { + beforeEach(() => { + MockDate.set('2021-11-25 10:30'); + }); + + afterEach(() => { + MockDate.reset(); + }); + + it('fires onChange with partial range on first click so parent state updates', () => { + const onDatesChange = jest.fn(); + const { container } = render(); + + const inputs = container.querySelectorAll('input'); + fireEvent.focus(inputs[0]); + + const calendar = queryByTestId(container, 'start-calendar'); + expect(calendar).not.toBeNull(); + + fireEvent.click(getByText(calendar!, '15')); + + const lastCall = onDatesChange.mock.calls[onDatesChange.mock.calls.length - 1][0]; + expect(Array.isArray(lastCall)).toBe(true); + expect(lastCall[0]).toBeTruthy(); + expect(lastCall[0].getDate()).toBe(15); + }); + + it('popover stays open after first click for second click', () => { + const startDate = new Date(2021, 10, 10, 12, 0, 0); + const { container } = render(); + + const inputs = container.querySelectorAll('input'); + fireEvent.focus(inputs[0]); + + expect(queryByTestId(container, 'start-calendar')).not.toBeNull(); + + // With value=[Nov10, null], clicking Nov 20 completes the range. + const calendar = queryByTestId(container, 'start-calendar')!; + fireEvent.click(getByText(calendar, '20')); + + // Both dates set → popover closes + expect(queryByTestId(container, 'start-calendar')).toBeNull(); + }); + + it('two-click range selection from cleared state', () => { + const onDatesChange = jest.fn(); + const { container } = render(); + + const inputs = container.querySelectorAll('input'); + fireEvent.focus(inputs[0]); + + const calendar = queryByTestId(container, 'start-calendar'); + expect(calendar).not.toBeNull(); + + fireEvent.click(getByText(calendar!, '10')); + + expect(onDatesChange).toHaveBeenCalled(); + const firstCall = onDatesChange.mock.calls[onDatesChange.mock.calls.length - 1][0]; + expect(firstCall[0]).toBeTruthy(); + expect(firstCall[0].getDate()).toBe(10); + + expect(queryByTestId(container, 'start-calendar')).not.toBeNull(); + }); + + it('clearing end date input preserves start date', () => { + const startDate = new Date(2021, 10, 10, 12, 0, 0); + const endDate = new Date(2021, 10, 20, 12, 0, 0); + const onDatesChange = jest.fn(); + const { container } = render( + + ); + + const inputs = container.querySelectorAll('input'); + fireEvent.focus(inputs[1]); + fireEvent.change(inputs[1], { target: { value: '' } }); + + const lastCall = onDatesChange.mock.calls[onDatesChange.mock.calls.length - 1][0]; + expect(lastCall[0]).toBeTruthy(); + expect(lastCall[0].getDate()).toBe(10); + expect(lastCall[1]).toBeNull(); + }); + + it('clearing start date input preserves end date', () => { + const startDate = new Date(2021, 10, 10, 12, 0, 0); + const endDate = new Date(2021, 10, 20, 12, 0, 0); + const onDatesChange = jest.fn(); + const { container } = render( + + ); + + const inputs = container.querySelectorAll('input'); + fireEvent.focus(inputs[0]); + fireEvent.change(inputs[0], { target: { value: '' } }); + + const lastCall = onDatesChange.mock.calls[onDatesChange.mock.calls.length - 1][0]; + expect(lastCall[0]).toBeNull(); + expect(lastCall[1]).toBeTruthy(); + expect(lastCall[1].getDate()).toBe(20); + }); + + it('end time picker works when only start date is set', () => { + const startDate = new Date(2021, 10, 10, 12, 0, 0); + const onDatesChange = jest.fn(); + const { container } = render( + + ); + + const inputs = container.querySelectorAll('input'); + fireEvent.focus(inputs[1]); + + const endCalendar = queryByTestId(container, 'end-calendar'); + expect(endCalendar).not.toBeNull(); + + const endTimeSelect = queryByTestId(container, 'end-time-select'); + expect(endTimeSelect).not.toBeNull(); + + const selectInput = endTimeSelect!.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = document.body.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const option3pm = Array.from(listbox!.querySelectorAll('[role="option"]')).find( + (o) => o.textContent === '3:00 PM' + ); + expect(option3pm).toBeTruthy(); + fireEvent.click(option3pm!); + + expect(onDatesChange).toHaveBeenCalled(); + const lastCall = onDatesChange.mock.calls[onDatesChange.mock.calls.length - 1][0]; + expect(lastCall[0]).toBeTruthy(); + expect(lastCall[1]).toBeTruthy(); + expect(lastCall[1].getHours()).toBe(15); + }); +}); + +describe('Calendar time select with minDate/maxDate', () => { + beforeEach(() => { + MockDate.set('2021-11-25 10:30'); + }); + + afterEach(() => { + MockDate.reset(); + }); + + const minDate = new Date(2021, 10, 5, 9, 0, 0); + const maxDate = new Date(2021, 10, 25, 17, 0, 0); + + it('constrains time options when selected date matches minDate', () => { + const startDate = new Date(2021, 10, 5, 10, 0, 0); + const endDate = new Date(2021, 10, 20, 12, 0, 0); + const { container } = render( + + ); + + const inputs = container.querySelectorAll('input'); + fireEvent.focus(inputs[0]); + + const timeSelect = queryByTestId(container, 'start-time-select'); + expect(timeSelect).not.toBeNull(); + + const selectInput = timeSelect!.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = document.body.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const texts = Array.from(listbox!.querySelectorAll('[role="option"]')).map( + (o) => o.textContent + ); + expect(texts).not.toContain('8:00 AM'); + expect(texts).toContain('10:00 AM'); + }); + + it('does not constrain time when date is between min and max', () => { + const startDate = new Date(2021, 10, 15, 10, 0, 0); + const endDate = new Date(2021, 10, 20, 12, 0, 0); + const { container } = render( + + ); + + const inputs = container.querySelectorAll('input'); + fireEvent.focus(inputs[0]); + + const timeSelect = queryByTestId(container, 'start-time-select'); + expect(timeSelect).not.toBeNull(); + + const selectInput = timeSelect!.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = document.body.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const texts = Array.from(listbox!.querySelectorAll('[role="option"]')).map( + (o) => o.textContent + ); + expect(texts).toContain('12:00 AM'); + expect(texts).toContain('8:00 AM'); + expect(texts).toContain('6:00 PM'); + }); + + it('constrains both ends when minDate and maxDate are the same day', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const sameDayMin = new Date(2021, 10, 15, 10, 0, 0); + const sameDayMax = new Date(2021, 10, 15, 16, 0, 0); + const startDate = new Date(2021, 10, 15, 12, 0, 0); + const { container } = render( + + ); + + const inputs = container.querySelectorAll('input'); + fireEvent.focus(inputs[0]); + + const timeSelect = queryByTestId(container, 'start-time-select'); + expect(timeSelect).not.toBeNull(); + + const selectInput = timeSelect!.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = document.body.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const texts = Array.from(listbox!.querySelectorAll('[role="option"]')).map( + (o) => o.textContent + ); + expect(texts).not.toContain('9:00 AM'); + expect(texts).not.toContain('5:00 PM'); + expect(texts).toContain('10:00 AM'); + expect(texts).toContain('12:00 PM'); + expect(texts).toContain('4:00 PM'); + warnSpy.mockRestore(); + }); +}); + +describe('TimePicker with null value', () => { + beforeEach(() => { + MockDate.set('2021-11-25 10:30'); + }); + + afterEach(() => { + MockDate.reset(); + }); + + const minTime = new Date(2021, 10, 5, 9, 0, 0); + const maxTime = new Date(2021, 10, 25, 17, 0, 0); + + it('shows all times when value is null', () => { + const { container } = render( + + + + ); + + const selectInput = container.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = document.body.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const texts = Array.from(listbox!.querySelectorAll('[role="option"]')).map( + (o) => o.textContent + ); + expect(texts).toContain('12:00 AM'); + expect(texts).toContain('8:00 PM'); + expect(texts).toContain('11:45 PM'); + }); + + it('uses minTime date as fallback when value is null', () => { + const onChange = jest.fn(); + const { container } = render( + + + + ); + + const selectInput = container.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = document.body.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const option3pm = Array.from(listbox!.querySelectorAll('[role="option"]')).find( + (o) => o.textContent === '3:00 PM' + ); + expect(option3pm).toBeTruthy(); + fireEvent.click(option3pm!); + + expect(onChange).toHaveBeenCalled(); + const result = onChange.mock.calls[0][0]; + expect(result).toBeTruthy(); + expect(result.getDate()).toBe(5); + expect(result.getHours()).toBe(15); + }); + + it('clamps to maxTime when selected time exceeds it and value is null', () => { + const sameDayMin = new Date(2021, 10, 15, 10, 0, 0); + const sameDayMax = new Date(2021, 10, 15, 16, 0, 0); + const onChange = jest.fn(); + const { container } = render( + + + + ); + + const selectInput = container.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = document.body.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const option8pm = Array.from(listbox!.querySelectorAll('[role="option"]')).find( + (o) => o.textContent === '8:00 PM' + ); + expect(option8pm).toBeTruthy(); + fireEvent.click(option8pm!); + + expect(onChange).toHaveBeenCalled(); + const result = onChange.mock.calls[0][0]; + expect(result).toBeTruthy(); + expect(result.getDate()).toBe(15); + expect(result.getHours()).toBe(16); + expect(result.getMinutes()).toBe(0); + }); + + it('clamps to minTime when selected time is before it and value is null', () => { + const sameDayMin = new Date(2021, 10, 15, 10, 0, 0); + const sameDayMax = new Date(2021, 10, 15, 16, 0, 0); + const onChange = jest.fn(); + const { container } = render( + + + + ); + + const selectInput = container.querySelector('[data-baseweb="select"]'); + if (selectInput?.firstChild) { + fireEvent.click(selectInput.firstChild as HTMLElement); + } + + const listbox = document.body.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const option8am = Array.from(listbox!.querySelectorAll('[role="option"]')).find( + (o) => o.textContent === '8:00 AM' + ); + expect(option8am).toBeTruthy(); + fireEvent.click(option8am!); + + expect(onChange).toHaveBeenCalled(); + const result = onChange.mock.calls[0][0]; + expect(result).toBeTruthy(); + expect(result.getDate()).toBe(15); + expect(result.getHours()).toBe(10); + expect(result.getMinutes()).toBe(0); + }); + + it('rebuilds steps when value transitions from set to null', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const onChange = jest.fn(); + function Case() { + const [value, setValue] = React.useState(new Date(2021, 10, 25, 15, 0, 0)); + return ( + + { + setValue(t); + onChange(t); + }} + /> + + + ); + } + + const { container } = render(); + + fireEvent.click(queryByTestId(container, 'clear')!); + + const selectEl = container.querySelector('[data-baseweb="select"]'); + expect(selectEl).not.toBeNull(); + const input = selectEl!.querySelector('input'); + if (input) { + fireEvent.focus(input); + fireEvent.click(input); + } + + const listbox = document.body.querySelector('[role="listbox"]'); + expect(listbox).not.toBeNull(); + const texts = Array.from(listbox!.querySelectorAll('[role="option"]')).map( + (o) => o.textContent + ); + expect(texts).toContain('6:00 PM'); + expect(texts).toContain('8:00 PM'); + expect(texts).toContain('12:00 AM'); + + warnSpy.mockRestore(); + }); +}); diff --git a/src/datepicker/__tests__/datepicker.stories.tsx b/src/datepicker/__tests__/datepicker.stories.tsx index b5aa7f9f3d..67b193824b 100644 --- a/src/datepicker/__tests__/datepicker.stories.tsx +++ b/src/datepicker/__tests__/datepicker.stories.tsx @@ -25,6 +25,7 @@ import { Scenario as DatepickerDefault } from './datepicker.scenario'; import { Scenario as DatepickerTimeScenario } from './datepicker-time.scenario'; import { Scenario as DatepickersColorStates } from './datepickers-color-states.scenario'; import { Scenario as DatepickersComposedRange } from './datepickers-composed-range.scenario'; +import { Scenario as DatepickersComposedRangeMinMax } from './datepickers-composed-range-min-max.scenario'; import { Scenario as DatepickersComposedSingle } from './datepickers-composed-single.scenario'; import { Scenario as StatefulCalendarOverridesScenario } from './stateful-calendar-overrides.scenario'; import { Scenario as StatefulCalendarScenario } from './stateful-calendar.scenario'; @@ -54,6 +55,7 @@ export const DatepickerTime = () => ; export const OnChangeFlow = () => ; export const StatefulColorStates = () => ; export const StatefulComposedRange = () => ; +export const StatefulComposedRangeMinMax = () => ; export const StatefulComposedSingle = () => ; export const StatefulCalendarOverrides = () => ; export const StatefulCalendar = () => ; diff --git a/src/datepicker/__tests__/datepicker.test.tsx b/src/datepicker/__tests__/datepicker.test.tsx index 464d937474..e428b8e341 100644 --- a/src/datepicker/__tests__/datepicker.test.tsx +++ b/src/datepicker/__tests__/datepicker.test.tsx @@ -316,4 +316,100 @@ describe('Datepicker', () => { fireEvent.focus(getByTestId(container, 'input')); }).not.toThrowError(); }); + + it('fires onChange with partial range in composed picker so parent state updates', () => { + const onChange = jest.fn(); + const { container } = render( + + + + ); + + const input = container.querySelector('input'); + if (input) fireEvent.focus(input); + + const calendar = queryByTestId(container, 'calendar'); + expect(calendar).not.toBeNull(); + + const day15 = getByText(calendar!, '15'); + fireEvent.click(day15); + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + const calledDate = lastCall[0].date; + expect(Array.isArray(calledDate)).toBe(true); + expect(calledDate[0].getDate()).toBe(15); + expect(queryByTestId(container, 'calendar')).not.toBeNull(); + }); + + it('completes range on second click and closes popover in composed picker', () => { + const startDate = new Date('2021-11-10T12:00:00'); + const onChange = jest.fn(); + const { container } = render( + + + + ); + + const input = container.querySelector('input'); + if (input) fireEvent.focus(input); + + expect(queryByTestId(container, 'calendar')).not.toBeNull(); + + const day20 = getByText(container, '20'); + fireEvent.click(day20); + + expect(onChange).toHaveBeenCalled(); + const calledDate = onChange.mock.calls[0][0].date; + expect(Array.isArray(calledDate)).toBe(true); + expect(calledDate[0].getDate()).toBe(10); + expect(calledDate[1].getDate()).toBe(20); + expect(queryByTestId(container, 'calendar')).toBeNull(); + }); + + it('clearing one input in composed picker preserves the other date', () => { + const startDate = new Date('2021-11-10T12:00:00'); + const endDate = new Date('2021-11-20T12:00:00'); + const onChange = jest.fn(); + const { container } = render( + + + + ); + + const input = container.querySelector('input'); + if (input) { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + } + + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + const calledDate = lastCall[0].date; + expect(Array.isArray(calledDate)).toBe(true); + expect(calledDate[0].getDate()).toBe(10); + expect(calledDate[1]).toBeNull(); + }); }); diff --git a/src/datepicker/__tests__/datepickers-composed-range-min-max.scenario.tsx b/src/datepicker/__tests__/datepickers-composed-range-min-max.scenario.tsx new file mode 100644 index 0000000000..5d9cda0f67 --- /dev/null +++ b/src/datepicker/__tests__/datepickers-composed-range-min-max.scenario.tsx @@ -0,0 +1,154 @@ +/* +Copyright (c) Uber Technologies, Inc. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ +import React, { useState } from 'react'; +import { isAfter, isBefore } from 'date-fns'; + +import { useStyletron } from '../../styles'; +import { FormControl } from '../../form-control'; +import ArrowRight from '../../icon/arrow-right'; +import { Datepicker, TimePicker } from '..'; + +const MIN_DATE = new Date(2019, 3, 1, 11, 0, 0); +const MAX_DATE = new Date(2019, 3, 10, 18, 0, 0); +const START_DATE = new Date(2019, 3, 1, 12, 0, 0); +const END_DATE = new Date(2019, 3, 10, 16, 0, 0); + +function printDate(dt) { + if (!dt) return 'undefined'; + return dt.getFullYear() + '/' + (dt.getMonth() + 1) + '/' + dt.getDate(); +} + +function printTime(dt) { + if (!dt) return 'undefined'; + return dt.toLocaleTimeString(); +} + +export function Scenario() { + const [css, theme] = useStyletron(); + const [dates, setDates] = useState>([START_DATE, END_DATE]); + + const inputGap = theme.sizing.scale300; + + return ( +
+
+
+
+ + setDates(date as any)} + timeSelectStart + range + placeholder="Start Date" + displayValueAtRangeIndex={0} + mask="9999/99/99" + overrides={{ + TimeSelectContainer: { + props: { id: 'time-select-start' }, + }, + }} + /> + +
+ +
+ + { + if (time) { + if (dates[1] && isAfter(time, dates[1])) { + setDates([time, time]); + } else { + setDates([time, dates[1]]); + } + } + }} + /> + +
+
+ +
+ +
+ +
+
+ + setDates(date as any)} + timeSelectEnd + range + placeholder="End Date" + displayValueAtRangeIndex={1} + mask="9999/99/99" + /> + +
+ +
+ + { + if (time) { + if (dates[0] && isBefore(time, dates[0])) { + setDates([time, time]); + } else { + setDates([dates[0], time]); + } + } + }} + /> + +
+
+
+ + + +
+

{printDate(dates[0])}

+

{printTime(dates[0])}

+

{printDate(dates[1])}

+

{printTime(dates[1])}

+
+
+ ); +} diff --git a/src/datepicker/__tests__/datepickers-composed-range.scenario.tsx b/src/datepicker/__tests__/datepickers-composed-range.scenario.tsx index 0c2b64a8e1..398b572ce1 100644 --- a/src/datepicker/__tests__/datepickers-composed-range.scenario.tsx +++ b/src/datepicker/__tests__/datepickers-composed-range.scenario.tsx @@ -47,7 +47,6 @@ export function Scenario() { setDates(date as any)} timeSelectStart range @@ -68,8 +67,8 @@ export function Scenario() { { - if (time && dates[1]) { - if (isAfter(time, dates[1])) { + if (time) { + if (dates[1] && isAfter(time, dates[1])) { setDates([time, time]); } else { setDates([time, dates[1]]); @@ -99,7 +98,7 @@ export function Scenario() { setDates(date as any)} timeSelectEnd range @@ -115,8 +114,8 @@ export function Scenario() { { - if (time && dates[0]) { - if (isBefore(time, dates[0])) { + if (time) { + if (dates[0] && isBefore(time, dates[0])) { setDates([time, time]); } else { setDates([dates[0], time]); diff --git a/src/datepicker/calendar.tsx b/src/datepicker/calendar.tsx index 206ee5763b..59096d9a4a 100644 --- a/src/datepicker/calendar.tsx +++ b/src/datepicker/calendar.tsx @@ -397,8 +397,8 @@ export default class Calendar extends React.Component< // with the date value set to the date with updated time if (Array.isArray(this.props.value)) { const dates = this.props.value.map((date, i) => { - if (date && index === i) { - return this.dateHelpers.applyTimeToDate(date, time); + if (index === i) { + return date ? this.dateHelpers.applyTimeToDate(date, time) : newTimeState[index]; } return date; }); @@ -500,6 +500,20 @@ export default class Calendar extends React.Component< ); }; + getTimeSelectProps = (value: T | undefined | null) => { + const { minDate, maxDate } = this.props; + const timeProps: { minTime?: T; maxTime?: T } = {}; + if (value) { + if (minDate && this.dateHelpers.isSameDay(value, minDate)) { + timeProps.minTime = minDate; + } + if (maxDate && this.dateHelpers.isSameDay(value, maxDate)) { + timeProps.maxTime = maxDate; + } + } + return timeProps; + }; + renderTimeSelect: (c: T | undefined | null, b: Function, a: string) => React.ReactNode = ( value, onChange, @@ -523,6 +537,7 @@ export default class Calendar extends React.Component< value={value ? this.dateHelpers.date(value) : value} onChange={onChange} nullable + {...this.getTimeSelectProps(value)} {...timeSelectProps} /> diff --git a/src/datepicker/datepicker.tsx b/src/datepicker/datepicker.tsx index 912f91047d..95f59018da 100644 --- a/src/datepicker/datepicker.tsx +++ b/src/datepicker/datepicker.tsx @@ -95,7 +95,7 @@ export default class Datepicker extends React.Component< const onRangeChange = this.props.onRangeChange; if (Array.isArray(date)) { - if (onChange && date.every(Boolean)) { + if (onChange && (date.every(Boolean) || this.isComposedRangePicker())) { // eslint-disable-next-line @typescript-eslint/no-explicit-any onChange({ date: date as any as Array }); } @@ -322,7 +322,13 @@ export default class Datepicker extends React.Component< inputValue.length === 0 ) { if (this.props.range) { - this.handleChange([]); + if (this.isComposedRangePicker() && Array.isArray(this.props.value)) { + const updated = [...this.props.value]; + updated[this.props.displayValueAtRangeIndex as number] = null; + this.handleChange(updated); + } else { + this.handleChange([]); + } } else { this.handleChange(null); } @@ -449,6 +455,12 @@ export default class Datepicker extends React.Component< return idList; }; + // Two separate Datepicker instances share state to form a start/end range, + // each controlling one slot via displayValueAtRangeIndex (0 = start, 1 = end). + isComposedRangePicker = () => { + return this.props.range && typeof this.props.displayValueAtRangeIndex === 'number'; + }; + hasLockedBehavior = () => { return ( this.props.rangedCalendarBehavior === RANGED_CALENDAR_BEHAVIOR.locked && diff --git a/src/timepicker/timepicker.tsx b/src/timepicker/timepicker.tsx index 63f5c46525..caca2be622 100644 --- a/src/timepicker/timepicker.tsx +++ b/src/timepicker/timepicker.tsx @@ -74,10 +74,18 @@ class TimePicker extends React.Component, TimePicke const adapterChanged = prevProps.adapter !== this.props.adapter; const minTimeChange = prevProps.minTime !== this.props.minTime; const maxTimeChange = prevProps.maxTime !== this.props.maxTime; + const valueDateChanged = + prevProps.value !== this.props.value && + (!prevProps.value !== !this.props.value || + (prevProps.value && + this.props.value && + (this.props.adapter.isValid(prevProps.value) || + this.props.adapter.isValid(this.props.value)) && + !this.props.adapter.isSameDay(prevProps.value, this.props.value))); if (adapterChanged) { this.dateHelpers = new DateHelpers(this.props.adapter); } - if (formatChanged || stepChanged || minTimeChange || maxTimeChange) { + if (formatChanged || stepChanged || minTimeChange || maxTimeChange || valueDateChanged) { const steps = this.buildSteps(); this.setState({ steps }); } @@ -177,7 +185,14 @@ class TimePicker extends React.Component, TimePicke handleChange = (seconds: number) => { const [hours, minutes] = this.dateHelpers.secondsToHourMinute(seconds); - const updatedDate = this.setTime(this.props.value, hours, minutes, 0); + const baseValue = this.props.value || this.props.minTime || this.props.maxTime; + let updatedDate = this.setTime(baseValue, hours, minutes, 0); + if (this.props.minTime && this.props.adapter.isBefore(updatedDate, this.props.minTime)) { + updatedDate = this.props.minTime; + } + if (this.props.maxTime && this.props.adapter.isAfter(updatedDate, this.props.maxTime)) { + updatedDate = this.props.maxTime; + } this.props.onChange && this.props.onChange(updatedDate); }; @@ -199,6 +214,10 @@ class TimePicker extends React.Component, TimePicke start: number; end: number; } => { + if (!this.props.value) { + return { start: 0, end: DAY }; + } + let { minTime: min, maxTime: max, ignoreMinMaxDateComponent } = this.props; const dayStart = this.setTime(this.props.value, 0, 0, 0); const dayEnd = this.setTime(this.props.value, 24, 0, 0);