Skip to content

Commit 6995a02

Browse files
feat(design-system): align clear button behavior across date and time pickers [AR-48187] (#319)
## Context During the demo Ruth asked to make clear icon button appear only on input hover or focus. Also I decided to add clear button icon to the TimePicker to have the same behavior in DsDatePicker and DsTimePicker components. ## Showcase https://github.com/user-attachments/assets/ee3cf071-c6aa-4e12-8cdb-158570ebdd37
1 parent 4863637 commit 6995a02

8 files changed

Lines changed: 135 additions & 12 deletions

File tree

.changeset/lucky-numbers-cheat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@drivenets/design-system': patch
3+
---
4+
5+
Align clear button behavior across `DsDatePicker` and `DsTimePicker` components

packages/design-system/src/components/ds-date-picker/__tests__/ds-date-picker.browser.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,50 @@ describe('DsDatePicker', () => {
117117
await userEvent.keyboard('{Escape}');
118118
});
119119

120+
it('should show clear button on hover when value is selected', async () => {
121+
await page.render(
122+
<div>
123+
<button type="button">outside</button>
124+
<DsDatePicker value={new Date('2026-01-15T00:00:00')} />
125+
</div>,
126+
);
127+
128+
await page.getByPlaceholder('mm/dd/yyyy').hover();
129+
await expect.element(page.getByRole('button', { name: /clear date/i })).toBeVisible();
130+
131+
await page.getByRole('button', { name: 'outside' }).hover();
132+
await expect.element(page.getByRole('button', { name: /clear date/i })).not.toBeInTheDocument();
133+
});
134+
135+
it('should not render clear button when hideClearButton is true', async () => {
136+
await page.render(<DsDatePicker value={new Date('2026-01-15T00:00:00')} hideClearButton />);
137+
138+
const input = page.getByPlaceholder('mm/dd/yyyy');
139+
140+
await input.hover();
141+
142+
await expect.element(page.getByRole('button', { name: /clear date/i })).not.toBeInTheDocument();
143+
});
144+
145+
it('should not show clear button in the nested time picker', async () => {
146+
function Wrapper() {
147+
const [value, setValue] = useState<Date | null>(new Date('2026-01-15T14:30:00'));
148+
149+
return <DsDatePicker value={value} onChange={setValue} withTime disablePortal />;
150+
}
151+
152+
await page.render(<Wrapper />);
153+
154+
await page.getByRole('button', { name: /open calendar/i }).click();
155+
156+
const timeInput = page.getByRole('textbox', { name: 'hh:mm AM/PM', exact: true });
157+
await timeInput.hover();
158+
159+
await expect.element(page.getByRole('button', { name: /clear time/i })).not.toBeInTheDocument();
160+
161+
await userEvent.keyboard('{Escape}');
162+
});
163+
120164
it('should reset input to last valid value on blur after appending invalid characters', async () => {
121165
function Wrapper() {
122166
const [value, setValue] = useState<Date | null>(new Date('2026-01-15T00:00:00'));

packages/design-system/src/components/ds-date-picker/ds-date-picker.module.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
.control {
44
display: flex;
5+
6+
&:not([data-disabled]):where(:hover, :focus-within) {
7+
.clearTrigger {
8+
visibility: visible;
9+
}
10+
}
11+
}
12+
13+
.clearTrigger {
14+
visibility: hidden;
515
}
616

717
.positioner {

packages/design-system/src/components/ds-date-picker/ds-date-picker.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ const DsDatePicker = ({
145145
{showClearButton && (
146146
<DatePicker.ClearTrigger asChild onClick={() => setValue(null)}>
147147
<DsButton
148+
className={styles.clearTrigger}
148149
design="v1.2"
149150
size="tiny"
150151
buttonType="tertiary"
@@ -184,6 +185,7 @@ const DsDatePicker = ({
184185
}}
185186
min={isSameDay(value, min) ? _min : undefined}
186187
max={isSameDay(value, max) ? _max : undefined}
188+
hideClearButton={true}
187189
disabled={disabled || !value}
188190
readOnly={readOnly}
189191
{...slotProps?.timePicker}

packages/design-system/src/components/ds-time-picker/__tests__/ds-time-picker.browser.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,31 @@ describe('DsTimePicker', () => {
6060
await expect.element(input).toHaveAttribute('readonly');
6161
});
6262

63+
it('should show clear button on hover when value is selected', async () => {
64+
await page.render(
65+
<div>
66+
<button type="button">outside</button>
67+
<DsTimePicker value={createTime(14, 30)} />
68+
</div>,
69+
);
70+
71+
await page.getByRole('textbox').hover();
72+
await expect.element(page.getByRole('button', { name: /clear time/i })).toBeVisible();
73+
74+
await page.getByRole('button', { name: 'outside' }).hover();
75+
await expect.element(page.getByRole('button', { name: /clear time/i })).not.toBeInTheDocument();
76+
});
77+
78+
it('should not render clear button when hideClearButton is true', async () => {
79+
await page.render(<DsTimePicker value={createTime(14, 30)} hideClearButton />);
80+
81+
const input = page.getByRole('textbox');
82+
83+
await input.hover();
84+
85+
await expect.element(page.getByRole('button', { name: /clear time/i })).not.toBeInTheDocument();
86+
});
87+
6388
it('should enforce min/max constraints on time selection', async () => {
6489
function Wrapper() {
6590
const [value, setValue] = useState<Date | null>(createTime(13, 50));
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.root {
2+
&:where(:hover, :focus-within) {
3+
.clearTrigger {
4+
visibility: visible;
5+
}
6+
}
7+
}
8+
9+
.clearTrigger {
10+
visibility: hidden;
11+
}

packages/design-system/src/components/ds-time-picker/ds-time-picker.tsx

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import { type ChangeEvent, Fragment, useEffect, useRef, useState } from 'react';
22
import { Popover } from '@ark-ui/react/popover';
33
import { Portal } from '@ark-ui/react/portal';
4+
import classNames from 'classnames';
45
import type { DsTimePickerProps } from './ds-time-picker.types';
56
import { clampTime, formatTime, parseTime, timeScrollerAdapter } from './ds-time-picker.utils';
67
import { TimeScroller } from './components/time-scroller/time-scroller';
78
import { DsIcon } from '../ds-icon';
89
import { DsButton } from '../ds-button';
910
import { DsTextInput } from '../ds-text-input';
1011
import { useControlled } from '../../utils/use-controlled';
12+
import styles from './ds-time-picker.module.scss';
1113

1214
const DsTimePicker = (props: DsTimePickerProps) => {
1315
const {
@@ -19,6 +21,7 @@ const DsTimePicker = (props: DsTimePickerProps) => {
1921
max,
2022
disabled,
2123
readOnly,
24+
hideClearButton = false,
2225
disablePortal = false,
2326
locale,
2427
slotProps,
@@ -103,6 +106,8 @@ const DsTimePicker = (props: DsTimePickerProps) => {
103106
setIsOpen(details.open);
104107
};
105108

109+
const showClearButton = !hideClearButton && !disabled && !readOnly && !!value;
110+
106111
const Wrapper = disablePortal ? Fragment : Portal;
107112

108113
return (
@@ -112,7 +117,7 @@ const DsTimePicker = (props: DsTimePickerProps) => {
112117
positioning={{ placement: 'bottom-start', gutter: 4 }}
113118
>
114119
<Popover.Anchor asChild>
115-
<div ref={ref} className={className}>
120+
<div ref={ref} className={classNames(styles.root, className)}>
116121
<DsTextInput
117122
ref={inputRef}
118123
id={id}
@@ -126,17 +131,32 @@ const DsTimePicker = (props: DsTimePickerProps) => {
126131
{...slotProps?.input}
127132
slots={{
128133
endAdornment: slotProps?.input?.slots?.endAdornment ?? (
129-
<Popover.Trigger asChild>
130-
<DsButton
131-
design="v1.2"
132-
size="tiny"
133-
buttonType="tertiary"
134-
disabled={disabled || readOnly}
135-
aria-label={locale?.openLabel ?? 'Open time picker'}
136-
>
137-
<DsIcon icon="schedule" variant="outlined" size="tiny" />
138-
</DsButton>
139-
</Popover.Trigger>
134+
<>
135+
{showClearButton && (
136+
<DsButton
137+
className={styles.clearTrigger}
138+
design="v1.2"
139+
size="tiny"
140+
buttonType="tertiary"
141+
disabled={disabled}
142+
aria-label={locale?.clearLabel ?? 'Clear time'}
143+
onClick={() => setValue(null)}
144+
>
145+
<DsIcon icon="close" size="tiny" />
146+
</DsButton>
147+
)}
148+
<Popover.Trigger asChild>
149+
<DsButton
150+
design="v1.2"
151+
size="tiny"
152+
buttonType="tertiary"
153+
disabled={disabled || readOnly}
154+
aria-label={locale?.openLabel ?? 'Open time picker'}
155+
>
156+
<DsIcon icon="schedule" variant="outlined" size="tiny" />
157+
</DsButton>
158+
</Popover.Trigger>
159+
</>
140160
),
141161
...slotProps?.input?.slots,
142162
}}

packages/design-system/src/components/ds-time-picker/ds-time-picker.types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export interface DsTimePickerProps {
2121
disabled?: boolean;
2222
readOnly?: boolean;
2323

24+
/**
25+
* Whether to hide the clear button
26+
* @default false
27+
*/
28+
hideClearButton?: boolean;
29+
2430
/**
2531
* Whether to disable the portal for the popover content
2632
* @default false

0 commit comments

Comments
 (0)