Skip to content

Commit 7ed1c0b

Browse files
committed
Add more unit tests and improve validation hint
1 parent 6988ea3 commit 7ed1c0b

File tree

4 files changed

+264
-93
lines changed

4 files changed

+264
-93
lines changed

packages/circuit-ui/components/DateInput/DateInput.module.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@
9696
box-shadow: inset 0 0 0 1px var(--cui-border-focus);
9797
}
9898

99+
.calendar-button:active,
100+
.calendar-button[aria-expanded="true"] {
101+
z-index: calc(var(--cui-z-index-absolute) + 1);
102+
}
103+
99104
.content {
100105
color: var(--cui-fg-normal);
101106
background-color: var(--cui-bg-elevated);

packages/circuit-ui/components/DateInput/DateInput.spec.tsx

Lines changed: 220 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -24,28 +24,28 @@ import { DateInput } from './DateInput.js';
2424
describe('DateInput', () => {
2525
const baseProps = {
2626
onChange: vi.fn(),
27-
label: 'Date',
28-
prevMonthButtonLabel: 'Previous month',
29-
nextMonthButtonLabel: 'Previous month',
30-
openCalendarButtonLabel: 'Change date',
31-
closeCalendarButtonLabel: 'Close',
32-
applyDateButtonLabel: 'Apply',
33-
clearDateButtonLabel: 'Clear',
27+
label: 'Date of birth',
3428
yearInputLabel: 'Year',
3529
monthInputLabel: 'Month',
3630
dayInputLabel: 'Day',
31+
openCalendarButtonLabel: 'Change date',
32+
closeCalendarButtonLabel: 'Close calendar',
33+
prevMonthButtonLabel: 'Previous month',
34+
nextMonthButtonLabel: 'Previous month',
35+
applyDateButtonLabel: 'Apply date',
36+
clearDateButtonLabel: 'Clear date',
3737
};
3838

3939
beforeAll(() => {
4040
MockDate.set('2000-01-01');
4141
});
4242

43-
// TODO: Move ref to outermost div?
4443
it('should forward a ref', () => {
45-
const ref = createRef<HTMLFieldSetElement>();
46-
render(<DateInput {...baseProps} ref={ref} />);
47-
const fieldset = screen.getByRole('group');
48-
expect(ref.current).toBe(fieldset);
44+
const ref = createRef<HTMLDivElement>();
45+
const { container } = render(<DateInput {...baseProps} ref={ref} />);
46+
// eslint-disable-next-line testing-library/no-container
47+
const wrapper = container.querySelectorAll('div')[0];
48+
expect(ref.current).toBe(wrapper);
4949
});
5050

5151
it('should merge a custom class name with the default ones', () => {
@@ -58,93 +58,239 @@ describe('DateInput', () => {
5858
expect(wrapper?.className).toContain(className);
5959
});
6060

61-
it('should select a calendar date', async () => {
62-
const onChange = vi.fn();
61+
describe('semantics', () => {
62+
it('should optionally have an accessible description', () => {
63+
const description = 'Description';
64+
render(<DateInput {...baseProps} validationHint={description} />);
65+
const fieldset = screen.getByRole('group');
66+
const inputs = screen.getAllByRole('spinbutton');
67+
68+
expect(fieldset).toHaveAccessibleDescription(description);
69+
expect(inputs[0]).toHaveAccessibleDescription(description);
70+
expect(inputs[1]).not.toHaveAccessibleDescription();
71+
expect(inputs[2]).not.toHaveAccessibleDescription();
72+
});
6373

64-
render(<DateInput {...baseProps} onChange={onChange} />);
74+
it('should accept a custom description via aria-describedby', () => {
75+
const customDescription = 'Custom description';
76+
const customDescriptionId = 'customDescriptionId';
77+
render(
78+
<>
79+
<DateInput {...baseProps} aria-describedby={customDescriptionId} />,
80+
<span id={customDescriptionId}>{customDescription}</span>
81+
</>,
82+
);
83+
const fieldset = screen.getByRole('group');
84+
const inputs = screen.getAllByRole('spinbutton');
6585

66-
const openCalendarButton = screen.getByRole('button', {
67-
name: /change date/i,
86+
expect(fieldset).toHaveAccessibleDescription(customDescription);
87+
expect(inputs[0]).toHaveAccessibleDescription(customDescription);
88+
expect(inputs[1]).not.toHaveAccessibleDescription();
89+
expect(inputs[2]).not.toHaveAccessibleDescription();
6890
});
6991

70-
await userEvent.click(openCalendarButton);
92+
it('should accept a custom description in addition to a validationHint', () => {
93+
const customDescription = 'Custom description';
94+
const customDescriptionId = 'customDescriptionId';
95+
const description = 'Description';
96+
render(
97+
<>
98+
<DateInput
99+
{...baseProps}
100+
validationHint={description}
101+
aria-describedby={customDescriptionId}
102+
/>
103+
<span id={customDescriptionId}>{customDescription}</span>,
104+
</>,
105+
);
106+
const fieldset = screen.getByRole('group');
107+
const inputs = screen.getAllByRole('spinbutton');
71108

72-
const calendarDialog = screen.getByRole('dialog');
109+
expect(fieldset).toHaveAccessibleDescription(
110+
`${customDescription} ${description}`,
111+
);
112+
expect(inputs[0]).toHaveAccessibleDescription(
113+
`${customDescription} ${description}`,
114+
);
115+
expect(inputs[1]).not.toHaveAccessibleDescription();
116+
expect(inputs[2]).not.toHaveAccessibleDescription();
117+
});
73118

74-
expect(calendarDialog).toBeVisible();
119+
it('should render as disabled', async () => {
120+
render(<DateInput {...baseProps} disabled />);
121+
expect(screen.getByLabelText(/day/i)).toBeDisabled();
122+
expect(screen.getByLabelText(/month/i)).toBeDisabled();
123+
expect(screen.getByLabelText(/year/i)).toBeDisabled();
124+
expect(
125+
screen.getByRole('button', { name: baseProps.openCalendarButtonLabel }),
126+
).toHaveAttribute('aria-disabled', 'true');
127+
});
75128

76-
const dateButton = screen.getByRole('button', { name: /12/ });
129+
it('should render as read-only', async () => {
130+
render(<DateInput {...baseProps} readOnly />);
131+
expect(screen.getByLabelText(/day/i)).toHaveAttribute('readonly');
132+
expect(screen.getByLabelText(/month/i)).toHaveAttribute('readonly');
133+
expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly');
134+
expect(
135+
screen.getByRole('button', { name: baseProps.openCalendarButtonLabel }),
136+
).toHaveAttribute('aria-disabled', 'true');
137+
});
77138

78-
await userEvent.click(dateButton);
139+
it('should render as invalid', async () => {
140+
render(<DateInput {...baseProps} invalid />);
141+
expect(screen.getByLabelText(/day/i)).toBeInvalid();
142+
expect(screen.getByLabelText(/month/i)).toBeInvalid();
143+
expect(screen.getByLabelText(/year/i)).toBeInvalid();
144+
});
79145

80-
expect(onChange).toHaveBeenCalledWith('2000-01-12');
81-
});
146+
it('should render as required', async () => {
147+
render(<DateInput {...baseProps} required />);
148+
expect(screen.getByLabelText(/day/i)).toBeRequired();
149+
expect(screen.getByLabelText(/month/i)).toBeRequired();
150+
expect(screen.getByLabelText(/year/i)).toBeRequired();
151+
});
82152

83-
it('should display the initial value correctly', () => {
84-
render(<DateInput {...baseProps} value="2000-01-12" />);
153+
it('should have relevant minimum input values', () => {
154+
render(<DateInput {...baseProps} min="2000-01-01" />);
155+
expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '1');
156+
expect(screen.getByLabelText(/month/i)).toHaveAttribute('min', '1');
157+
expect(screen.getByLabelText(/year/i)).toHaveAttribute('min', '2000');
158+
});
85159

86-
expect(screen.getByLabelText(/day/i)).toHaveValue(12);
87-
expect(screen.getByLabelText(/month/i)).toHaveValue(1);
88-
expect(screen.getByLabelText(/year/i)).toHaveValue(2000);
89-
});
160+
it('should have relevant maximum input values', () => {
161+
render(<DateInput {...baseProps} max="2001-01-01" />);
162+
expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '31');
163+
expect(screen.getByLabelText(/month/i)).toHaveAttribute('max', '12');
164+
expect(screen.getByLabelText(/year/i)).toHaveAttribute('max', '2001');
165+
});
90166

91-
it('should render a disabled input', () => {
92-
render(<DateInput {...baseProps} disabled />);
93-
expect(screen.getByLabelText(/day/i)).toBeDisabled();
94-
expect(screen.getByLabelText(/month/i)).toBeDisabled();
95-
expect(screen.getByLabelText(/year/i)).toBeDisabled();
96-
expect(
97-
screen.getByRole('button', { name: baseProps.openCalendarButtonLabel }),
98-
).toHaveAttribute('aria-disabled', 'true');
99-
});
167+
it('should mark the year input as readonly when the minimum and maximum dates have the same year', () => {
168+
render(<DateInput {...baseProps} min="2000-04-29" max="2000-06-15" />);
169+
expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly');
170+
expect(screen.getByLabelText(/month/i)).toHaveAttribute('min', '4');
171+
expect(screen.getByLabelText(/month/i)).toHaveAttribute('max', '6');
172+
});
100173

101-
it('should handle min/max dates', () => {
102-
render(<DateInput {...baseProps} min="2000-01-01" max="2001-01-01" />);
103-
expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '1');
104-
expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '31');
105-
expect(screen.getByLabelText(/month/i)).toHaveAttribute('min', '1');
106-
expect(screen.getByLabelText(/month/i)).toHaveAttribute('max', '12');
107-
expect(screen.getByLabelText(/year/i)).toHaveAttribute('min', '2000');
108-
expect(screen.getByLabelText(/year/i)).toHaveAttribute('max', '2001');
174+
it('should mark the year and month inputs as readonly when the minimum and maximum dates have the same year and month', () => {
175+
render(<DateInput {...baseProps} min="2000-04-09" max="2000-04-27" />);
176+
expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly');
177+
expect(screen.getByLabelText(/month/i)).toHaveAttribute('readonly');
178+
expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '9');
179+
expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '27');
180+
});
109181
});
110182

111-
it('should handle min/max dates as the user types year', async () => {
112-
render(<DateInput {...baseProps} min="2000-04-29" max="2001-02-15" />);
183+
describe('state', () => {
184+
it('should display a default value', () => {
185+
render(<DateInput {...baseProps} defaultValue="2000-01-12" />);
113186

114-
await userEvent.type(screen.getByLabelText(/year/i), '2001');
115-
/* expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '1');
116-
expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '1'); */
117-
expect(screen.getByLabelText(/month/i)).toHaveAttribute('min', '1');
118-
expect(screen.getByLabelText(/month/i)).toHaveAttribute('max', '2');
119-
});
187+
expect(screen.getByLabelText(/day/i)).toHaveValue(12);
188+
expect(screen.getByLabelText(/month/i)).toHaveValue(1);
189+
expect(screen.getByLabelText(/year/i)).toHaveValue(2000);
190+
});
120191

121-
it('should handle min/max dates as the user types year and month', async () => {
122-
render(<DateInput {...baseProps} min="2000-04-29" max="2001-02-15" />);
192+
it('should display an initial value', () => {
193+
render(<DateInput {...baseProps} value="2000-01-12" />);
123194

124-
await userEvent.type(screen.getByLabelText(/year/i), '2001');
125-
await userEvent.type(screen.getByLabelText(/month/i), '02');
195+
expect(screen.getByLabelText(/day/i)).toHaveValue(12);
196+
expect(screen.getByLabelText(/month/i)).toHaveValue(1);
197+
expect(screen.getByLabelText(/year/i)).toHaveValue(2000);
198+
});
126199

127-
expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '1');
128-
expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '15');
129-
});
200+
it('should update the displayed value', () => {
201+
const { rerender } = render(
202+
<DateInput {...baseProps} value="2000-01-12" />,
203+
);
130204

131-
it('years field should be readonly if min/max dates have the same year', () => {
132-
render(<DateInput {...baseProps} min="2000-04-29" max="2000-06-15" />);
133-
expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly');
134-
expect(screen.getByLabelText(/month/i)).toHaveAttribute('min', '4');
135-
expect(screen.getByLabelText(/month/i)).toHaveAttribute('max', '6');
205+
rerender(<DateInput {...baseProps} value="2000-01-15" />);
206+
207+
expect(screen.getByLabelText(/day/i)).toHaveValue(15);
208+
expect(screen.getByLabelText(/month/i)).toHaveValue(1);
209+
expect(screen.getByLabelText(/year/i)).toHaveValue(2000);
210+
});
136211
});
137212

138-
it('years and months fields should render as readonly if min/max dates have the same year and same month', () => {
139-
render(<DateInput {...baseProps} min="2000-04-09" max="2000-04-27" />);
140-
expect(screen.getByLabelText(/year/i)).toHaveAttribute('readonly');
141-
expect(screen.getByLabelText(/month/i)).toHaveAttribute('readonly');
213+
describe('user interactions', () => {
214+
it('should focus the first input when clicking the label', async () => {
215+
render(<DateInput {...baseProps} />);
216+
217+
await userEvent.click(screen.getByText('Date of birth'));
218+
219+
expect(screen.getAllByRole('spinbutton')[0]).toHaveFocus();
220+
});
221+
222+
it('should allow users to type a date', async () => {
223+
const onChange = vi.fn();
224+
225+
render(<DateInput {...baseProps} onChange={onChange} />);
226+
227+
await userEvent.type(screen.getByLabelText('Year'), '2017');
228+
await userEvent.type(screen.getByLabelText('Month'), '8');
229+
await userEvent.type(screen.getByLabelText('Day'), '28');
230+
231+
expect(onChange).toHaveBeenCalledWith('2017-08-28');
232+
});
233+
234+
it('should update the minimum and maximum input values as the user types', async () => {
235+
render(<DateInput {...baseProps} min="2000-04-29" max="2001-02-15" />);
236+
237+
await userEvent.type(screen.getByLabelText(/year/i), '2001');
238+
239+
expect(screen.getByLabelText(/month/i)).toHaveAttribute('min', '1');
240+
expect(screen.getByLabelText(/month/i)).toHaveAttribute('max', '2');
241+
242+
await userEvent.type(screen.getByLabelText(/month/i), '2');
243+
244+
expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '1');
245+
expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '15');
246+
});
247+
248+
it('should allow users to select a date on a calendar', async () => {
249+
const onChange = vi.fn();
250+
251+
render(<DateInput {...baseProps} onChange={onChange} />);
252+
253+
const openCalendarButton = screen.getByRole('button', {
254+
name: /change date/i,
255+
});
256+
await userEvent.click(openCalendarButton);
257+
258+
const calendarDialog = screen.getByRole('dialog');
259+
expect(calendarDialog).toBeVisible();
260+
261+
const dateButton = screen.getByRole('button', { name: /12/ });
262+
await userEvent.click(dateButton);
263+
264+
expect(onChange).toHaveBeenCalledWith('2000-01-12');
265+
});
142266

143-
expect(screen.getByLabelText(/day/i)).toHaveAttribute('min', '9');
144-
expect(screen.getByLabelText(/day/i)).toHaveAttribute('max', '27');
267+
it('should allow users to clear the date', async () => {
268+
const onChange = vi.fn();
269+
270+
render(
271+
<DateInput
272+
{...baseProps}
273+
defaultValue="2000-01-12"
274+
onChange={onChange}
275+
/>,
276+
);
277+
278+
const openCalendarButton = screen.getByRole('button', {
279+
name: /change date/i,
280+
});
281+
await userEvent.click(openCalendarButton);
282+
283+
const calendarDialog = screen.getByRole('dialog');
284+
expect(calendarDialog).toBeVisible();
285+
286+
const clearButton = screen.getByRole('button', { name: /clear date/i });
287+
await userEvent.click(clearButton);
288+
289+
expect(onChange).toHaveBeenCalledWith('');
290+
});
145291
});
146292

147-
describe('Status messages', () => {
293+
describe('status messages', () => {
148294
it('should render an empty live region on mount', () => {
149295
render(<DateInput {...baseProps} />);
150296
const liveRegionEl = screen.getByRole('status');

0 commit comments

Comments
 (0)