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); }