Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/soft-boxes-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@tailor-platform/app-shell": patch
---

Fix `DateField` / `DatePicker` not validating typed input against `minValue`, `maxValue`, or `isDateUnavailable`. A date entered by typing (or via a keyboard shortcut) that fell outside the allowed range was silently accepted. It's now flagged invalid (`aria-invalid` + a built-in message) while the value still flows through `onChange` — matching how the calendar already behaves and the react-aria validation contract.

- Keyboard shortcuts (`t`, `m`, `y`, …) no longer clamp their target to the boundary; like typing, an out-of-range result is surfaced as invalid instead of being silently coerced.
- `isDateUnavailable` now also drives field validation (e.g. weekends or specific blackout dates typed into the field are flagged), not just calendar selection.
- Consumer `errorMessage` still takes precedence over the built-in messages.
70 changes: 62 additions & 8 deletions packages/core/src/components/date-field/date-field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getLocalTimeZone,
startOfWeek,
endOfWeek,
isSameDay,
} from "@internationalized/date";
import { createAppShellWrapper } from "../../../tests/test-utils";
import { DateField, DatePicker } from "./date-field";
Expand Down Expand Up @@ -650,7 +651,7 @@ describe("DateField keyboard shortcuts", () => {
});
});

it("clamps a shortcut target to minValue / maxValue", async () => {
it("flags a shortcut target invalid when it lands outside minValue / maxValue (no clamp)", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
Expand All @@ -662,13 +663,64 @@ describe("DateField keyboard shortcuts", () => {
onChange={onChange}
/>,
);
const group = screen.getByRole("group");

await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("y"); // year start (1 Jan) < min → clamps to 10 Jun
await lastEmit(onChange, "2025-06-10");
await user.keyboard("y"); // year start = 1 Jan 2025 — before min; emitted, NOT clamped
await lastEmit(onChange, "2025-01-01");
expect(group.hasAttribute("data-invalid")).toBe(true);

await user.keyboard("r"); // year end = 31 Dec 2025 — after max; emitted, NOT clamped
await lastEmit(onChange, "2025-12-31");
expect(group.hasAttribute("data-invalid")).toBe(true);
});

it("flags a typed date before minValue invalid, but still emits it", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<DateField label="Date" minValue={new CalendarDate(2025, 6, 10)} onChange={onChange} />);
const group = screen.getByRole("group");

// en order: month / day / year — type 5 Jun 2025 (before the 10 Jun min).
await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("06052025");
await lastEmit(onChange, "2025-06-05"); // value flows through, not clamped
expect(group.hasAttribute("data-invalid")).toBe(true);
// Built-in message (no consumer errorMessage provided).
expect(screen.getByText("Date is outside the allowed range.")).toBeDefined();
});

it("clears the invalid flag once the typed date is back within range", async () => {
const user = userEvent.setup();
render(<DateField label="Date" minValue={new CalendarDate(2025, 6, 10)} />);
const group = screen.getByRole("group");

await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("06052025"); // 5 Jun — before min → invalid
expect(group.hasAttribute("data-invalid")).toBe(true);

await user.keyboard("r"); // year end (31 Dec) > max → clamps to 20 Jun
await lastEmit(onChange, "2025-06-20");
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("15"); // 15 Jun — within range → valid again
expect(group.hasAttribute("data-invalid")).toBe(false);
});

it("flags a typed date invalid when isDateUnavailable rejects it", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const unavailable = new CalendarDate(2025, 6, 12);
render(
<DateField
label="Date"
isDateUnavailable={(d) => isSameDay(d, unavailable)}
onChange={onChange}
/>,
);

await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("06122025"); // the unavailable 12 Jun 2025
await lastEmit(onChange, "2025-06-12");
expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true);
expect(screen.getByText("This date is unavailable.")).toBeDefined();
});

it("'/' commits the current segment and advances to the next ('1/' ⇒ month 01)", async () => {
Expand Down Expand Up @@ -866,7 +918,7 @@ describe("DatePicker keyboard", () => {
});
});

