From 82b86dc1a86372560641acd60476f7bde3b6453e Mon Sep 17 00:00:00 2001 From: Boris Serdiuk Date: Wed, 23 Nov 2022 12:46:12 +0100 Subject: [PATCH] feat: Add slot for custom controls in absolute range date picker --- .../calendar-permutations.page.tsx | 17 ++- .../date-range-picker/custom-control.page.tsx | 86 ++++++++++++ .../date-range-picker/range-calendar.page.tsx | 1 + .../__snapshots__/documenter.test.ts.snap | 21 +++ .../date-range-picker-absolute.test.tsx | 67 +++++++++ src/date-range-picker/calendar/index.tsx | 131 ++++++++++-------- src/date-range-picker/calendar/utils.ts | 4 +- src/date-range-picker/dropdown.tsx | 7 +- src/date-range-picker/index.tsx | 2 + src/date-range-picker/interfaces.ts | 31 +++-- src/date-range-picker/styles.scss | 4 - src/date-range-picker/utils.tsx | 10 +- 12 files changed, 301 insertions(+), 80 deletions(-) create mode 100644 pages/date-range-picker/custom-control.page.tsx diff --git a/pages/date-range-picker/calendar-permutations.page.tsx b/pages/date-range-picker/calendar-permutations.page.tsx index f18a12abf05..b8d580c49d2 100644 --- a/pages/date-range-picker/calendar-permutations.page.tsx +++ b/pages/date-range-picker/calendar-permutations.page.tsx @@ -21,19 +21,26 @@ const intervals = [ ['2021-05-10', '2021-05-30'], ]; -const permutations = createPermutations( - intervals.map(([startDate, endDate]) => ({ +const permutations = createPermutations([ + ...intervals.map(([startDate, endDate]) => ({ value: [{ start: { date: startDate, time: '' }, end: { date: endDate, time: '' } }], setValue: [() => {}], locale: ['en-GB'], startOfWeek: [1], isDateEnabled: [() => true], onChange: [() => {}], - timeInputFormat: ['hh:mm:ss'], + timeInputFormat: ['hh:mm:ss'] as const, i18nStrings: [i18nStrings], dateOnly: [false, true], - })) -); + customAbsoluteRangeControl: [undefined], + })), + { + value: [{ start: { date: '', time: '' }, end: { date: '', time: '' } }], + setValue: [() => {}], + i18nStrings: [i18nStrings], + customAbsoluteRangeControl: [() => 'Custom control'], + }, +]); export default function DateRangePickerCalendarPage() { let i = -1; diff --git a/pages/date-range-picker/custom-control.page.tsx b/pages/date-range-picker/custom-control.page.tsx new file mode 100644 index 00000000000..712ff041fd4 --- /dev/null +++ b/pages/date-range-picker/custom-control.page.tsx @@ -0,0 +1,86 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import startOfWeek from 'date-fns/startOfWeek'; +import endOfWeek from 'date-fns/endOfWeek'; +import startOfMonth from 'date-fns/startOfMonth'; +import endOfMonth from 'date-fns/endOfMonth'; +import enLocale from 'date-fns/locale/en-GB'; +import { Box, DateRangePicker, DateRangePickerProps, Link, FormField } from '~components'; +import { i18nStrings, isValid } from './common'; +import { formatDate } from '~components/internal/utils/date-time'; + +export default function DatePickerScenario() { + const [value, setValue] = useState(null); + + return ( + +

Date range picker with custom control

+ + setValue(e.detail.value)} + locale={enLocale.code} + i18nStrings={i18nStrings} + relativeOptions={[]} + placeholder="Filter by a date and time range" + isValidRange={isValid} + rangeSelectorMode="absolute-only" + customAbsoluteRangeControl={(selectedDate, setSelectedDate) => ( + <> + Auto-select:{' '} + { + const today = formatDate(new Date()); + return setSelectedDate({ + start: { date: today, time: '' }, + end: { date: today, time: '' }, + }); + }} + > + 1D + {' '} + + setSelectedDate({ + start: { + date: formatDate(startOfWeek(new Date(), { locale: enLocale })), + time: '', + }, + end: { + date: formatDate(endOfWeek(new Date(), { locale: enLocale })), + time: '', + }, + }) + } + > + 7D + {' '} + + setSelectedDate({ + start: { date: formatDate(startOfMonth(new Date())), time: '' }, + end: { date: formatDate(endOfMonth(new Date())), time: '' }, + }) + } + > + 1M + {' '} + + setSelectedDate({ + start: { date: '', time: '' }, + end: { date: '', time: '' }, + }) + } + > + None + + + )} + /> + +
+ ); +} diff --git a/pages/date-range-picker/range-calendar.page.tsx b/pages/date-range-picker/range-calendar.page.tsx index af4e5969422..7652aae99e0 100644 --- a/pages/date-range-picker/range-calendar.page.tsx +++ b/pages/date-range-picker/range-calendar.page.tsx @@ -31,6 +31,7 @@ export default function RangeCalendarScenario() { dateOnly={dateOnly} timeInputFormat="hh:mm" isDateEnabled={date => date.getDate() !== 15} + customAbsoluteRangeControl={undefined} /> Focusable element after the range calendar diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index 76fe8db85ed..2496f96e543 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -4843,6 +4843,27 @@ is provided by its parent form field component. "optional": true, "type": "string", }, + Object { + "description": "Specifies an additional control displayed in the dropdown below the range calendar.", + "inlineType": Object { + "name": "DateRangePickerProps.AbsoluteRangeControl", + "parameters": Array [ + Object { + "name": "selectedRange", + "type": "DateRangePickerProps.PendingAbsoluteValue", + }, + Object { + "name": "setSelectedRange", + "type": "React.Dispatch>", + }, + ], + "returnType": "React.ReactNode", + "type": "function", + }, + "name": "customAbsoluteRangeControl", + "optional": true, + "type": "DateRangePickerProps.AbsoluteRangeControl", + }, Object { "defaultValue": "false", "description": "Hides time inputs and changes the input format to date-only, e.g. 2021-04-06. diff --git a/src/date-range-picker/__tests__/date-range-picker-absolute.test.tsx b/src/date-range-picker/__tests__/date-range-picker-absolute.test.tsx index 1db7aab0edf..e58bcbf87ea 100644 --- a/src/date-range-picker/__tests__/date-range-picker-absolute.test.tsx +++ b/src/date-range-picker/__tests__/date-range-picker-absolute.test.tsx @@ -469,5 +469,72 @@ describe('Date range picker', () => { ); }); }); + + describe('custom control', () => { + const customControl: DateRangePickerProps.AbsoluteRangeControl = (value, setValue) => ( + <> +
{JSON.stringify(value)}
+ + + + ); + test('renders current value from calendar', () => { + const { wrapper, getByTestId } = renderDateRangePicker({ + ...defaultProps, + value: { type: 'absolute', startDate: '2022-11-24T12:55:00', endDate: '2022-11-28T11:14:00' }, + customAbsoluteRangeControl: customControl, + }); + act(() => wrapper.findTrigger().click()); + expect(getByTestId('display')).toHaveTextContent( + '{"start":{"date":"2022-11-24","time":"12:55:00"},"end":{"date":"2022-11-28","time":"11:14:00"}}' + ); + }); + + test('can update value in calendar', () => { + const { wrapper, getByTestId } = renderDateRangePicker({ + ...defaultProps, + customAbsoluteRangeControl: customControl, + }); + act(() => wrapper.findTrigger().click()); + getByTestId('set-date').click(); + expect(getByTestId('display')).toHaveTextContent( + '{"start":{"date":"2022-01-02","time":"00:00:00"},"end":{"date":"2022-02-06","time":"12:34:56"}}' + ); + expect(wrapper.findDropdown()!.findSelectedStartDate()!.getElement()).toHaveTextContent('2'); + expect(wrapper.findDropdown()!.findStartDateInput()!.findNativeInput().getElement()).toHaveValue('2022/01/02'); + expect(wrapper.findDropdown()!.findStartTimeInput()!.findNativeInput().getElement()).toHaveValue('00:00:00'); + expect(wrapper.findDropdown()!.findSelectedEndDate()!.getElement()).toHaveTextContent('6'); + expect(wrapper.findDropdown()!.findEndDateInput()!.findNativeInput().getElement()).toHaveValue('2022/02/06'); + expect(wrapper.findDropdown()!.findEndTimeInput()!.findNativeInput().getElement()).toHaveValue('12:34:56'); + }); + + test('can clear value in calendar', () => { + const { wrapper, getByTestId } = renderDateRangePicker({ + ...defaultProps, + value: { type: 'absolute', startDate: '2022-11-24T12:55:00', endDate: '2022-11-28T11:14:00' }, + customAbsoluteRangeControl: customControl, + }); + act(() => wrapper.findTrigger().click()); + getByTestId('clear-date').click(); + expect(getByTestId('display')).toHaveTextContent('{"start":{"date":"","time":""},"end":{"date":"","time":""}}'); + expect(wrapper.findDropdown()!.findSelectedStartDate()).toBeNull(); + expect(wrapper.findDropdown()!.findStartDateInput()!.findNativeInput().getElement()).toHaveValue(''); + expect(wrapper.findDropdown()!.findStartTimeInput()!.findNativeInput().getElement()).toHaveValue(''); + expect(wrapper.findDropdown()!.findSelectedEndDate()).toBeNull(); + expect(wrapper.findDropdown()!.findEndDateInput()!.findNativeInput().getElement()).toHaveValue(''); + expect(wrapper.findDropdown()!.findEndTimeInput()!.findNativeInput().getElement()).toHaveValue(''); + }); + }); }); }); diff --git a/src/date-range-picker/calendar/index.tsx b/src/date-range-picker/calendar/index.tsx index 1a9823039e7..320a984f341 100644 --- a/src/date-range-picker/calendar/index.tsx +++ b/src/date-range-picker/calendar/index.tsx @@ -4,8 +4,9 @@ import React, { useState } from 'react'; import { addMonths, endOfDay, isAfter, isBefore, isSameMonth, startOfDay, startOfMonth } from 'date-fns'; import styles from '../styles.css.js'; +import SpaceBetween from '../../space-between/internal'; import { BaseComponentProps } from '../../internal/base-component'; -import { DateTimeStrings, PendingAbsoluteValue, RangeCalendarI18nStrings } from '../interfaces'; +import { DateRangePickerProps, RangeCalendarI18nStrings } from '../interfaces'; import CalendarHeader from './header'; import { Grids } from './grids'; import { TimeInputProps } from '../../time-input/interfaces'; @@ -21,14 +22,15 @@ import RangeInputs from './range-inputs.js'; import { findDateToFocus, findMonthToDisplay } from './utils'; export interface DateRangePickerCalendarProps extends BaseComponentProps { - value: PendingAbsoluteValue; - setValue: React.Dispatch>; + value: DateRangePickerProps.PendingAbsoluteValue; + setValue: React.Dispatch>; locale?: string; startOfWeek?: number; isDateEnabled?: (date: Date) => boolean; i18nStrings: RangeCalendarI18nStrings; dateOnly?: boolean; timeInputFormat?: TimeInputProps.Format; + customAbsoluteRangeControl: DateRangePickerProps.AbsoluteRangeControl | undefined; } export default function DateRangePickerCalendar({ @@ -40,6 +42,7 @@ export default function DateRangePickerCalendar({ i18nStrings, dateOnly = false, timeInputFormat = 'hh:mm:ss', + customAbsoluteRangeControl, }: DateRangePickerCalendarProps) { const isSingleGrid = useMobile(); const normalizedLocale = normalizeLocale('DateRangePicker', locale); @@ -60,6 +63,13 @@ export default function DateRangePickerCalendar({ return findDateToFocus(parseDate(value.start.date), currentMonth, isDateEnabled); }); + const updateCurrentMonth = (startDate: string) => { + if (startDate.length >= 8) { + const newCurrentMonth = startOfMonth(parseDate(startDate)); + setCurrentMonth(isSingleGrid ? newCurrentMonth : addMonths(newCurrentMonth, 1)); + } + }; + // recommended to include the start/end time announced with the selection // because the user is not aware of the fact that a start/end time is also set as soon as they select a date const announceStart = (startDate: Date) => { @@ -144,7 +154,10 @@ export default function DateRangePickerCalendar({ } } - const formatValue = (date: Date | null | undefined, previous: DateTimeStrings): DateTimeStrings => { + const formatValue = ( + date: Date | null | undefined, + previous: DateRangePickerProps.DateTimeStrings + ): DateRangePickerProps.DateTimeStrings => { if (date === null) { // explicitly reset to empty return { date: '', time: '' }; @@ -171,12 +184,19 @@ export default function DateRangePickerCalendar({ }; const onChangeStartDate = (value: string) => { - setValue((oldValue: PendingAbsoluteValue) => ({ ...oldValue, start: { ...oldValue.start, date: value } })); + setValue((oldValue: DateRangePickerProps.PendingAbsoluteValue) => ({ + ...oldValue, + start: { ...oldValue.start, date: value }, + })); + updateCurrentMonth(value); + }; - if (value.length >= 8) { - const newCurrentMonth = startOfMonth(parseDate(value)); - setCurrentMonth(isSingleGrid ? newCurrentMonth : addMonths(newCurrentMonth, 1)); - } + const interceptedSetValue: DateRangePickerCalendarProps['setValue'] = newValue => { + setValue(oldValue => { + const updated = typeof newValue === 'function' ? newValue(oldValue) : newValue; + updateCurrentMonth(updated.start.date); + return updated; + }); }; const headingIdPrefix = useUniqueId('date-range-picker-calendar-heading'); @@ -187,53 +207,56 @@ export default function DateRangePickerCalendar({ [styles['one-grid']]: isSingleGrid, })} > -
- + +
+ + + +
- + setValue(oldValue => ({ ...oldValue, start: { ...oldValue.start, time: value } })) + } + endDate={value.end.date} + onChangeEndDate={value => setValue(oldValue => ({ ...oldValue, end: { ...oldValue.end, date: value } }))} + endTime={value.end.time} + onChangeEndTime={value => setValue(oldValue => ({ ...oldValue, end: { ...oldValue.end, time: value } }))} + i18nStrings={i18nStrings} + dateOnly={dateOnly} + timeInputFormat={timeInputFormat} /> -
- - - setValue(oldValue => ({ ...oldValue, start: { ...oldValue.start, time: value } })) - } - endDate={value.end.date} - onChangeEndDate={value => setValue(oldValue => ({ ...oldValue, end: { ...oldValue.end, date: value } }))} - endTime={value.end.time} - onChangeEndTime={value => setValue(oldValue => ({ ...oldValue, end: { ...oldValue.end, time: value } }))} - i18nStrings={i18nStrings} - dateOnly={dateOnly} - timeInputFormat={timeInputFormat} - /> + {customAbsoluteRangeControl &&
{customAbsoluteRangeControl(value, interceptedSetValue)}
} + {announcement} diff --git a/src/date-range-picker/calendar/utils.ts b/src/date-range-picker/calendar/utils.ts index d18c103f4d5..9c1f0d5a851 100644 --- a/src/date-range-picker/calendar/utils.ts +++ b/src/date-range-picker/calendar/utils.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { addMonths, isSameMonth, startOfMonth } from 'date-fns'; -import { DateRangePickerProps, PendingAbsoluteValue } from '../interfaces'; +import { DateRangePickerProps } from '../interfaces'; import { parseDate } from '../../internal/utils/date-time'; export function findDateToFocus( @@ -22,7 +22,7 @@ export function findDateToFocus( return null; } -export function findMonthToDisplay(value: PendingAbsoluteValue, isSingleGrid: boolean) { +export function findMonthToDisplay(value: DateRangePickerProps.PendingAbsoluteValue, isSingleGrid: boolean) { if (value.start.date) { const startDate = parseDate(value.start.date); if (isSingleGrid) { diff --git a/src/date-range-picker/dropdown.tsx b/src/date-range-picker/dropdown.tsx index 5818570ef53..2671efe4fdb 100644 --- a/src/date-range-picker/dropdown.tsx +++ b/src/date-range-picker/dropdown.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React, { useEffect, useRef, useState } from 'react'; -import { DateRangePickerProps, PendingAbsoluteValue } from './interfaces'; +import { DateRangePickerProps } from './interfaces'; import Calendar from './calendar'; import { ButtonProps } from '../button/interfaces'; import { InternalButton } from '../button/internal'; @@ -43,6 +43,7 @@ export interface DateRangePickerDropdownProps ariaLabelledby?: string; ariaDescribedby?: string; + customAbsoluteRangeControl: DateRangePickerProps.AbsoluteRangeControl | undefined; } export function DateRangePickerDropdown({ @@ -63,12 +64,13 @@ export function DateRangePickerDropdown({ rangeSelectorMode, ariaLabelledby, ariaDescribedby, + customAbsoluteRangeControl, }: DateRangePickerDropdownProps) { const [rangeSelectionMode, setRangeSelectionMode] = useState<'absolute' | 'relative'>( getDefaultMode(value, relativeOptions, rangeSelectorMode) ); - const [selectedAbsoluteRange, setSelectedAbsoluteRange] = useState(() => + const [selectedAbsoluteRange, setSelectedAbsoluteRange] = useState(() => splitAbsoluteValue(value?.type === 'absolute' ? value : null) ); @@ -172,6 +174,7 @@ export function DateRangePickerDropdown({ i18nStrings={i18nStrings} dateOnly={dateOnly} timeInputFormat={timeInputFormat} + customAbsoluteRangeControl={customAbsoluteRangeControl} /> )} diff --git a/src/date-range-picker/index.tsx b/src/date-range-picker/index.tsx index 5f4a9ec8e65..33c763ac74c 100644 --- a/src/date-range-picker/index.tsx +++ b/src/date-range-picker/index.tsx @@ -103,6 +103,7 @@ const DateRangePicker = React.forwardRef( timeInputFormat = 'hh:mm:ss', expandToViewport = false, rangeSelectorMode = 'default', + customAbsoluteRangeControl, ...rest }: DateRangePickerProps, ref: Ref @@ -273,6 +274,7 @@ const DateRangePicker = React.forwardRef( rangeSelectorMode={rangeSelectorMode} ariaLabelledby={ariaLabelledby} ariaDescribedby={ariaDescribedby} + customAbsoluteRangeControl={customAbsoluteRangeControl} /> )} diff --git a/src/date-range-picker/interfaces.ts b/src/date-range-picker/interfaces.ts index 672954a4f74..9211c73e119 100644 --- a/src/date-range-picker/interfaces.ts +++ b/src/date-range-picker/interfaces.ts @@ -5,6 +5,7 @@ import { FormFieldValidationControlProps } from '../internal/context/form-field- import { NonCancelableEventHandler } from '../internal/events'; import { TimeInputProps } from '../time-input/interfaces'; import { ExpandToViewport } from '../internal/components/dropdown/interfaces'; +import React from 'react'; export interface DateRangePickerBaseProps { /** @@ -152,6 +153,11 @@ export interface DateRangePickerProps * allows the user to clear the selected value. */ showClearButton?: boolean; + + /** + * Specifies an additional control displayed in the dropdown below the range calendar. + */ + customAbsoluteRangeControl?: DateRangePickerProps.AbsoluteRangeControl; } export namespace DateRangePickerProps { @@ -218,6 +224,21 @@ export namespace DateRangePickerProps { (date: Date): number; } + export interface DateTimeStrings { + date: string; + time: string; + } + + export interface PendingAbsoluteValue { + start: DateTimeStrings; + end: DateTimeStrings; + } + + export type AbsoluteRangeControl = ( + selectedRange: PendingAbsoluteValue, + setSelectedRange: React.Dispatch> + ) => React.ReactNode; + export type RangeSelectorMode = 'default' | 'absolute-only' | 'relative-only'; export interface Ref { @@ -368,16 +389,6 @@ export namespace DateRangePickerProps { export type DayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6; -export interface DateTimeStrings { - date: string; - time: string; -} - -export interface PendingAbsoluteValue { - start: DateTimeStrings; - end: DateTimeStrings; -} - export type RangeCalendarI18nStrings = Pick< DateRangePickerProps.I18nStrings, | 'todayAriaLabel' diff --git a/src/date-range-picker/styles.scss b/src/date-range-picker/styles.scss index 34996abd301..1ba3f571dcb 100644 --- a/src/date-range-picker/styles.scss +++ b/src/date-range-picker/styles.scss @@ -38,10 +38,6 @@ $calendar-header-color: awsui.$color-text-body-default; } .calendar { - display: block; - width: 100%; - margin-bottom: awsui.$space-scaled-s; - &-header { display: flex; justify-content: space-between; diff --git a/src/date-range-picker/utils.tsx b/src/date-range-picker/utils.tsx index c2c27bf6305..1a2467614c2 100644 --- a/src/date-range-picker/utils.tsx +++ b/src/date-range-picker/utils.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { DateRangePickerProps, PendingAbsoluteValue } from './interfaces'; +import { DateRangePickerProps } from './interfaces'; import { setTimeOffset } from './time-offset'; import { joinDateTime, splitDateTime } from '../internal/utils/date-time'; @@ -38,7 +38,9 @@ export function getDefaultMode( return relativeOptions.length > 0 ? 'relative' : 'absolute'; } -export function splitAbsoluteValue(value: null | DateRangePickerProps.AbsoluteValue): PendingAbsoluteValue { +export function splitAbsoluteValue( + value: null | DateRangePickerProps.AbsoluteValue +): DateRangePickerProps.PendingAbsoluteValue { if (!value) { return { start: { date: '', time: '' }, @@ -48,7 +50,9 @@ export function splitAbsoluteValue(value: null | DateRangePickerProps.AbsoluteVa return { start: splitDateTime(value.startDate), end: splitDateTime(value.endDate) }; } -export function joinAbsoluteValue(value: PendingAbsoluteValue): DateRangePickerProps.AbsoluteValue { +export function joinAbsoluteValue( + value: DateRangePickerProps.PendingAbsoluteValue +): DateRangePickerProps.AbsoluteValue { return { type: 'absolute', startDate: joinDateTime(value.start.date, value.start.time || '00:00:00'),