diff --git a/.changeset/soft-boxes-invent.md b/.changeset/soft-boxes-invent.md
new file mode 100644
index 00000000..68d804c5
--- /dev/null
+++ b/.changeset/soft-boxes-invent.md
@@ -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.
diff --git a/packages/core/src/components/date-field/date-field.test.tsx b/packages/core/src/components/date-field/date-field.test.tsx
index 1c22356b..d5a95433 100644
--- a/packages/core/src/components/date-field/date-field.test.tsx
+++ b/packages/core/src/components/date-field/date-field.test.tsx
@@ -9,6 +9,7 @@ import {
getLocalTimeZone,
startOfWeek,
endOfWeek,
+ isSameDay,
} from "@internationalized/date";
import { createAppShellWrapper } from "../../../tests/test-utils";
import { DateField, DatePicker } from "./date-field";
@@ -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(
@@ -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();
+ 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();
+ 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(
+ 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 () => {
@@ -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(
@@ -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();
});
});
diff --git a/packages/core/src/components/date-field/date-field.tsx b/packages/core/src/components/date-field/date-field.tsx
index 15e3dc39..4b81e872 100644
--- a/packages/core/src/components/date-field/date-field.tsx
+++ b/packages/core/src/components/date-field/date-field.tsx
@@ -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 {
@@ -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(
controlled: V | undefined,
@@ -113,6 +129,7 @@ function DateField({
placeholderValue,
minValue,
maxValue,
+ isDateUnavailable,
isDisabled,
isReadOnly,
isInvalid,
@@ -125,16 +142,12 @@ function DateField({
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
@@ -146,17 +159,27 @@ function DateField({
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 (