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
20 changes: 20 additions & 0 deletions apps/www/src/content/docs/components/calendar/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,26 @@ export const datePickerDemo = {
name: 'Without Calendar Icon',
code: `<DatePicker showCalendarIcon={false} slotProps={{ input: { size: "medium" } }} />`
},
{
name: 'With Field',
code: `
function DatePickerFieldExample() {
const [error, setError] = React.useState();
return (
<Field
label="Start date"
description="Type a date or pick one from the calendar"
error={error}
style={{ maxWidth: 240 }}
>
<DatePicker
onErrorChange={setError}
slotProps={{ input: { size: "medium" } }}
/>
</Field>
);
Comment thread
rohanchkrabrty marked this conversation as resolved.
}`
},
{
name: 'Custom Trigger',
code: `
Expand Down
1 change: 0 additions & 1 deletion apps/www/src/content/docs/components/calendar/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
| `calendar-day-tooltip` | The tooltip shown on hover (when `showTooltip` and a message exists) |
| `date-picker-trigger` | The `<div>` wrapping the DatePicker trigger |
| `date-picker-input` | The DatePicker's text `<input>` |
| `date-picker-error` | The error message (always mounted, toggled via `data-visible`) |
| `date-picker-positioner` | The popover positioner for the DatePicker |
| `date-picker-content` | The DatePicker popover content that holds the calendar |
| `range-picker-trigger` | The `<div>` wrapping the RangePicker trigger |
Expand Down
10 changes: 10 additions & 0 deletions apps/www/src/content/docs/components/calendar/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,16 @@ export interface DatePickerProps {
/** Callback function when date is selected */
onSelect?: (date: Date) => void;

/**
* Fires when the typed-input validation state changes: with a message when
* the typed text stops parsing as a valid in-bounds date, and with
* `undefined` when it becomes valid again (or the picker commits/closes).
* DatePicker renders no error UI of its own — not even `aria-invalid`.
* Lift this into `Field`'s `error` prop (or your form library) to display
* it; `Field` also wires `aria-invalid` onto the input.
*/
onErrorChange?: (error: string | undefined) => void;

/** @deprecated Use `slotProps.calendar` instead. */
calendarProps?: CalendarProps;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,11 +76,6 @@ describe('DatePicker data-slot contract', () => {
it('exposes the trigger and input slots when closed', () => {
const { container } = render(<DatePicker />);
expectSlots(container, ['date-picker-trigger', 'date-picker-input']);
// The error span always mounts (toggled via `data-visible`, not conditional
// rendering) so a screen reader announces it the instant an error appears.
expect(getSlot(container, 'date-picker-error')).not.toHaveAttribute(
'data-visible'
);
});

it('exposes the content slots and nested calendar slots when open', () => {
Expand All @@ -92,13 +87,6 @@ describe('DatePicker data-slot contract', () => {
'calendar'
]);
});

it('exposes the error slot when the typed date is invalid', () => {
const { container } = render(<DatePicker />);
const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: 'not a date' } });
expect(getSlot(container, 'date-picker-error')).not.toBeNull();
});
});

describe('RangePicker data-slot contract', () => {
Expand Down
126 changes: 116 additions & 10 deletions packages/raystack/components/calendar/__tests__/date-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,59 +300,68 @@ describe('DatePicker', () => {
* are now allowed; bounds come from `startMonth` / `endMonth`.
*/
it('accepts a future date when no calendarProps bounds are set', () => {
render(<DatePicker dateFormat='DD/MM/YYYY' />);
const onErrorChange = vi.fn();
render(
<DatePicker dateFormat='DD/MM/YYYY' onErrorChange={onErrorChange} />
);

const input = screen.getByPlaceholderText(
'Select date'
) as HTMLInputElement;
// Pick a date far in the future to avoid clock skew in tests.
fireEvent.change(input, { target: { value: '15/06/2099' } });
expect(input.getAttribute('aria-invalid')).not.toBe('true');
expect(onErrorChange).not.toHaveBeenCalled();
});

it('accepts a future date within endMonth', () => {
const onErrorChange = vi.fn();
render(
<DatePicker
dateFormat='DD/MM/YYYY'
calendarProps={{ endMonth: new Date(2099, 11, 31) }}
onErrorChange={onErrorChange}
/>
);

const input = screen.getByPlaceholderText(
'Select date'
) as HTMLInputElement;
fireEvent.change(input, { target: { value: '15/06/2099' } });
expect(input.getAttribute('aria-invalid')).not.toBe('true');
expect(onErrorChange).not.toHaveBeenCalled();
});

it('rejects a date past endMonth', () => {
const onErrorChange = vi.fn();
render(
<DatePicker
dateFormat='DD/MM/YYYY'
calendarProps={{ endMonth: new Date(2026, 11, 31) }}
onErrorChange={onErrorChange}
/>
);

const input = screen.getByPlaceholderText(
'Select date'
) as HTMLInputElement;
fireEvent.change(input, { target: { value: '15/06/2099' } });
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(onErrorChange).toHaveBeenLastCalledWith('Invalid date');
});

it('rejects a date before startMonth', () => {
const onErrorChange = vi.fn();
render(
<DatePicker
dateFormat='DD/MM/YYYY'
calendarProps={{ startMonth: new Date(2026, 0, 1) }}
onErrorChange={onErrorChange}
/>
);

const input = screen.getByPlaceholderText(
'Select date'
) as HTMLInputElement;
fireEvent.change(input, { target: { value: '15/06/2020' } });
expect(input.getAttribute('aria-invalid')).toBe('true');
expect(onErrorChange).toHaveBeenLastCalledWith('Invalid date');
});
});

Expand Down Expand Up @@ -405,15 +414,18 @@ describe('DatePicker', () => {
});

it('accepts a fully-typed valid date matching dateFormat', () => {
render(<DatePicker dateFormat='DD/MM/YYYY' />);
const onErrorChange = vi.fn();
render(
<DatePicker dateFormat='DD/MM/YYYY' onErrorChange={onErrorChange} />
);

const input = screen.getByPlaceholderText(
'Select date'
) as HTMLInputElement;

fireEvent.change(input, { target: { value: '15/06/2025' } });
// The change handler accepted it — error attr stays unset.
expect(input.getAttribute('aria-invalid')).not.toBe('true');
// The change handler accepted it — no error transition fired.
expect(onErrorChange).not.toHaveBeenCalled();
});

/*
Expand Down Expand Up @@ -456,7 +468,14 @@ describe('DatePicker', () => {

it('does not fire onSelect while typing — partial stays uncommitted, valid waits for commit (Enter/blur/outside-click)', () => {
const onSelect = vi.fn();
render(<DatePicker dateFormat='DD/MM/YYYY' onSelect={onSelect} />);
const onErrorChange = vi.fn();
render(
<DatePicker
dateFormat='DD/MM/YYYY'
onSelect={onSelect}
onErrorChange={onErrorChange}
/>
);

const input = screen.getByPlaceholderText(
'Select date'
Expand All @@ -470,11 +489,98 @@ describe('DatePicker', () => {
// commit only happens via Enter / blur / outside-click (see the
// dedicated single-fire test below for that path).
fireEvent.change(input, { target: { value: '15/06/2025' } });
expect(input.getAttribute('aria-invalid')).not.toBe('true');
expect(onErrorChange).toHaveBeenLastCalledWith(undefined);
expect(onSelect).not.toHaveBeenCalled();
});
});

describe('onErrorChange', () => {
/*
* DatePicker renders no error message itself — consumers lift validity
* into `Field`'s `error` prop (or a form library) via this callback.
* It fires on transitions only, like `onOpenChange`.
*/
it('fires with a message when typed text stops parsing as a date', () => {
const onErrorChange = vi.fn();
render(
<DatePicker dateFormat='DD/MM/YYYY' onErrorChange={onErrorChange} />
);

const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: 'not a date' } });

expect(onErrorChange).toHaveBeenCalledTimes(1);
expect(onErrorChange).toHaveBeenCalledWith('Invalid date');
});

it('does not re-fire on consecutive invalid keystrokes', () => {
const onErrorChange = vi.fn();
render(
<DatePicker dateFormat='DD/MM/YYYY' onErrorChange={onErrorChange} />
);

const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: '15/' } });
fireEvent.change(input, { target: { value: '15/0' } });
fireEvent.change(input, { target: { value: '15/06' } });

expect(onErrorChange).toHaveBeenCalledTimes(1);
});

it('fires with undefined when the typed text becomes valid again', () => {
const onErrorChange = vi.fn();
render(
<DatePicker dateFormat='DD/MM/YYYY' onErrorChange={onErrorChange} />
);

const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: '15/06' } });
fireEvent.change(input, { target: { value: '15/06/2025' } });

expect(onErrorChange).toHaveBeenCalledTimes(2);
expect(onErrorChange).toHaveBeenLastCalledWith(undefined);
});

it('never fires while typing stays valid', () => {
const onErrorChange = vi.fn();
render(
<DatePicker dateFormat='DD/MM/YYYY' onErrorChange={onErrorChange} />
);

const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: '15/06/2025' } });

expect(onErrorChange).not.toHaveBeenCalled();
});

it('fires with undefined when the picker commits via Enter with invalid text', () => {
const onErrorChange = vi.fn();
render(
<DatePicker dateFormat='DD/MM/YYYY' onErrorChange={onErrorChange} />
);

const input = screen.getByPlaceholderText('Select date');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: 'garbage' } });
fireEvent.keyUp(input, { code: 'Enter' });

expect(onErrorChange).toHaveBeenLastCalledWith(undefined);
});

it('renders no error presentation of its own — no message, no aria-invalid', () => {
render(<DatePicker dateFormat='DD/MM/YYYY' />);

const input = screen.getByPlaceholderText('Select date');
fireEvent.change(input, { target: { value: 'not a date' } });

// Presentation is fully consumer-owned: `onErrorChange` lifted into
// `Field`'s `error` prop renders the message and wires aria-invalid.
expect(input.getAttribute('aria-invalid')).toBeNull();
expect(screen.queryByRole('alert')).toBeNull();
expect(screen.queryByText('Invalid date')).toBeNull();
});
});

describe('disabled state', () => {
/*
* The trailing CalendarIcon renders as a sibling `<div>` to the actual
Expand Down
24 changes: 0 additions & 24 deletions packages/raystack/components/calendar/calendar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -343,27 +343,3 @@
.datePickerInput {
text-align: left;
}

.datePickerError {
Comment thread
rohilsurana marked this conversation as resolved.
display: block;
/* Reserved line: the space exists whether or not an error is showing,
so appearing/disappearing errors never shift the layout below. */
min-height: var(--rs-line-height-mini);
margin-top: var(--rs-space-1);
color: var(--rs-color-foreground-danger-primary);
font-weight: var(--rs-font-weight-regular);
font-size: var(--rs-font-size-mini);
line-height: var(--rs-line-height-mini);
letter-spacing: var(--rs-letter-spacing-mini);
opacity: 0;
}

.datePickerError[data-visible] {
opacity: 1;
}

@media (prefers-reduced-motion: no-preference) {
.datePickerError {
transition: opacity var(--rs-duration-fast) var(--rs-ease-out);
}
}
Loading
Loading