From 31abed89458cf6f73b190266e4eb1945b0629f67 Mon Sep 17 00:00:00 2001 From: interacsean Date: Thu, 16 Jul 2026 15:19:03 +1000 Subject: [PATCH] fix(date-field): validate typed/shortcut input against min/max + isDateUnavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing a date (or landing one via a keyboard shortcut) outside the allowed range was silently accepted — minValue/maxValue were only consulted by the shortcut clamp, and isDateUnavailable only gated calendar selection. The field now flags such a value invalid (aria-invalid + a built-in message) while still emitting it through onChange, matching the calendar's behavior and react-aria's validation contract. - Remove the applyShortcut clamp: a shortcut target outside [min,max] is surfaced as invalid rather than coerced to the boundary, consistent with typing. The open-popover (calendar) path still clamps roving focus. - Honour isDateUnavailable in field validation (DateField + DatePicker), not just calendar selection. - useDateFieldState now returns { isInvalid, invalidReason }; date-field.tsx ORs it into the derived invalid state and shows a localized default message (en/ja) that a consumer errorMessage overrides. Co-Authored-By: Claude Fable 5 --- .changeset/soft-boxes-invent.md | 9 +++ .../components/date-field/date-field.test.tsx | 70 ++++++++++++++++--- .../src/components/date-field/date-field.tsx | 58 +++++++++++---- .../core/src/components/date-field/i18n.ts | 6 ++ .../date-field/use-date-field-state.ts | 59 +++++++++++++--- 5 files changed, 169 insertions(+), 33 deletions(-) create mode 100644 .changeset/soft-boxes-invent.md 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 (
@@ -180,7 +203,7 @@ function DateField({ describedById={describedBy} /> {descText && {descText}} - {derivedInvalid && errorText && {errorText}} + {derivedInvalid && shownError && {shownError}} {name && }
); @@ -242,7 +265,6 @@ function DatePicker({ 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(null); @@ -264,9 +286,11 @@ function DatePicker({ 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, @@ -288,7 +312,13 @@ function DatePicker({ 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 }) @@ -333,7 +363,7 @@ function DatePicker({ /> {descText && {descText}} - {derivedInvalid && errorText && {errorText}} + {derivedInvalid && shownError && {shownError}} {name && } ); diff --git a/packages/core/src/components/date-field/i18n.ts b/packages/core/src/components/date-field/i18n.ts index fe024592..f99cd6a6 100644 --- a/packages/core/src/components/date-field/i18n.ts +++ b/packages/core/src/components/date-field/i18n.ts @@ -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", @@ -28,6 +32,8 @@ export const dateFieldLabels = defineI18nLabels({ chooseDateFor: (p: { name: string }) => `${p.name}、日付を選択`, calendar: "カレンダー", empty: "未入力", + dateOutOfRange: "指定できる範囲外の日付です。", + dateUnavailable: "この日付は選択できません。", year: "年", month: "月", day: "日", diff --git a/packages/core/src/components/date-field/use-date-field-state.ts b/packages/core/src/components/date-field/use-date-field-state.ts index c79bff3b..685a5e2b 100644 --- a/packages/core/src/components/date-field/use-date-field-state.ts +++ b/packages/core/src/components/date-field/use-date-field-state.ts @@ -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; @@ -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, @@ -168,6 +203,7 @@ export function useDateFieldState(options: DateFieldStateOptions) { placeholderValue, minValue, maxValue, + isDateUnavailable, firstDayOfWeek, isReadOnly, } = options; @@ -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], ); /** @@ -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,