diff --git a/apps/www/src/content/docs/components/calendar/demo.ts b/apps/www/src/content/docs/components/calendar/demo.ts
index 742eefc17..2487a9b4c 100644
--- a/apps/www/src/content/docs/components/calendar/demo.ts
+++ b/apps/www/src/content/docs/components/calendar/demo.ts
@@ -184,6 +184,26 @@ export const datePickerDemo = {
name: 'Without Calendar Icon',
code: ``
},
+ {
+ name: 'With Field',
+ code: `
+function DatePickerFieldExample() {
+ const [error, setError] = React.useState();
+ return (
+
+
+
+ );
+}`
+ },
{
name: 'Custom Trigger',
code: `
diff --git a/apps/www/src/content/docs/components/calendar/index.mdx b/apps/www/src/content/docs/components/calendar/index.mdx
index d7a708b24..a76621b2c 100644
--- a/apps/www/src/content/docs/components/calendar/index.mdx
+++ b/apps/www/src/content/docs/components/calendar/index.mdx
@@ -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 `
` wrapping the DatePicker trigger |
| `date-picker-input` | The DatePicker's text `` |
-| `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 `
` wrapping the RangePicker trigger |
diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts
index cebb5cc3d..04fe813e9 100644
--- a/apps/www/src/content/docs/components/calendar/props.ts
+++ b/apps/www/src/content/docs/components/calendar/props.ts
@@ -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;
diff --git a/packages/raystack/components/calendar/__tests__/data-slots.test.tsx b/packages/raystack/components/calendar/__tests__/data-slots.test.tsx
index 8517d9975..6842b71e5 100644
--- a/packages/raystack/components/calendar/__tests__/data-slots.test.tsx
+++ b/packages/raystack/components/calendar/__tests__/data-slots.test.tsx
@@ -76,11 +76,6 @@ describe('DatePicker data-slot contract', () => {
it('exposes the trigger and input slots when closed', () => {
const { container } = render();
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', () => {
@@ -92,13 +87,6 @@ describe('DatePicker data-slot contract', () => {
'calendar'
]);
});
-
- it('exposes the error slot when the typed date is invalid', () => {
- const { container } = render();
- 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', () => {
diff --git a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx
index 0bf7ff635..59c5ff9b5 100644
--- a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx
+++ b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx
@@ -300,21 +300,26 @@ describe('DatePicker', () => {
* are now allowed; bounds come from `startMonth` / `endMonth`.
*/
it('accepts a future date when no calendarProps bounds are set', () => {
- render();
+ const onErrorChange = vi.fn();
+ render(
+
+ );
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(
);
@@ -322,14 +327,16 @@ describe('DatePicker', () => {
'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(
);
@@ -337,14 +344,16 @@ describe('DatePicker', () => {
'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(
);
@@ -352,7 +361,7 @@ describe('DatePicker', () => {
'Select date'
) as HTMLInputElement;
fireEvent.change(input, { target: { value: '15/06/2020' } });
- expect(input.getAttribute('aria-invalid')).toBe('true');
+ expect(onErrorChange).toHaveBeenLastCalledWith('Invalid date');
});
});
@@ -405,15 +414,18 @@ describe('DatePicker', () => {
});
it('accepts a fully-typed valid date matching dateFormat', () => {
- render();
+ const onErrorChange = vi.fn();
+ render(
+
+ );
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();
});
/*
@@ -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();
+ const onErrorChange = vi.fn();
+ render(
+
+ );
const input = screen.getByPlaceholderText(
'Select date'
@@ -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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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();
+
+ 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 `
` to the actual
diff --git a/packages/raystack/components/calendar/calendar.module.css b/packages/raystack/components/calendar/calendar.module.css
index 378834433..ddcb9550c 100644
--- a/packages/raystack/components/calendar/calendar.module.css
+++ b/packages/raystack/components/calendar/calendar.module.css
@@ -343,27 +343,3 @@
.datePickerInput {
text-align: left;
}
-
-.datePickerError {
- 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);
- }
-}
diff --git a/packages/raystack/components/calendar/date-picker.tsx b/packages/raystack/components/calendar/date-picker.tsx
index 916e8c927..519689f9c 100644
--- a/packages/raystack/components/calendar/date-picker.tsx
+++ b/packages/raystack/components/calendar/date-picker.tsx
@@ -6,7 +6,7 @@ import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
-import { useEffect, useId, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { PropsBase } from 'react-day-picker';
import { Input } from '../input';
import { InputProps } from '../input/input';
@@ -47,6 +47,15 @@ export interface DatePickerProps {
/** @deprecated Use `slotProps.popover` instead. */
popoverProps?: PopoverContentProps;
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;
value?: Date;
defaultValue?: Date;
children?:
@@ -65,6 +74,7 @@ export function DatePicker({
value: valueProp,
defaultValue,
onSelect = () => undefined,
+ onErrorChange,
children,
showCalendarIcon = true,
timeZone
@@ -87,7 +97,13 @@ export function DatePicker({
const [selectedDate, setSelectedDate] = useState(
valueProp ?? defaultValue
);
- const [error, setError] = useState();
+
+ const errorRef = useRef(undefined);
+
+ function updateError(next: string | undefined) {
+ if (next !== errorRef.current) onErrorChange?.(next);
+ errorRef.current = next;
+ }
// Sync only when controlled — uncontrolled mode keeps its own state.
// biome-ignore lint/correctness/useExhaustiveDependencies: compare on timestamp, not Date identity
@@ -141,8 +157,9 @@ export function DatePicker({
function closePicker() {
popover.disengage();
const committedDate = selectedDateRef.current;
+ const hadError = errorRef.current !== undefined;
setInputValue(committedDate ? dayjs(committedDate).format(dateFormat) : '');
- setError(undefined);
+ updateError(undefined);
/*
* Emit the committed Date directly. Going through
* `dayjs(formattedString).toDate()` re-parses the formatted string without
@@ -152,7 +169,7 @@ export function DatePicker({
* Skip when nothing was ever selected — `onSelect` is typed
* `(date: Date) => void` so we don't fire with `undefined`.
*/
- if (!error && committedDate) onSelect(committedDate);
+ if (!hadError && committedDate) onSelect(committedDate);
}
function handleSelect(day: Date | undefined) {
@@ -161,7 +178,7 @@ export function DatePicker({
// clicks the currently-selected day (deselect). Only forward defined
// dates to consumer `onSelect` — keeps the prop type narrow.
if (day) onSelect(day);
- setError(undefined);
+ updateError(undefined);
popover.disengage();
}
@@ -201,49 +218,27 @@ export function DatePicker({
if (isValid) {
setSelectedDate(date.toDate());
- setError(undefined);
+ updateError(undefined);
} else {
- setError('Invalid date');
+ updateError('Invalid date');
}
}
- const errorId = useId();
- // Link the error text to the input so a user tabbing into an already-erroring
- // field hears the reason, not just "invalid" (role="alert" alone only fires
- // when the message first appears). Merge with any consumer-supplied value.
- const describedBy =
- [inputProps['aria-describedby'], error ? errorId : undefined]
- .filter(Boolean)
- .join(' ') || undefined;
-
const defaultTrigger = (
- <>
- : undefined}
- data-slot='date-picker-input'
- {...inputProps}
- aria-describedby={describedBy}
- ref={popover.inputRef}
- value={inputValue}
- onChange={handleInputChange}
- onFocus={popover.handleInputFocus}
- onBlur={popover.handleInputBlur}
- onKeyUp={handleKeyUp}
- />
-
- {error}
-
- >
+ : undefined}
+ data-slot='date-picker-input'
+ {...inputProps}
+ ref={popover.inputRef}
+ value={inputValue}
+ onChange={handleInputChange}
+ onFocus={popover.handleInputFocus}
+ onBlur={popover.handleInputBlur}
+ onKeyUp={handleKeyUp}
+ />
);
/*
diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css
index 0a37a8571..90ea1b0a4 100644
--- a/packages/raystack/components/filter-chip/filter-chip.module.css
+++ b/packages/raystack/components/filter-chip/filter-chip.module.css
@@ -227,14 +227,6 @@ button.selectValue:hover {
display: none;
}
-/* The DatePicker reserves an (invisible) error line below its input so
- appearing errors don't shift layout. Inside the 24px chip row that
- reserved line inflates the wrapper and pushes the input off-center;
- the chip signals date errors with the danger border below instead. */
-.dateFieldWrapper [class*="datePickerError"] {
- display: none;
-}
-
.dateFieldWrapper [class*="input-error-wrapper"] {
border: 1px solid var(--rs-color-border-danger-primary);
}