Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export const DatePicker: Story = {

// Ensure clicking the icon opens the calendar
expect(canvas.queryByRole('dialog')).toBeNull();
await userEvent.click(canvas.getByLabelText('Toon/verberg de kalender'));
const trigger = canvas.getByRole('button', {name: 'Toon/verberg de kalender'});
expect(trigger).toBeVisible();
await userEvent.click(trigger);
expect(canvas.getByRole('dialog')).toBeVisible();
},
};
Expand Down Expand Up @@ -105,9 +107,9 @@ export const DatePickerLimitedRange: Story = {
play: async ({canvasElement}) => {
const canvas = within(canvasElement);

// Calendar is by default not visible, until you focus the field
// Calendar is by default not visible, until you click the trigger
expect(canvas.queryByRole('dialog')).toBeNull();
await userEvent.click(canvas.getByText('Date'));
await userEvent.click(canvas.getByRole('button', {name: 'Toon/verberg de kalender'}));
expect(await canvas.findByRole('dialog')).toBeVisible();
},
};
Expand All @@ -134,9 +136,9 @@ export const DatePickerDisabledDates: Story = {
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement);
// calendar is by default not visible, until you focus the field
// calendar is by default not visible, until you click the trigger
expect(canvas.queryByRole('dialog')).toBeNull();
await userEvent.click(canvas.getByText('Today disabled'));
await userEvent.click(canvas.getByRole('button', {name: 'Toon/verberg de kalender'}));

expect(await canvas.findByRole('dialog')).toBeVisible();

