From 383bd875766c0778cda49427c046be5ab5a3e840 Mon Sep 17 00:00:00 2001 From: Bucky Schwarz Date: Thu, 18 Jun 2026 16:10:36 -0400 Subject: [PATCH] feat(datepickers): add optional hasClearButton prop to datepickers --- .changeset/tall-schools-agree.md | 5 + src/components/DatePicker/Common.tsx | 153 ++++++++++++++++-- .../DatePicker/DatePicker.stories.tsx | 16 ++ src/components/DatePicker/DatePicker.test.tsx | 63 ++++++++ src/components/DatePicker/DatePicker.tsx | 21 ++- .../DatePicker/DateRangePicker.stories.tsx | 25 +++ .../DatePicker/DateRangePicker.test.tsx | 69 ++++++++ src/components/DatePicker/DateRangePicker.tsx | 9 ++ .../DateTimeRangePicker.stories.tsx | 26 +++ .../DatePicker/DateTimeRangePicker.test.tsx | 72 +++++++++ .../DatePicker/DateTimeRangePicker.tsx | 9 ++ 11 files changed, 450 insertions(+), 18 deletions(-) create mode 100644 .changeset/tall-schools-agree.md diff --git a/.changeset/tall-schools-agree.md b/.changeset/tall-schools-agree.md new file mode 100644 index 000000000..53332550a --- /dev/null +++ b/.changeset/tall-schools-agree.md @@ -0,0 +1,5 @@ +--- +'@clickhouse/click-ui': minor +--- + +Adds the property `hasCloseButton`, which shows a close button on the right side of the input to the date pickers. diff --git a/src/components/DatePicker/Common.tsx b/src/components/DatePicker/Common.tsx index 0f6323e54..59f19e32e 100644 --- a/src/components/DatePicker/Common.tsx +++ b/src/components/DatePicker/Common.tsx @@ -2,6 +2,7 @@ import { styled } from 'styled-components'; import { InputElement, InputStartContent, InputWrapper } from '@/components/InputWrapper'; import { KeyboardEvent, + MouseEvent, ReactNode, useCallback, useEffect, @@ -45,9 +46,10 @@ const yearsOffset = Math.floor(totalYears / 2); const HighlightedInputWrapper = styled(InputWrapper)<{ $isActive: boolean; $width?: string; + $minWidth?: string; error?: boolean; }>` - ${({ $isActive, $width, error, theme }) => { + ${({ $isActive, $width, $minWidth, error, theme }) => { let borderColor = $isActive ? theme.click.datePicker.dateOption.color.stroke.active : theme.click.field.color.stroke.default; @@ -58,21 +60,37 @@ const HighlightedInputWrapper = styled(InputWrapper)<{ return `border: ${theme.click.datePicker.dateOption.stroke} solid ${borderColor}; width: ${$width ? $width : explicitWidth}; - ${$width && `min-width: ${explicitWidth};`} + ${$minWidth ? `min-width: ${$minWidth};` : ''} `; }} }`; -interface DatePickerInputProps { - isActive: boolean; - disabled: boolean; - id?: string; - partialMonth?: number; - partialYear?: number; - placeholder?: string; - selectedDate?: Date; - timezone?: Timezone; -} +// This span is rendered in the Date*PickerInputs, which are children of a Popover Trigger +// which are buttons. So using an IconButton will cause React to throw an error since a button can't be inside a button +const ClearButton = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; + ${({ theme }) => ` + padding: ${theme.click.button.iconButton.default.space.y} ${theme.click.button.iconButton.default.space.x}; + border: 1px solid ${theme.click.button.iconButton.color.primary.stroke.default}; + border-radius: ${theme.click.button.iconButton.radii.all}; + color: ${theme.click.button.iconButton.color.primary.text.default}; + background-color: ${theme.click.button.iconButton.color.primary.background.default}; + + &:hover { + border-color: ${theme.click.button.iconButton.color.primary.stroke.hover}; + background-color: ${theme.click.button.iconButton.color.primary.background.hover}; + } + + &:active { + border-color: ${theme.click.button.iconButton.color.primary.stroke.active}; + background-color: ${theme.click.button.iconButton.color.primary.background.active}; + } + `} +`; const formatPartialDate = ( timezone: Timezone, @@ -99,10 +117,25 @@ const formatPartialDate = ( return ''; }; +interface DatePickerInputProps { + isActive: boolean; + disabled: boolean; + hasClearButton?: boolean; + id?: string; + onClear?: () => void; + partialMonth?: number; + partialYear?: number; + placeholder?: string; + selectedDate?: Date; + timezone?: Timezone; +} + export const DatePickerInput = ({ isActive, disabled, + hasClearButton, id, + onClear, partialMonth, partialYear, placeholder, @@ -117,6 +150,17 @@ export const DatePickerInput = ({ partialMonth ); + // Stop propagation to keep the event from bubbling up to the dropdown trigger and opening the dropdown + const handleClear = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + if (onClear) { + onClear(); + } + }, + [onClear] + ); + return ( + {hasClearButton && !disabled && ( + + + + )} ); }; @@ -140,7 +197,9 @@ export const DatePickerInput = ({ interface DateRangePickerInputProps { isActive: boolean; disabled: boolean; + hasClearButton?: boolean; id?: string; + onClear?: () => void; placeholder?: string; selectedEndDate?: Date; selectedStartDate?: Date; @@ -150,7 +209,9 @@ interface DateRangePickerInputProps { export const DateRangePickerInput = ({ isActive, disabled, + hasClearButton, id, + onClear, placeholder, selectedEndDate, selectedStartDate, @@ -158,6 +219,23 @@ export const DateRangePickerInput = ({ }: DateRangePickerInputProps) => { const defaultId = useId(); + // Stop propagation to keep the event from bubbling up to the dropdown trigger and opening the dropdown + const handleClear = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + if (onClear) { + onClear(); + } + }, + [onClear] + ); + + // The Dropdown.Trigger wrapping this input toggles on pointerdown, so stop + // pointerdown (and click) from bubbling up to it when clearing. + const handleClearPointerDown = useCallback((event: MouseEvent) => { + event.stopPropagation(); + }, []); + let formattedValue = ( @@ -205,6 +284,20 @@ export const DateRangePickerInput = ({ > {formattedValue} + {hasClearButton && !disabled && ( + + + + )} ); }; @@ -212,7 +305,9 @@ export const DateRangePickerInput = ({ interface DateTimeRangePickerInputProps { isActive: boolean; disabled: boolean; + hasClearButton?: boolean; id?: string; + onClear?: () => void; placeholder?: string; selectedEndDate?: Date; selectedStartDate?: Date; @@ -223,7 +318,9 @@ interface DateTimeRangePickerInputProps { export const DateTimeRangePickerInput = ({ isActive, disabled, + hasClearButton, id, + onClear, placeholder, selectedEndDate, selectedStartDate, @@ -232,6 +329,23 @@ export const DateTimeRangePickerInput = ({ }: DateTimeRangePickerInputProps) => { const defaultId = useId(); + // Stop propagation to keep the event from bubbling up to the dropdown trigger and opening the dropdown + const handleClear = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + if (onClear) { + onClear(); + } + }, + [onClear] + ); + + // The Dropdown.Trigger wrapping this input toggles on pointerdown, so stop + // pointerdown (and click) from bubbling up to it when clearing. + const handleClearPointerDown = useCallback((event: MouseEvent) => { + event.stopPropagation(); + }, []); + const formatDateTime = useCallback( (date: Date) => { if (shouldShowSeconds) { @@ -299,6 +413,7 @@ export const DateTimeRangePickerInput = ({ {formattedValue} + {hasClearButton && !disabled && ( + + + + )} ); }; diff --git a/src/components/DatePicker/DatePicker.stories.tsx b/src/components/DatePicker/DatePicker.stories.tsx index a327f6ef7..950a899fd 100644 --- a/src/components/DatePicker/DatePicker.stories.tsx +++ b/src/components/DatePicker/DatePicker.stories.tsx @@ -101,3 +101,19 @@ export const TimezoneLocalVsUTC = { ); }, }; + +export const HasClearButton = { + ...defaultStory, + render: (args: Args) => { + const date = args.date ? new Date(args.date) : new Date('2026-04-15'); + return ( + + ); + }, +}; diff --git a/src/components/DatePicker/DatePicker.test.tsx b/src/components/DatePicker/DatePicker.test.tsx index dc07e83a2..fa5a43404 100644 --- a/src/components/DatePicker/DatePicker.test.tsx +++ b/src/components/DatePicker/DatePicker.test.tsx @@ -623,4 +623,67 @@ describe('DatePicker', () => { }); }); }); + + describe('clearing the selected date', () => { + it('hides the clear control until it is explicitly enabled', () => { + const { queryByTestId } = renderCUI(); + + expect(queryByTestId('datepicker-input-clear')).not.toBeInTheDocument(); + }); + + it('offers a clear control when enabled', () => { + const { getByTestId } = renderCUI( + + ); + + expect(getByTestId('datepicker-input-clear')).toBeInTheDocument(); + }); + + it('withholds the clear control while the picker is disabled', () => { + const { queryByTestId } = renderCUI( + + ); + + expect(queryByTestId('datepicker-input-clear')).not.toBeInTheDocument(); + }); + + describe('when the clear control is clicked', () => { + it('removes the currently selected date', async () => { + const { getByTestId } = renderCUI( + + ); + + expect(getByTestId('datepicker-input')).toHaveValue('Jul 04, 2020'); + + await userEvent.click(getByTestId('datepicker-input-clear')); + + expect(getByTestId('datepicker-input')).toHaveValue(''); + }); + + it('leaves the calendar closed', async () => { + const { getByTestId, queryByTestId } = renderCUI( + + ); + + await userEvent.click(getByTestId('datepicker-input-clear')); + + expect(queryByTestId('datepicker-calendar-container')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index 257bbaf3c..35bbceafe 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -202,6 +202,7 @@ export interface DatePickerProps { date?: Date; disabled?: boolean; futureDatesDisabled?: boolean; + hasClearButton?: boolean; onSelectDate: (selectedDate: Date) => void; placeholder?: string; responsivePositioning?: boolean; @@ -213,6 +214,7 @@ export const DatePicker = ({ date, disabled = false, futureDatesDisabled = false, + hasClearButton = false, onSelectDate, placeholder, responsivePositioning = true, @@ -266,17 +268,17 @@ export const DatePicker = ({ [onSelectDate, resetPartialState] ); - const onYearSelect = useCallback((year: number) => { + const handleYearSelect = useCallback((year: number) => { setPartialYear(year); setPartialMonth(undefined); }, []); - const onMonthSelect = useCallback((year: number, month: number) => { + const handleMonthSelect = useCallback((year: number, month: number) => { setPartialYear(year); setPartialMonth(month); }, []); - const onTriggerKeyDown = useCallback((e: KeyboardEvent) => { + const handleTriggerKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsOpen(true); @@ -284,6 +286,11 @@ export const DatePicker = ({ } }, []); + const handleClear = useCallback(() => { + resetPartialState(); + setSelectedDate(undefined); + }, [resetPartialState]); + return ( diff --git a/src/components/DatePicker/DateRangePicker.stories.tsx b/src/components/DatePicker/DateRangePicker.stories.tsx index a3454322d..e94f815c8 100644 --- a/src/components/DatePicker/DateRangePicker.stories.tsx +++ b/src/components/DatePicker/DateRangePicker.stories.tsx @@ -106,6 +106,31 @@ export const DateRangeFutureStartDatesDisabled: Story = { }, }; +export const HasClearButton: Story = { + args: { + predefinedDatesList: [], + }, + render: (args: Args) => { + const endDate = args.endDate ? new Date(args.endDate) : new Date('2026-04-30'); + const startDate = args.startDate ? new Date(args.startDate) : new Date('2026-04-15'); + + return ( + + ); + }, +}; + export const PredefinedDatesLastSixMonths: Story = { render: (args: Args) => { const endDate = args.endDate ? new Date(args.endDate) : undefined; diff --git a/src/components/DatePicker/DateRangePicker.test.tsx b/src/components/DatePicker/DateRangePicker.test.tsx index fe41c4014..abd0d8d33 100644 --- a/src/components/DatePicker/DateRangePicker.test.tsx +++ b/src/components/DatePicker/DateRangePicker.test.tsx @@ -485,4 +485,73 @@ describe('DateRangePicker', () => { expect(emittedEnd.toISOString()).toBe('2026-04-22T00:00:00.000Z'); }); }); + + describe('clearing the selected date range', () => { + it('hides the clear control until it is explicitly enabled', () => { + const { queryByTestId } = renderCUI( + + ); + + expect(queryByTestId('daterangepicker-input-clear')).not.toBeInTheDocument(); + }); + + it('offers a clear control when enabled', () => { + const { getByTestId } = renderCUI( + + ); + + expect(getByTestId('daterangepicker-input-clear')).toBeInTheDocument(); + }); + + it('withholds the clear control while the picker is disabled', () => { + const { queryByTestId } = renderCUI( + + ); + + expect(queryByTestId('daterangepicker-input-clear')).not.toBeInTheDocument(); + }); + + describe('when the clear control is clicked', () => { + it('removes both the start and end dates', async () => { + const startDate = new Date('07-04-2020'); + const endDate = new Date('07-05-2020'); + const { getByTestId, getByText, queryByText } = renderCUI( + + ); + + expect(getByText('Jul 04, 2020 – Jul 05, 2020')).toBeInTheDocument(); + + await userEvent.click(getByTestId('daterangepicker-input-clear')); + + expect(queryByText('Jul 04, 2020 – Jul 05, 2020')).not.toBeInTheDocument(); + expect(getByText('start date – end date')).toBeInTheDocument(); + }); + + it('leaves the calendar closed', async () => { + const { getByTestId, queryByTestId } = renderCUI( + + ); + + await userEvent.click(getByTestId('daterangepicker-input-clear')); + + expect(queryByTestId('datepicker-calendar-container')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/components/DatePicker/DateRangePicker.tsx b/src/components/DatePicker/DateRangePicker.tsx index 38bb9554c..35b7aadb7 100644 --- a/src/components/DatePicker/DateRangePicker.tsx +++ b/src/components/DatePicker/DateRangePicker.tsx @@ -296,6 +296,7 @@ export interface DateRangePickerProps { disabled?: boolean; futureDatesDisabled?: boolean; futureStartDatesDisabled?: boolean; + hasClearButton?: boolean; onSelectDateRange: (selectedStartDate: Date, selectedEndDate: Date) => void; openDirection?: OpenDirection; placeholder?: string; @@ -312,6 +313,7 @@ export const DateRangePicker = ({ disabled = false, futureDatesDisabled = false, futureStartDatesDisabled = false, + hasClearButton = false, maxRangeLength = -1, onSelectDateRange, openDirection = 'right', @@ -363,6 +365,11 @@ export const DateRangePicker = ({ setCalendarOpenDirection(openDirection); }, [openDirection]); + const handleClear = useCallback((): void => { + setSelectedStartDate(undefined); + setSelectedEndDate(undefined); + }, []); + const handleOpenChange = (isOpen: boolean): void => { setIsOpen(isOpen); @@ -436,7 +443,9 @@ export const DateRangePicker = ({ { + const endDate = args.endDate ? new Date(args.endDate) : new Date('2026-04-30'); + const startDate = args.startDate ? new Date(args.startDate) : new Date('2026-04-15'); + + return ( + + ); + }, +}; + export const DateTimeRangePickerLeftSide: Story = { render: (args: Args) => { const endDate = args.endDate ? new Date(args.endDate) : undefined; diff --git a/src/components/DatePicker/DateTimeRangePicker.test.tsx b/src/components/DatePicker/DateTimeRangePicker.test.tsx index 76171c7b5..adb659537 100644 --- a/src/components/DatePicker/DateTimeRangePicker.test.tsx +++ b/src/components/DatePicker/DateTimeRangePicker.test.tsx @@ -1215,4 +1215,76 @@ describe('DateTimeRangePicker', () => { expect((timeInputs[0] as HTMLInputElement).value).toBe('08:00'); }); }); + + describe('clearing the selected date range', () => { + it('hides the clear control until it is explicitly enabled', () => { + const { queryByTestId } = renderCUI( + + ); + + expect(queryByTestId('datetimepicker-input-clear')).not.toBeInTheDocument(); + }); + + it('offers a clear control when enabled', () => { + const { getByTestId } = renderCUI( + + ); + + expect(getByTestId('datetimepicker-input-clear')).toBeInTheDocument(); + }); + + it('withholds the clear control while the picker is disabled', () => { + const { queryByTestId } = renderCUI( + + ); + + expect(queryByTestId('datetimepicker-input-clear')).not.toBeInTheDocument(); + }); + + describe('when the clear control is clicked', () => { + it('removes both the start and end dates', async () => { + const startDate = new Date('07-04-2020'); + const endDate = new Date('07-05-2020'); + const { getByTestId } = renderCUI( + + ); + + expect(getByTestId('datetimepicker-input').textContent).toBe( + 'Jul 04, 12:00 pm – Jul 05, 12:00 pm' + ); + + await userEvent.click(getByTestId('datetimepicker-input-clear')); + + expect(getByTestId('datetimepicker-input').textContent).toBe( + 'start date – end date' + ); + }); + + it('leaves the calendar closed', async () => { + const { getByTestId, queryByTestId } = renderCUI( + + ); + + await userEvent.click(getByTestId('datetimepicker-input-clear')); + + expect(queryByTestId('datepicker-calendar-container')).not.toBeInTheDocument(); + }); + }); + }); }); diff --git a/src/components/DatePicker/DateTimeRangePicker.tsx b/src/components/DatePicker/DateTimeRangePicker.tsx index ed5332815..cdf198ad6 100644 --- a/src/components/DatePicker/DateTimeRangePicker.tsx +++ b/src/components/DatePicker/DateTimeRangePicker.tsx @@ -773,6 +773,7 @@ export interface DateTimeRangePickerProps { endDate?: Date; futureDatesDisabled?: boolean; futureStartDatesDisabled?: boolean; + hasClearButton?: boolean; onSelectDateRange: ( startDate: Date, endDate: Date, @@ -796,6 +797,7 @@ export const DateTimeRangePicker = ({ endDate, futureDatesDisabled = false, futureStartDatesDisabled = false, + hasClearButton = false, maxRangeLength = -1, onSelectDateRange, openDirection = 'right', @@ -871,6 +873,11 @@ export const DateTimeRangePicker = ({ setCalendarOpenDirection('right'); }, []); + const handleClear = useCallback((): void => { + setSelectedStartDate(undefined); + setSelectedEndDate(undefined); + }, []); + const handleOpenChange = useCallback((isOpen: boolean): void => { setIsOpen(isOpen); @@ -972,7 +979,9 @@ export const DateTimeRangePicker = ({