it("clamps a field shortcut to minValue while the popover is closed", async () => {
it("flags a field shortcut invalid when it lands before minValue (no clamp, popover closed)", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
Expand All @@ -879,13 +931,15 @@ describe("DatePicker keyboard", () => {
);

// Focus a segment (not the trigger) — the popover stays closed, so this is
// the field path. "y" targets 1 Jan, which is clamped up to the min.
// the field path. "y" targets 1 Jan: emitted as-is and flagged invalid,
// where the open-popover (calendar) path instead clamps the roving focus.
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("y");

await waitFor(() => {
expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-10");
expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-01-01");
});
expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true);
expect(screen.queryByRole("dialog")).toBeNull();
});
});
58 changes: 44 additions & 14 deletions packages/core/src/components/date-field/date-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import type { DateValue } from "@internationalized/date";
import { cn } from "@/lib/utils";
import { buildLocaleResolver, type LocalizedString } from "@/lib/i18n";
import { useResolvedLocale, useTimeZone } from "@/contexts/appshell-context";
import { useDateFieldState, type Granularity, type HourCycle } from "./use-date-field-state";
import {
useDateFieldState,
type DateFieldInvalidReason,
type Granularity,
type HourCycle,
} from "./use-date-field-state";
import { useCalendarState, type FirstDayOfWeek } from "../calendar/use-calendar-state";
import { CalendarView } from "../calendar/calendar-view";
import {
Expand All @@ -22,6 +27,17 @@ import { useDateFieldT } from "./i18n";
* differ. Consumers never see Base UI or the date engines.
*/

// Built-in validation message key for a field's invalid reason (null = none, so
// the consumer's `errorMessage` — or no message — stands). A lookup rather than
// a nested ternary keeps the lint happy.
function invalidMessageKey(
reason: DateFieldInvalidReason | null | undefined,
): "dateUnavailable" | "dateOutOfRange" | null {
if (reason === "unavailable") return "dateUnavailable";
if (reason === "range") return "dateOutOfRange";
return null;
}

// ─── Small controlled-state helper ────────────────────────────────────────────
function useControlledState<V>(
controlled: V | undefined,
Expand Down Expand Up @@ -113,6 +129,7 @@ function DateField<T extends DateValue = DateValue>({
placeholderValue,
minValue,
maxValue,
isDateUnavailable,
isDisabled,
isReadOnly,
isInvalid,
Expand All @@ -125,16 +142,12 @@ function DateField<T extends DateValue = DateValue>({
const { locale: shellLocale, language } = useResolvedLocale();
const resolvedLocale = localeProp ?? shellLocale;
const resolve = buildLocaleResolver(language);
const t = useDateFieldT();

const labelId = useId();
const descId = useId();
const errId = useId();

const labelText = label ? resolve(label, "") : undefined;
const descText = description ? resolve(description, "") : undefined;
const errorText = errorMessage ? resolve(errorMessage, "") : undefined;
const derivedInvalid = !!errorText || !!isInvalid;

const state = useDateFieldState({
// Pass `value` through as-is: `null` is a controlled-empty value and must
// stay distinct from `undefined` (uncontrolled), or a parent clearing the
Expand All @@ -146,17 +159,27 @@ function DateField<T extends DateValue = DateValue>({
locale: resolvedLocale,
hourCycle,
placeholderValue,
// Let the keyboard shortcuts clamp into range (the field has no calendar to
// enforce it otherwise).
// min/max and unavailability flag a typed/shortcut value invalid (not
// clamped) — the field is free-entry with no calendar to gate selection.
minValue,
maxValue,
isDateUnavailable,
// Drives the `w`/`k` (start/end of week) shortcuts; the standalone field has
// no calendar to pair with, so this is the only week-start override.
firstDayOfWeek,
isReadOnly,
});

const describedBy = cn(descText && descId, derivedInvalid && errorText && errId) || undefined;
const labelText = label ? resolve(label, "") : undefined;
const descText = description ? resolve(description, "") : undefined;
const errorText = errorMessage ? resolve(errorMessage, "") : undefined;
// Consumer `errorMessage` wins; otherwise fall back to the built-in message
// for an out-of-range / unavailable typed value.
const msgKey = invalidMessageKey(state.invalidReason);
const shownError = errorText ?? (msgKey ? t(msgKey) : undefined);
const derivedInvalid = !!errorText || !!isInvalid || state.isInvalid;

const describedBy = cn(descText && descId, derivedInvalid && shownError && errId) || undefined;

return (
<div data-slot="date-field" className={cn("astw:flex astw:flex-col astw:gap-1", className)}>
Expand All @@ -180,7 +203,7 @@ function DateField<T extends DateValue = DateValue>({
describedById={describedBy}
/>
{descText && <DatePickerDescription id={descId}>{descText}</DatePickerDescription>}
{derivedInvalid && errorText && <DatePickerError id={errId}>{errorText}</DatePickerError>}
{derivedInvalid && shownError && <DatePickerError id={errId}>{shownError}</DatePickerError>}
{name && <input type="hidden" name={name} value={state.fieldValue?.toString() ?? ""} />}
</div>
);
Expand Down Expand Up @@ -242,7 +265,6 @@ function DatePicker<T extends DateValue = DateValue>({
const labelText = label ? resolve(label, "") : undefined;
const descText = description ? resolve(description, "") : undefined;
const errorText = errorMessage ? resolve(errorMessage, "") : undefined;
const derivedInvalid = !!errorText || !!isInvalid;

const [open, setOpen] = useState(false);
const fieldRef = useRef<HTMLDivElement>(null);
Expand All @@ -264,9 +286,11 @@ function DatePicker<T extends DateValue = DateValue>({
timeZone: resolvedTz,
hourCycle,
placeholderValue,
// Let the keyboard shortcuts clamp into the same range the calendar enforces.
// Same bounds the calendar enforces, but on the field they flag a typed/
// shortcut value invalid (the calendar gates selection; typing can't be).
minValue,
maxValue,
isDateUnavailable,
// Match the calendar's week-start so field + calendar `w`/`k` agree.
firstDayOfWeek,
isReadOnly,
Expand All @@ -288,7 +312,13 @@ function DatePicker<T extends DateValue = DateValue>({
timeZone: resolvedTz,
});

const describedBy = cn(descText && descId, derivedInvalid && errorText && errId) || undefined;
// Consumer `errorMessage` wins; otherwise the built-in out-of-range /
// unavailable message for a typed or shortcut-entered value.
const msgKey = invalidMessageKey(fieldState.invalidReason);
const shownError = errorText ?? (msgKey ? t(msgKey) : undefined);
const derivedInvalid = !!errorText || !!isInvalid || fieldState.isInvalid;

const describedBy = cn(descText && descId, derivedInvalid && shownError && errId) || undefined;
const accessibleName = labelText ?? ariaLabel;
const popoverAriaLabel = accessibleName
? t("chooseDateFor", { name: accessibleName })
Expand Down Expand Up @@ -333,7 +363,7 @@ function DatePicker<T extends DateValue = DateValue>({
/>
</DatePopover>
{descText && <DatePickerDescription id={descId}>{descText}</DatePickerDescription>}
{derivedInvalid && errorText && <DatePickerError id={errId}>{errorText}</DatePickerError>}
{derivedInvalid && shownError && <DatePickerError id={errId}>{shownError}</DatePickerError>}
{name && <input type="hidden" name={name} value={fieldState.fieldValue?.toString() ?? ""} />}
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/components/date-field/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export const dateFieldLabels = defineI18nLabels({
chooseDateFor: (p: { name: string }) => `${p.name}, choose date`,
calendar: "Calendar",
empty: "Empty",
// Default validation messages (shown when the consumer passes no
// `errorMessage`): a typed/shortcut date outside min/max, or unavailable.
dateOutOfRange: "Date is outside the allowed range.",
dateUnavailable: "This date is unavailable.",
// Per-segment accessible names (keys match the segment `type`).
year: "year",
month: "month",
Expand All @@ -28,6 +32,8 @@ export const dateFieldLabels = defineI18nLabels({
chooseDateFor: (p: { name: string }) => `${p.name}、日付を選択`,
calendar: "カレンダー",
empty: "未入力",
dateOutOfRange: "指定できる範囲外の日付です。",
dateUnavailable: "この日付は選択できません。",
year: "年",
month: "月",
day: "日",
Expand Down
59 changes: 48 additions & 11 deletions packages/core/src/components/date-field/use-date-field-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,20 @@ export interface DateFieldStateOptions {
timeZone?: string;
hourCycle?: HourCycle;
placeholderValue?: DateValue;
/** Lower/upper bound the keyboard shortcuts clamp their target date into. */
/**
* Bounds used to flag the composed value invalid when it falls outside
* `[minValue, maxValue]`. The field is free-entry, so it is NOT clamped —
* typed dates and the QBO shortcuts may land outside the range and are
* surfaced as invalid (matching react-aria's validation contract).
*/
minValue?: DateValue;
maxValue?: DateValue;
/**
* Marks the composed value invalid when it returns `true` (e.g. weekends or
* specific blackout dates). Mirrors the calendar's `isDateUnavailable`; here
* it drives validation rather than blocking entry.
*/
isDateUnavailable?: (date: DateValue) => boolean;
/** Week-start for the `w`/`k` shortcuts; defaults to the locale convention. */
firstDayOfWeek?: FirstDayOfWeek;
isDisabled?: boolean;
Expand Down Expand Up @@ -156,6 +167,30 @@ function use12HourCycle(locale: string, hourCycle?: HourCycle): boolean {
}
}

/** Why a composed value is invalid — out of `[min,max]`, or `isDateUnavailable`. */
export type DateFieldInvalidReason = "range" | "unavailable";

/**
* Validation for a free-entry field: the value still emits, but a date outside
* `[minValue, maxValue]` or rejected by `isDateUnavailable` is flagged invalid.
* Range bounds are compared at day granularity (via `toCalendarDate`), matching
* how the calendar clamps. Returns `null` when the value is complete and allowed
* (or incomplete, i.e. nothing to validate yet).
*/
function getInvalidReason(
value: DateValue | null,
minValue?: DateValue,
maxValue?: DateValue,
isDateUnavailable?: (date: DateValue) => boolean,
): DateFieldInvalidReason | null {
if (value == null) return null;
const cd = toCalendarDate(value as never);
if (minValue != null && cd.compare(toCalendarDate(minValue as never)) < 0) return "range";
if (maxValue != null && cd.compare(toCalendarDate(maxValue as never)) > 0) return "range";
if (isDateUnavailable?.(value)) return "unavailable";
return null;
}

export function useDateFieldState(options: DateFieldStateOptions) {
const {
value: controlledValue,
Expand All @@ -168,6 +203,7 @@ export function useDateFieldState(options: DateFieldStateOptions) {
placeholderValue,
minValue,
maxValue,
isDateUnavailable,
firstDayOfWeek,
isReadOnly,
} = options;
Expand Down Expand Up @@ -410,18 +446,14 @@ export function useDateFieldState(options: DateFieldStateOptions) {
} else {
base = completeCalendarDate(fields) ?? ref;
}
let next = resolveDateShortcut(cmd, base, ref, locale, firstDayOfWeek);
// Clamp into [minValue, maxValue] so a shortcut can't land outside the
// allowed range — mirrors the calendar grid, which clamps roving focus the
// same way. (`isDateUnavailable` isn't enforced here; the field is a
// free-entry control, so — like typing — it may land on an unavailable date.)
const lo = minValue ? toCalendarDate(minValue) : null;
const hi = maxValue ? toCalendarDate(maxValue) : null;
if (lo && next.compare(lo) < 0) next = lo;
if (hi && next.compare(hi) > 0) next = hi;
const next = resolveDateShortcut(cmd, base, ref, locale, firstDayOfWeek);
// No clamping: like typing, a shortcut may land outside [minValue, maxValue]
// (or on an unavailable date). An out-of-range result is surfaced as invalid
// (see the validation below) rather than silently coerced to the boundary —
// the same free-entry contract as typing.
commit({ ...fields, year: next.year, month: next.month, day: next.day });
},
[fields, timeZone, locale, isReadOnly, commit, minValue, maxValue, firstDayOfWeek],
[fields, timeZone, locale, isReadOnly, commit, firstDayOfWeek],
);

/**
Expand Down Expand Up @@ -540,10 +572,15 @@ export function useDateFieldState(options: DateFieldStateOptions) {
}, [segmentFormat, fields, editableTypes, getLimits]);

const fieldValue = composeValue(fields);
// Free-entry validation: the value still emits (it's what the user typed), but
// an out-of-range or unavailable date is surfaced as invalid, not clamped.
const invalidReason = getInvalidReason(fieldValue, minValue, maxValue, isDateUnavailable);

return {
segments,
fieldValue,
isInvalid: invalidReason != null,
invalidReason,
cycle,
setDigit,
setDayPeriod,
Expand Down
Loading