Expand All @@ -159,9 +161,15 @@ export const DatePickerKeyboardNavigation: Story = {
play: async ({canvasElement}) => {
const canvas = within(canvasElement);

// Calendar is by default not visible, until you focus the field
// Calendar is by default not visible, until you click the trigger
expect(canvas.queryByRole('dialog')).toBeNull();
await userEvent.click(canvas.getByText('Date'));
const trigger = canvas.getByRole('button', {name: 'Toon/verberg de kalender'});
expect(trigger).toBeVisible();

await userEvent.keyboard('{Tab}');
expect(trigger).toHaveFocus();
await userEvent.keyboard('{Enter}');
expect(await canvas.findByRole('dialog')).toBeVisible();

// Ensure ESC key closes the dialog again
Expand Down Expand Up @@ -204,12 +212,12 @@ export const DatePickerTypeDateManually: Story = {
// Ensure formatting is applied on blur
date.blur();
await waitFor(() => {
expect(canvas.queryByRole('dialog')).toBeNull();
expect(date).toHaveDisplayValue('29-8-2025');
});
expect(date).toHaveDisplayValue('29-8-2025');

// Ensure that the date is properly highlighted in the calendar
await userEvent.click(date);
const trigger = canvas.getByRole('button', {name: 'Toon/verberg de kalender'});
await userEvent.click(trigger);
expect(await canvas.findByRole('dialog')).toBeVisible();
const selectedEventButton = await canvas.findByRole('button', {
name: 'vrijdag 29 augustus 2025',
Expand Down Expand Up @@ -295,7 +303,8 @@ export const NoErrorWhileFocus: Story = {

// open the date picker and shift focus to it
const input = canvas.getByLabelText('No error displayed while picker is open');
await userEvent.click(input);
const trigger = canvas.getByRole('button', {name: 'Toon/verberg de kalender'});
await userEvent.click(trigger);

const dialog = await canvas.findByRole('dialog');
expect(dialog).toBeVisible();
Expand Down
141 changes: 56 additions & 85 deletions src/components/forms/DateField/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,17 @@ import {useId} from 'react';
import {flushSync} from 'react-dom';
import {useIntl} from 'react-intl';

import DatePickerCalendar from '@/components/forms/DatePickerCalendar';
import {FloatingWidget, useFloatingWidget} from '@/components/forms/FloatingWidget';
import {DatePicker, DatePickerRoot, DatePickerTrigger} from '@/components/forms/DatePicker';
import type DatePickerCalendar from '@/components/forms/DatePickerCalendar';
import Label from '@/components/forms/Label';
import Tooltip from '@/components/forms/Tooltip';
import Icon from '@/components/icons';

import {useDateLocaleMeta} from '../hooks';
import {PART_PLACEHOLDERS} from '../messages';
import {parseDate} from '../utils';
import './DatePicker.scss';

interface DatePickerProps {
interface DatePickerFieldProps {
/**
* The name of the form field/input, used to set/track the field value in the form state.
*/
Expand Down Expand Up @@ -65,7 +64,7 @@ interface DatePickerProps {
*
* TODO: on mobile devices, use the native date picker?
*/
const DatePicker: React.FC<DatePickerProps> = ({
const DatePickerField: React.FC<DatePickerFieldProps> = ({
name,
label,
tooltip,
Expand All @@ -81,16 +80,6 @@ const DatePicker: React.FC<DatePickerProps> = ({
const {validateField} = useFormikContext();
const [{value, onBlur, onChange}, {error, touched}, {setTouched, setValue}] =
useField<string>(name);
const {
refs,
floatingStyles,
context,
getFloatingProps,
getReferenceProps,
isOpen,
setIsOpen,
arrowRef,
} = useFloatingWidget();
const dateLocaleMeta = useDateLocaleMeta();

const placeholderMap = {
Expand Down Expand Up @@ -125,7 +114,6 @@ const DatePicker: React.FC<DatePickerProps> = ({
}))
: undefined;

const referenceProps = getReferenceProps();
return (
<>
<Label
Expand All @@ -136,76 +124,59 @@ const DatePicker: React.FC<DatePickerProps> = ({
>
{label}
</Label>
<Paragraph className="openforms-datepicker-textbox">
<Textbox
name={name}
value={textboxValue}
onChange={onChange}
onBlur={async event => {
const value = event.target.value;
// Attempt to create a date object using the locale meta
const date = parseDate(value, dateLocaleMeta);
// If we were able to create a date object, format it to an ISO-8601 string and set it
// as the field value. Otherwise, just set the entered value to the field directly.
// It's up to the validation libraries to check it.
const newValue = date ? formatISO(date, {representation: 'date'}) : value;
await setValue(newValue);
onBlur(event);
// only run validation while the picker is not opened
if (!isOpen) {
await validateField(name);
}
}}
className="utrecht-textbox--openforms"
id={id}
disabled={isDisabled}
invalid={touched && !!error}
aria-describedby={ariaDescribedBy}
placeholder={placeholder}
ref={refs.setReference}
autoComplete="off"
{...referenceProps}
/>
<Icon
icon="calendar"
className="openforms-datepicker-textbox__calendar-toggle"
aria-label={formatMessage({
description: 'Datepicker: accessible calendar toggle label',
defaultMessage: 'Toggle calendar',
})}
aria-hidden="false"
aria-controls={referenceProps['aria-controls']}
aria-expanded={referenceProps['aria-expanded']}
aria-haspopup={referenceProps['aria-haspopup']}
onClick={() => !isOpen && setIsOpen(true)}
/>
</Paragraph>
<FloatingWidget
isOpen={isOpen}
context={context}
setFloating={refs.setFloating}
floatingStyles={floatingStyles}
getFloatingProps={getFloatingProps}
arrowRef={arrowRef}
>
<DatePickerCalendar
onCalendarClick={async selectedDate => {
flushSync(() => {
// Need to truncate, because the selected date is in datetime format
const truncated = selectedDate.substring(0, 10);
setValue(truncated);
setIsOpen(false, {keepDismissed: true});
});
await setTouched(true);
}}
currentDate={currentDate ?? undefined}
minDate={minDate}
maxDate={maxDate}
events={calendarEvents}
/>
</FloatingWidget>

<DatePickerRoot onOpen={() => setTouched(true)}>
{({refs, setIsOpen}) => (
<>
<Paragraph className="openforms-datepicker-textbox">
<Textbox
ref={refs.setPositionReference}
name={name}
value={textboxValue}
onChange={onChange}
onBlur={async event => {
const value = event.target.value;
// Attempt to create a date object using the locale meta
const date = parseDate(value, dateLocaleMeta);
// If we were able to create a date object, format it to an ISO-8601 string and set it
// as the field value. Otherwise, just set the entered value to the field directly.
// It's up to the validation libraries to check it.
const newValue = date ? formatISO(date, {representation: 'date'}) : value;
await setValue(newValue);
onBlur(event);
await validateField(name);
}}
className="utrecht-textbox--openforms"
id={id}
disabled={isDisabled}
invalid={touched && !!error}
aria-describedby={ariaDescribedBy}
placeholder={placeholder}
autoComplete="off"
/>
<DatePickerTrigger className="openforms-datepicker-textbox__calendar-toggle" />
</Paragraph>
<DatePicker
onCalendarClick={async selectedDate => {
flushSync(() => {
// Need to truncate, because the selected date is in datetime format
const truncated = selectedDate.substring(0, 10);
setValue(truncated);
setIsOpen(false);
});
await setTouched(true);
await validateField(name);
}}
currentDate={currentDate ?? undefined}
minDate={minDate}
maxDate={maxDate}
events={calendarEvents}
/>
</>
)}
</DatePickerRoot>
</>
);
};

export default DatePicker;
export default DatePickerField;
99 changes: 99 additions & 0 deletions src/components/forms/DatePicker.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type {Meta, StoryObj} from '@storybook/react-vite';
import {Paragraph} from '@utrecht/component-library-react';
import {expect, userEvent, waitFor, within} from 'storybook/test';

import {DatePicker, DatePickerRoot, DatePickerTrigger} from './DatePicker';

export default {
title: 'Internal API / Forms / DatePicker',
component: DatePicker,
decorators: [
Story => {
return (
<>
<DatePickerRoot>
{({refs}) => (
<div
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
columnGap: '1em',
}}
>
<p
ref={refs.setPositionReference}
style={{border: 'dashed 1px #ccc', padding: '1em'}}
>
Reference for the the dialog
</p>
<DatePickerTrigger />
<Story />
</div>
)}
</DatePickerRoot>
</>
);
},
],
args: {
children: null,
},
argTypes: {
children: {table: {disable: true}},
},
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof DatePicker>;

type Story = StoryObj<typeof DatePicker>;

const waitForFocus = async (element: Element) => {
await waitFor(() => {
expect(element).toBeVisible();
expect(element).toHaveFocus();
});
};

export const WithoutChildren: Story = {
play: async ({canvasElement, step}) => {
const canvas = within(canvasElement);
expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();

const trigger = canvas.getByRole('button', {name: 'Toggle calendar'});
expect(trigger).toBeVisible();

await step('clicking trigger opens dialog', async () => {
await userEvent.click(trigger);
const dialog = await canvas.findByRole('dialog');
await waitForFocus(dialog);
await userEvent.keyboard('{Escape}');
expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();
});

await step('keyboard navigation opens dialog', async () => {
trigger.focus();
await userEvent.keyboard('{Enter}');
const dialog = await canvas.findByRole('dialog');
await waitForFocus(dialog);
});
},
};

export const WithChildren: Story = {
args: {
children: (
<Paragraph style={{textAlign: 'center', padding: '12px'}}>An additional paragraph</Paragraph>
),
},
play: async ({canvasElement}) => {
const canvas = within(canvasElement);
expect(canvas.queryByRole('dialog')).not.toBeInTheDocument();

const trigger = canvas.getByRole('button', {name: 'Toggle calendar'});
expect(trigger).toBeVisible();
await userEvent.click(trigger);
},
};
Loading