diff --git a/.changeset/odd-maps-glow.md b/.changeset/odd-maps-glow.md
new file mode 100644
index 00000000..5f3d0201
--- /dev/null
+++ b/.changeset/odd-maps-glow.md
@@ -0,0 +1,36 @@
+---
+"@tailor-platform/app-shell": major
+---
+
+Refactor `DateField` / `DatePicker` to follow the same composition model as `Field`, `Select`, `Combobox`, and `Autocomplete`.
+
+The date controls are now **control-first**: field chrome moved out of the control props and into `Field.Root` composition. This is a breaking change for the field chrome API (`label`, `description`, `errorMessage`, `isInvalid`), while the semantic date props (`isRequired`, `isDisabled`, `isReadOnly`, `minValue`, `maxValue`, `isDateUnavailable`) remain top-level and aligned with `Calendar`.
+
+Before:
+
+```tsx
+
+```
+
+After:
+
+```tsx
+
+ Delivery date
+
+ When should we ship your order?
+ {error}
+
+```
+
+Standalone usage still works with accessible naming:
+
+```tsx
+
+```
diff --git a/docs/components/date-picker.md b/docs/components/date-picker.md
index 20df4f31..f88b934b 100644
--- a/docs/components/date-picker.md
+++ b/docs/components/date-picker.md
@@ -5,9 +5,7 @@ description: Accessible date input components (@internationalized/date + Base UI
# DatePicker
-Three related components for date input — a segmented field, a field with a calendar popover, and a standalone calendar grid. Built on [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/) (the value layer) and Base UI (`Popover`), with the segmented input and calendar grid implemented to the [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/) date-picker/grid patterns. They integrate automatically with AppShell's locale and timezone context.
-
-> **Implementation note.** This is the `@internationalized/date` + Base UI variant. The public API and accessibility contract are identical to the react-aria variant; only the internals differ.
+Three related components for date input — a segmented field, a field with a calendar popover, and a standalone calendar grid. Built on [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/) and Base UI.
[Live preview in the UI Catalogue →](https://ui.tailor.tech/components/date-picker)
@@ -21,36 +19,48 @@ import {
// Date value helpers (re-exported from @internationalized/date)
parseDate,
getLocalTimeZone,
+ today,
type CalendarDate,
type DateValue,
} from "@tailor-platform/app-shell";
```
-No separate `@internationalized/date` install needed — the value types and helpers are re-exported from `@tailor-platform/app-shell`.
+## API shape
+
+`DateField` and `DatePicker` are standalone composite controls.
+
+- They own date entry, keyboard behavior, constraints, locale/timezone handling, and form value serialization.
+- They expose standard labeling hooks: `id`, `aria-label`, `aria-labelledby`, `aria-describedby`, and `isInvalid`.
+- They do **not** auto-wire into Base UI `Field.Root`; use standard HTML labels / descriptions / errors instead.
## DateField
-A segmented input that lets users type dates digit-by-digit, with per-segment Up/Down, type-to-fill auto-advance, and full keyboard support.
+Standalone usage with an accessible name:
```tsx
-
+
```
-### With description and error
+With a visible label + description:
```tsx
+
+ Invoice date
+
+
Format follows your locale
```
-### Controlled
+Controlled:
```tsx
const [date, setDate] = useState(null);
- ;
+
+ ;
```
## DatePicker
@@ -58,14 +68,18 @@ const [date, setDate] = useState(null);
A `DateField` with a calendar popover.
```tsx
-
+
```
-### Constrained + unavailable dates
+Constrained + unavailable dates:
```tsx
+
+ Delivery date
+
{
const dow = date.toDate(getLocalTimeZone()).getDay();
@@ -74,15 +88,34 @@ A `DateField` with a calendar popover.
/>
```
-### Week start
+Week start:
```tsx
-
+
+```
+
+## External errors
+
+Use standard HTML + ARIA for external errors:
+
+```tsx
+
+ Delivery date
+
+
+{error && {error}
}
```
## Calendar
-A standalone calendar grid for custom date-selection UIs (e.g. reporting filters).
+A standalone calendar grid for custom date-selection UIs.
```tsx
console.log(date)} />
@@ -93,95 +126,54 @@ A standalone calendar grid for custom date-selection UIs (e.g. reporting filters
Locale and timezone come from AppShell automatically. Override per field with `locale` / `timeZone`:
```tsx
-
+
```
-Segment order, first-day-of-week, and month/weekday names all follow the resolved locale.
-
## Keyboard
-- **Segments:** `↑`/`↓` increment/decrement, digits type-to-fill (auto-advance), `←`/`→` move between segments, `Backspace` clears, `/` commits the current segment and advances (so a single `1` means January, not the start of `1x`).
-- **Whole-date shortcuts** (QuickBooks Online-style, case-insensitive): `t` today · `m`/`h` start/end of the entered month (current month when empty) · `y`/`r` start/end of the year · `w`/`k` start/end of the week (locale-aware) · `-` previous day · `=`/`+` next day (both step across month **and** year boundaries; `+` needs no Shift). A 1–2 digit year expands to the 2000s on blur (`26` → `2026`). These work **from a focused date segment** (they set the field value, clamped to `minValue`/`maxValue`) **and while the calendar popover is open** (they move the highlighted day like the arrow keys — press `Enter` to confirm; `minValue`/`maxValue` clamp and unavailable days can't be confirmed).
-- **Calendar grid:** arrows move by day/week, `Home`/`End` to week start/end, `PageUp`/`PageDown` by month, `Shift`+`PageUp`/`PageDown` by year, `Enter`/`Space` selects. `Alt`+`↓` opens the calendar from the field (`DatePicker`).
-
-## Accessibility
-
-- The segmented field is a labelled `role="group"` of `role="spinbutton"` segments with `aria-valuemin`/`max`/`now`/`text`.
-- The calendar is a `role="grid"`; each day is a button with a full-date `aria-label`; disabled/unavailable days are announced via `aria-disabled`.
-- The popover is a labelled `role="dialog"`.
-
-> **Known limitations (this variant).** The segments are `` that aren't `contentEditable`, so a touch device's on-screen keyboard doesn't open for typing — on mobile, use the calendar popover to pick a date (desktop keyboard entry and the calendar both work fully). The APG patterns are implemented and unit-tested but **not yet screen-reader-audited**, and RTL arrow-key flipping isn't handled.
+- **Segments:** `↑`/`↓` increment/decrement, digits type-to-fill (auto-advance), `←`/`→` move between segments, `Backspace` clears, `/` commits the current segment and advances.
+- **Whole-date shortcuts:** `t` today · `m`/`h` start/end of the entered month · `y`/`r` start/end of the year · `w`/`k` start/end of the week · `-` previous day · `=`/`+` next day.
+- **Calendar grid:** arrows move by day/week, `Home`/`End` to week start/end, `PageUp`/`PageDown` by month, `Shift`+`PageUp`/`PageDown` by year, `Enter`/`Space` selects. `Alt`+`↓` opens the calendar from the field.
## Props
-The tables below list props this variant **actually implements** for v1 (date granularity). A few props are part of the type surface — kept identical to the react-aria variant so a later swap is source-compatible — but aren't acted on yet; those are called out under [Proposed / not yet implemented](#proposed--not-yet-implemented).
-
### DateFieldProps
-| Prop | Type | Description |
-| ----------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
-| `label` | `LocalizedString` | Field label |
-| `description` | `LocalizedString` | Helper text below the field |
-| `errorMessage` | `LocalizedString` | Error text; also sets the invalid state |
-| `value` / `defaultValue` | `DateValue \| null` | Controlled / uncontrolled value (`CalendarDate` at date granularity) |
-| `onChange` | `(v: DateValue \| null) => void` | Fires on a complete, valid value; `null` when cleared |
-| `isDisabled` / `isReadOnly` / `isInvalid` | `boolean` | State flags |
-| `isRequired` | `boolean` | Sets `aria-required` on the segments (no visual required indicator yet) |
-| `placeholderValue` | `DateValue` | Seeds unset segments (increment start + segment order) |
-| `autoFocus` | `boolean` | Focus the first segment on mount |
-| `locale` | `string` | BCP-47 locale override (defaults to the AppShell formatting locale) |
-| `name` | `string` | Emits a hidden `
` with the ISO value for form submission |
-| `firstDayOfWeek` | `"sun" \| "mon" \| "tue" \| "wed" \| "thu" \| "fri" \| "sat"` | Override the locale's week start for the `w`/`k` keyboard shortcuts; omit to follow the locale |
-| `aria-label` | `string` | Accessible name when there's no visible `label` (e.g. compact filters) |
-| `className` | `string` | Root element class |
-
-> `DateField` has no calendar, so `minValue` / `maxValue` / `isDateUnavailable` don't apply to it — they're honoured by `DatePicker` and `Calendar` below.
+| Prop | Type | Description |
+| -------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------- |
+| `value` / `defaultValue` | `DateValue \| null` | Controlled / uncontrolled value |
+| `onChange` | `(v: DateValue \| null) => void` | Fires when the value changes |
+| `onBlur` | `() => void` | Fires when focus leaves the whole segmented control |
+| `minValue` / `maxValue` | `DateValue` | Inclusive date range bounds |
+| `isDateUnavailable` | `(date: DateValue) => boolean` | Marks specific dates unavailable |
+| `isDisabled` | `boolean` | Disables interaction and form submission |
+| `isReadOnly` | `boolean` | Allows focus/navigation without editing |
+| `isRequired` | `boolean` | Marks the control required |
+| `isInvalid` | `boolean` | Adds invalid styling / `aria-invalid` to the segmented UI |
+| `placeholderValue` | `DateValue` | Seeds unset segments |
+| `autoFocus` | `boolean` | Focus the first segment on mount |
+| `locale` | `string` | BCP-47 locale override |
+| `name` | `string` | Emits a form value through the proxy input |
+| `id` | `string` | Proxy input id (use with external `
`). |
+| `firstDayOfWeek` | `"sun" \| "mon" \| "tue" \| "wed" \| "thu" \| "fri" \| "sat"` | Override the locale week start used by `w` / `k` shortcuts |
+| `aria-label` / `aria-labelledby` | `string` | Accessible name |
+| `aria-describedby` | `string` | IDs of description / error elements |
+| `className` | `string` | Root element class |
### DatePickerProps
All `DateFieldProps`, plus:
-| Prop | Type | Description |
-| ----------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------- |
-| `minValue` / `maxValue` | `DateValue` | Earliest / latest selectable date in the calendar |
-| `isDateUnavailable` | `(date: DateValue) => boolean` | Mark individual dates unselectable (still keyboard-navigable) |
-| `firstDayOfWeek` | `"sun" \| "mon" \| "tue" \| "wed" \| "thu" \| "fri" \| "sat"` | Force the calendar's first column; omit to follow the locale |
-| `timeZone` | `string` | IANA timezone for resolving "today"; defaults to AppShell `timeZone` |
+| Prop | Type | Description |
+| ---------------- | ----------------------- | ----------------------------------- |
+| `timeZone` | `string` | IANA timezone for resolving "today" |
+| `firstDayOfWeek` | `"sun" \| "mon" \| ...` | Force the calendar's first column |
### CalendarProps
-The standalone calendar grid. It has no segmented input, so its surface is listed in full:
-
-| Prop | Type | Description |
-| -------------------------------------- | ------------------------------ | ------------------------------------------------------------- |
-| `value` / `defaultValue` | `DateValue \| null` | Controlled / uncontrolled selected date |
-| `onChange` | `(v: DateValue) => void` | Fires when a date is selected |
-| `minValue` / `maxValue` | `DateValue` | Earliest / latest selectable date |
-| `isDateUnavailable` | `(date: DateValue) => boolean` | Mark individual dates unselectable (still keyboard-navigable) |
-| `focusedValue` / `defaultFocusedValue` | `DateValue` | Controlled / initial focused (visible) date |
-| `onFocusChange` | `(date: CalendarDate) => void` | Fires when the focused date changes (arrows, month paging) |
-| `firstDayOfWeek` | `"sun" \| "mon" \| …` | Force the first column; omit to follow the locale |
-| `isDisabled` / `isReadOnly` | `boolean` | Disable the grid / prevent selection changes |
-| `timeZone` | `string` | IANA timezone for "today"; defaults to AppShell `timeZone` |
-| `locale` | `string` | BCP-47 locale override |
-| `aria-label` / `aria-labelledby` | `string` | Accessible name for the grid |
-| `className` | `string` | Root element class |
-
-### Proposed / not yet implemented
-
-Accepted by the prop types (for parity with the react-aria variant) but **not acted on** in this variant yet:
-
-| Prop | Type | Status |
-| -------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `granularity` | `"day" \| "hour" \| "minute" \| "second"` | Only `"day"` is supported (the default). Time granularities — and the `CalendarDateTime` / `ZonedDateTime` values they produce — are the tracked **DateTime fast-follow**; the calendar has no time selection yet. |
-| `hourCycle` | `12 \| 24` | No effect until time granularity lands (12h/24h only matters with an hour segment). |
-| `hideTimeZone` | `boolean` | Unused; only relevant to `ZonedDateTime` display (time granularity). |
-
-Only date granularity is supported in v1; DateTime support is planned for a later release.
+See the calendar docs in-code: controlled/uncontrolled value, min/max, unavailable dates, focused date, locale, timezone, accessible naming, and className.
## Related
-- [Form](./form.md) — wrap date fields with validation
-- [Input](./input.md) — plain text input
-- [useTimeZone](../api/use-time-zone.md) — access the configured timezone consumed automatically by these components
-- [useResolvedLocale](../api/use-resolved-locale.md) — access the locale used for segment order and month names
+- [useTimeZone](../api/use-time-zone.md)
+- [useResolvedLocale](../api/use-resolved-locale.md)
diff --git a/examples/vite-app/src/pages/date-picker/page.tsx b/examples/vite-app/src/pages/date-picker/page.tsx
index b421d4e5..0bef2cfc 100644
--- a/examples/vite-app/src/pages/date-picker/page.tsx
+++ b/examples/vite-app/src/pages/date-picker/page.tsx
@@ -1,10 +1,9 @@
-import { useState, type FormEvent } from "react";
+import { cloneElement, useState, type FormEvent, type ReactElement } from "react";
import {
Layout,
DateField,
DatePicker,
Calendar,
- Form,
Button,
useTimeZone,
parseDate,
@@ -14,6 +13,55 @@ import {
} from "@tailor-platform/app-shell";
import { CalendarDays } from "lucide-react";
+type DemoFieldControlProps = {
+ id?: string;
+ "aria-labelledby"?: string;
+ "aria-describedby"?: string;
+ isInvalid?: boolean;
+};
+
+function DemoField({
+ id,
+ label,
+ description,
+ error,
+ children,
+}: {
+ id: string;
+ label: string;
+ description?: string;
+ error?: string;
+ children: ReactElement;
+}) {
+ const describedBy = [description && `${id}-description`, error && `${id}-error`]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+
+
+ {label}
+
+ {cloneElement(children, {
+ id,
+ "aria-labelledby": `${id}-label`,
+ "aria-describedby": describedBy || undefined,
+ isInvalid: !!error || children.props.isInvalid,
+ })}
+ {description && (
+
+ {description}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
+
const DatePickerPage = () => {
const tz = useTimeZone();
const [fieldValue, setFieldValue] = useState(null);
@@ -29,8 +77,8 @@ const DatePickerPage = () => {
const tomorrow = tz.today().add({ days: 1 });
const threeMonths = tz.today().add({ months: 3 });
- // Validation runs on submit; the DatePicker surfaces the message through its
- // own `errorMessage` / `isInvalid` props (it isn't a Base UI Field control).
+ // Validation runs on submit; label / description / error wiring stays plain
+ // HTML + ARIA so the date controls don't depend on Base UI's internal Field plumbing.
const handleDeliverySubmit = (e: FormEvent) => {
e.preventDefault();
if (!deliveryDate) {
@@ -72,19 +120,25 @@ const DatePickerPage = () => {
DateField
- setFieldValue(v as CalendarDate | null)}
- />
-
+ setFieldValue(v as CalendarDate | null)}
+ />
+
+
-
-
+ >
+
+
+
+
+
+
+
+
{fieldValue && (
@@ -99,30 +153,38 @@ const DatePickerPage = () => {
DatePicker
- setPickerValue(v as CalendarDate | null)}
- />
-
+ setPickerValue(v as CalendarDate | null)}
+ />
+
+
-
+
+
+ {
- const day = d.toDate(tz.value).getDay();
- return day === 0 || day === 6;
- }}
- />
-
+ {
+ const day = d.toDate(tz.value).getDay();
+ return day === 0 || day === 6;
+ }}
+ />
+
+
+ >
+
+
{pickerValue && (
@@ -136,31 +198,31 @@ const DatePickerPage = () => {
In a form (submit validation)
- Standard Form +{" "}
- Button. Submitting empty (or
- with a past date) triggers validation — the error surfaces through the DatePicker's
- own errorMessage /{" "}
- isInvalid props, and clears as
- soon as a valid date is picked.
+ Standard form submit with a manually wired label + description + error. Submitting
+ empty (or with a past date) marks the date picker invalid through its public props,
+ and the error clears as soon as a valid date is picked.
-
+
{confirmedDate && (
✓ Delivery scheduled for {confirmedDate}
@@ -177,9 +239,15 @@ const DatePickerPage = () => {
explicitly to force a specific start day regardless of locale.
-
-
-
+
+
+
+
+
+
+
+
+
@@ -189,9 +257,15 @@ const DatePickerPage = () => {
Locale (segment order + names)
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap b/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap
index 856ed2c5..db35e0b5 100644
--- a/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap
+++ b/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`snapshots > DateField 1`] = `""`;
+exports[`snapshots > DateField 1`] = `""`;
-exports[`snapshots > DateField — invalid with error 1`] = `""`;
+exports[`snapshots > DateField — manually labelled + described 1`] = `"Date Pick a date
Required
"`;
-exports[`snapshots > DatePicker — closed 1`] = `""`;
+exports[`snapshots > DatePicker — closed 1`] = `""`;
diff --git a/packages/core/package.json b/packages/core/package.json
index 02a28f38..a98cb0a6 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -80,6 +80,7 @@
"postcss": "^8",
"react": "catalog:",
"react-dom": "catalog:",
+ "react-hook-form": "^7.71.2",
"tailwindcss": "catalog:",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
diff --git a/packages/core/src/components/autocomplete-standalone.test.tsx b/packages/core/src/components/autocomplete-standalone.test.tsx
index 7dd4f730..d56f870f 100644
--- a/packages/core/src/components/autocomplete-standalone.test.tsx
+++ b/packages/core/src/components/autocomplete-standalone.test.tsx
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { renderRHFForm } from "../../tests/rhf-test-utils";
+import { Field } from "./field";
import { Autocomplete } from "./autocomplete-standalone";
afterEach(() => {
@@ -146,6 +148,73 @@ describe("Autocomplete (standalone)", () => {
});
});
+describe("Autocomplete (standalone, RHF integration)", () => {
+ it("submits the typed value and resets back to the default", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ renderRHFForm({
+ defaultValues: { fruit: "Apple" },
+ name: "fruit",
+ onSubmit,
+ render: ({ field, fieldState }) => (
+
+ Fruit
+
+ {fieldState.error?.message}
+
+ ),
+ });
+
+ const input = screen.getByRole("combobox", { name: "Fruit" }) as HTMLInputElement;
+ expect(input.value).toBe("Apple");
+
+ await user.clear(input);
+ await user.type(input, "Cherry");
+ await user.keyboard("{Escape}");
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ fruit: "Cherry" });
+ });
+
+ await user.click(screen.getByRole("button", { name: "Reset" }));
+ expect(input.value).toBe("Apple");
+ });
+
+ it("surfaces required errors on submit", async () => {
+ const user = userEvent.setup();
+
+ renderRHFForm({
+ defaultValues: { fruit: "" },
+ name: "fruit",
+ rules: { required: "Fruit is required" },
+ render: ({ field, fieldState }) => (
+
+ Fruit
+
+ {fieldState.error?.message}
+
+ ),
+ });
+
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ expect(await screen.findByText("Fruit is required")).toBeDefined();
+ });
+});
+
describe("Autocomplete.Async (standalone)", () => {
it("renders and calls fetcher on input", async () => {
const fetcher = vi.fn().mockResolvedValue(["Result A", "Result B"]);
diff --git a/packages/core/src/components/checkbox.test.tsx b/packages/core/src/components/checkbox.test.tsx
index b4fafdf4..a0de8fec 100644
--- a/packages/core/src/components/checkbox.test.tsx
+++ b/packages/core/src/components/checkbox.test.tsx
@@ -1,7 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import { cleanup, render, screen } from "@testing-library/react";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { renderRHFForm } from "../../tests/rhf-test-utils";
import { Checkbox } from "./checkbox";
import { Field } from "./field";
@@ -150,4 +151,70 @@ describe("Checkbox", () => {
expect(screen.getByRole("checkbox").hasAttribute("data-disabled")).toBe(true);
});
});
+
+ describe("RHF integration", () => {
+ it("submits boolean values and resets back to the default", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ renderRHFForm({
+ defaultValues: { acceptTerms: false },
+ name: "acceptTerms",
+ onSubmit,
+ render: ({ field, fieldState }) => (
+
+
+ {fieldState.error?.message}
+
+ ),
+ });
+
+ const checkbox = screen.getByRole("checkbox", { name: "Accept the terms" });
+ await user.click(checkbox);
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ acceptTerms: true });
+ });
+
+ await user.click(screen.getByRole("button", { name: "Reset" }));
+ expect(checkbox.getAttribute("aria-checked")).toBe("false");
+ });
+
+ it("renders required errors after blur", async () => {
+ const user = userEvent.setup();
+
+ renderRHFForm({
+ defaultValues: { acceptTerms: false },
+ name: "acceptTerms",
+ mode: "onBlur",
+ rules: {
+ validate: (value) => value || "You must accept the terms.",
+ },
+ render: ({ field, fieldState }) => (
+
+
+ {fieldState.error?.message}
+
+ ),
+ });
+
+ await user.tab();
+ await user.tab();
+
+ expect(await screen.findByText("You must accept the terms.")).toBeDefined();
+ });
+ });
});
diff --git a/packages/core/src/components/combobox-standalone.test.tsx b/packages/core/src/components/combobox-standalone.test.tsx
index 2faecd03..4decb597 100644
--- a/packages/core/src/components/combobox-standalone.test.tsx
+++ b/packages/core/src/components/combobox-standalone.test.tsx
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { renderRHFForm } from "../../tests/rhf-test-utils";
+import { Field } from "./field";
import { Combobox } from "./combobox-standalone";
afterEach(() => {
@@ -175,6 +177,72 @@ describe("Combobox (standalone, multiple)", () => {
});
});
+describe("Combobox (standalone, RHF integration)", () => {
+ it("submits the selected item and resets back to empty", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ renderRHFForm({
+ defaultValues: { fruit: null as string | null },
+ name: "fruit",
+ onSubmit,
+ render: ({ field, fieldState }) => (
+
+ Fruit
+
+ {fieldState.error?.message}
+
+ ),
+ });
+
+ const input = screen.getByRole("combobox", { name: "Fruit" });
+ await user.click(input);
+ await user.type(input, "Ban");
+ await waitFor(() => expect(screen.getByText("Banana")).toBeDefined());
+ await user.click(screen.getByText("Banana"));
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ fruit: "Banana" });
+ });
+
+ await user.click(screen.getByRole("button", { name: "Reset" }));
+ expect((screen.getByRole("combobox", { name: "Fruit" }) as HTMLInputElement).value).toBe("");
+ });
+
+ it("surfaces required errors on submit", async () => {
+ const user = userEvent.setup();
+
+ renderRHFForm({
+ defaultValues: { fruit: null as string | null },
+ name: "fruit",
+ rules: { required: "Fruit is required" },
+ render: ({ field, fieldState }) => (
+
+ Fruit
+
+ {fieldState.error?.message}
+
+ ),
+ });
+
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ expect(await screen.findByText("Fruit is required")).toBeDefined();
+ });
+});
+
describe("Combobox.Async (standalone)", () => {
it("renders and calls fetcher on input", async () => {
const fetcher = vi.fn().mockResolvedValue(["Result A", "Result B"]);
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 d5a95433..0e8ba99a 100644
--- a/packages/core/src/components/date-field/date-field.test.tsx
+++ b/packages/core/src/components/date-field/date-field.test.tsx
@@ -2,30 +2,15 @@ import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import {
- CalendarDate,
- parseDate,
- today,
- getLocalTimeZone,
- startOfWeek,
- endOfWeek,
- isSameDay,
-} from "@internationalized/date";
+import { CalendarDate, parseDate, isSameDay } from "@internationalized/date";
+import { renderRHFForm } from "../../../tests/rhf-test-utils";
import { createAppShellWrapper } from "../../../tests/test-utils";
import { DateField, DatePicker } from "./date-field";
-// This suite is the parity contract shared with the react-aria implementation:
-// it asserts public behaviour + the DOM accessibility contract (spinbutton
-// segments, role="grid" cells with data-* state attributes, role="dialog"
-// popover), not implementation details.
-
afterEach(() => {
cleanup();
});
-// ─── Helpers ──────────────────────────────────────────────────────────────────
-
-/** Find day cells inside a calendar grid (not nav buttons). */
function getCalendarCells() {
return screen.getAllByRole("button", { hidden: true }).filter((c) => c.closest('[role="grid"]'));
}
@@ -36,73 +21,97 @@ function getEnabledCalendarCells() {
);
}
-// ─── Snapshots ──────────────────────────────────────────────────────────────
-// Visual-structure snapshots per the add-component convention. Inputs are
-// pinned (fixed `defaultValue`, no live "today" in view) so output is stable.
+function ControlledField({ onChange }: { onChange: (v: unknown) => void }) {
+ const [value, setValue] = useState
(null);
+
+ return (
+ <>
+ {
+ setValue(nextValue as CalendarDate | null);
+ onChange(nextValue);
+ }}
+ />
+ elsewhere
+ >
+ );
+}
describe("snapshots", () => {
it("DateField", () => {
- const { container } = render( );
+ const { container } = render( );
expect(container.innerHTML).toMatchSnapshot();
});
- it("DateField — invalid with error", () => {
+ it("DateField — manually labelled + described", () => {
const { container } = render(
- ,
+ <>
+
+ Date
+
+
+ Pick a date
+ Required
+ >,
);
expect(container.innerHTML).toMatchSnapshot();
});
it("DatePicker — closed", () => {
- const { container } = render( );
+ const { container } = render( );
expect(container.innerHTML).toMatchSnapshot();
});
});
-// ─── DateField ─────────────────────────────────────────────────────────────────
-
describe("DateField", () => {
- it("renders with a label", () => {
- render( );
- expect(screen.getByText("Invoice date")).toBeDefined();
+ it("renders standalone with an aria-label", () => {
+ render( );
+ expect(screen.getAllByRole("spinbutton").length).toBeGreaterThan(0);
});
- it("renders date segments", () => {
- render( );
- // segments are exposed as spinbuttons for day, month, year
- const segments = screen.getAllByRole("spinbutton");
- expect(segments.length).toBeGreaterThan(0);
- });
-
- it("renders with description", () => {
- render( );
- expect(screen.getByText("Pick any date")).toBeDefined();
- });
+ it("focuses the first segment when a linked external label is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ <>
+
+ Date
+
+
+ >,
+ );
- it("renders error message when isInvalid", () => {
- render( );
- expect(screen.getByText("Required")).toBeDefined();
+ await user.click(screen.getByText("Date"));
+ expect(document.activeElement?.getAttribute("role")).toBe("spinbutton");
});
- it("renders as disabled", () => {
- render( );
- const groups = screen.getAllByRole("group");
- const disabledGroup = groups.find((g) => g.getAttribute("aria-disabled") === "true");
- expect(disabledGroup).toBeDefined();
- });
+ it("supports manual aria-describedby and invalid state", () => {
+ render(
+ <>
+ Date
+
+ Pick a date
+ Required
+ >,
+ );
- it("resolves LocalizedString label with language fallback", () => {
- render( (locale === "ja" ? "日付" : "Date")} />);
- // default locale resolves to english
- expect(screen.getByText("Date")).toBeDefined();
+ const group = screen.getByRole("group");
+ expect(group.getAttribute("aria-describedby")).toBe("date-help date-error");
+ expect(group.hasAttribute("data-invalid")).toBe(true);
+ expect(screen.getByText("Required")).toBeDefined();
});
it("fires onChange once a complete date is typed across the segments", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
- // Fill every segment by aria-label (order-independent across locales).
await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("06");
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -111,19 +120,15 @@ describe("DateField", () => {
await user.keyboard("2025");
await waitFor(() => {
- expect(onChange).toHaveBeenCalled();
- const last = onChange.mock.calls.at(-1)?.[0];
- expect(last?.toString()).toBe("2025-06-15");
+ expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-15");
});
});
- it("auto-advances across segments as a full date is typed (no explicit tabbing)", async () => {
+ it("auto-advances across segments as a full date is typed", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
- // Locale here is "en" → MM/DD/YYYY. Typing carries across segments:
- // "02" fills+advances month, "15" fills+advances day, "2025" fills year.
await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("02152025");
@@ -135,7 +140,7 @@ describe("DateField", () => {
it("accumulates a non-leading-zero entry (2 then 9 → 29, not 9)", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("12");
@@ -149,13 +154,11 @@ describe("DateField", () => {
});
});
- it("accepts day 31 typed before a month (day max isn't tied to the current month)", async () => {
+ it("accepts day 31 typed before a month", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
- // Type the day first — "31" must not collapse to "1" just because the
- // current (anchor) month happens to have 30/28 days.
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("31");
await user.click(screen.getByRole("spinbutton", { name: "month" }));
@@ -173,19 +176,17 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
- // Enter 30 / 02 / 2026 (Feb 2026 has 28 days) — invalid until blur.
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("30");
await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("02");
await user.click(screen.getByRole("spinbutton", { name: "year" }));
await user.keyboard("2026");
- // Blur the field.
await user.click(screen.getByRole("button", { name: "elsewhere" }));
await waitFor(() => {
@@ -198,7 +199,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -208,7 +209,7 @@ describe("DateField", () => {
await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("02");
await user.click(screen.getByRole("spinbutton", { name: "year" }));
- await user.keyboard("2024"); // leap year
+ await user.keyboard("2024");
await user.click(screen.getByRole("button", { name: "elsewhere" }));
await waitFor(() => {
@@ -216,62 +217,16 @@ describe("DateField", () => {
});
});
- // A controlled field round-trips every emit through the parent's `value`, so
- // these guard the clamp against external-sync interference (the uncontrolled
- // cases above don't exercise that path).
- function ControlledField({ onChange }: { onChange: (v: unknown) => void }) {
- const [v, setV] = useState(null);
- return (
- <>
- {
- setV(nv as CalendarDate | null);
- onChange(nv);
- }}
- />
- elsewhere
- >
- );
- }
-
- it("clamps an impossible day on blur even when controlled (29/02/2026)", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render( );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("29");
- await user.click(screen.getByRole("spinbutton", { name: "month" }));
- await user.keyboard("02");
- await user.click(screen.getByRole("spinbutton", { name: "year" }));
- await user.keyboard("2026"); // not a leap year → Feb has 28 days
- await user.click(screen.getByRole("button", { name: "elsewhere" }));
-
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28");
- });
- });
-
- it("re-clamps when editing the year turns a valid leap day invalid (29 Feb 2024 → 2026)", async () => {
+ it("clamps an impossible day on blur even when controlled", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render( );
- // First enter a genuinely valid leap-year date.
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("29");
await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("02");
await user.click(screen.getByRole("spinbutton", { name: "year" }));
- await user.keyboard("2024");
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2024-02-29");
- });
-
- // Now change the year to a non-leap year — 29 Feb no longer exists.
- await user.click(screen.getByRole("spinbutton", { name: "year" }));
await user.keyboard("2026");
await user.click(screen.getByRole("button", { name: "elsewhere" }));
@@ -280,666 +235,269 @@ describe("DateField", () => {
});
});
- it("auto-corrects an impossible day as soon as the year is complete (no blur)", async () => {
+ it("calls onBlur once when focus leaves the whole group", async () => {
const user = userEvent.setup();
- const onChange = vi.fn();
- render( );
-
- // 29 typed before the month (allowed), then Feb 2026 (28 days). The moment
- // the 4-digit year lands, the day self-corrects — without leaving the field.
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("29");
- await user.click(screen.getByRole("spinbutton", { name: "month" }));
- await user.keyboard("02");
- await user.click(screen.getByRole("spinbutton", { name: "year" }));
- await user.keyboard("2026");
-
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28");
- });
- });
-
- it("re-corrects on year completion when a leap day turns invalid (no blur)", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render( );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("29");
- await user.click(screen.getByRole("spinbutton", { name: "month" }));
- await user.keyboard("02");
- await user.click(screen.getByRole("spinbutton", { name: "year" }));
- await user.keyboard("2024");
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2024-02-29");
- });
-
- // Retype the year to a non-leap year — corrects on completion, no blur.
- await user.click(screen.getByRole("spinbutton", { name: "year" }));
- await user.keyboard("2026");
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28");
- });
- });
-
- // On-blur backfill: a provided day (finest unit) lets the coarser fields
- // default to the current month/year. The anchor for a bare DateField is
- // today("UTC"), so expectations are derived from that same basis.
- it("backfills the current month + year when only the day is entered, on blur", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
+ const onBlur = vi.fn();
render(
<>
-
+
elsewhere
>,
);
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("2");
- await user.click(screen.getByRole("button", { name: "elsewhere" }));
-
- const expected = today("UTC").set({ day: 2 });
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected.toString());
- });
- });
-
- it("backfills the current year when day + month are entered, on blur", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- <>
-
- elsewhere
- >,
- );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("02");
await user.click(screen.getByRole("spinbutton", { name: "month" }));
- await user.keyboard("08");
await user.click(screen.getByRole("button", { name: "elsewhere" }));
- const expected = today("UTC").set({ month: 8, day: 2 });
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected.toString());
- });
- });
-
- it("never backfills the day — a lone year entry emits nothing on blur", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- <>
-
- elsewhere
- >,
- );
-
- await user.click(screen.getByRole("spinbutton", { name: "year" }));
- await user.keyboard("2025");
- await user.click(screen.getByRole("button", { name: "elsewhere" }));
-
- // The day is the trigger for backfill; without it we never guess a value.
- expect(onChange.mock.calls.some(([v]) => v != null)).toBe(false);
+ expect(onBlur).toHaveBeenCalledTimes(1);
});
it("clears a controlled DateField when the value is reset to null", () => {
const { rerender } = render(
{}}
/>,
);
expect(screen.getByRole("spinbutton", { name: "day" }).textContent).toBe("15");
- // Parent clears the field: value={null} is controlled-empty, not uncontrolled.
- rerender( {}} />);
+ rerender( {}} />);
expect(screen.getByRole("spinbutton", { name: "day" }).getAttribute("aria-valuetext")).toBe(
"Empty",
);
});
- it("sets aria-required on the segments when isRequired", () => {
- render( );
- const day = screen.getByRole("spinbutton", { name: "day" });
- expect(day.getAttribute("aria-required")).toBe("true");
- });
-});
-
-// ─── Keyboard: whole-date shortcuts (QBO-style) ──────────────────────────────
-// These fire from any focused date segment and set the entire date in one go.
-// A bare DateField anchors on today("UTC"), so "today"/empty-field expectations
-// derive from that same basis. Locale here is "en" (weeks start Sunday).
-
-describe("DateField keyboard shortcuts", () => {
- async function lastEmit(onChange: ReturnType, expected: string) {
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected);
- });
- }
-
- it("'t' jumps to today (case-insensitive)", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render( );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("T"); // upper-case → same as "t"
-
- await lastEmit(onChange, today("UTC").toString());
- });
-
- it("'m' goes to the start of the entered month", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- ,
+ it("marks the segments required when isRequired is set", () => {
+ render( );
+ expect(screen.getByRole("spinbutton", { name: "day" }).getAttribute("aria-required")).toBe(
+ "true",
);
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("m");
-
- await lastEmit(onChange, "2025-06-01");
});
- it("'m' falls back to the start of the current month when no date is entered", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render( );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("m");
-
- await lastEmit(onChange, today("UTC").set({ day: 1 }).toString());
- });
-
- it("'h' goes to the end of the entered month", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- ,
- );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("h");
-
- await lastEmit(onChange, "2025-06-30");
- });
-
- it("'y' and 'r' jump to the start / end of the entered year", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- ,
- );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("y");
- await lastEmit(onChange, "2025-01-01");
-
- await user.keyboard("r");
- await lastEmit(onChange, "2025-12-31");
- });
-
- it("'w' and 'k' jump to the start / end of the week (locale-aware)", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- const start = new CalendarDate(2025, 6, 18); // a Wednesday
- render( );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("w");
- await lastEmit(onChange, startOfWeek(start, "en").toString());
-
- await user.keyboard("k");
- await lastEmit(onChange, endOfWeek(start, "en").toString());
- });
-
- it("'-' decrements a day and rolls back over the month boundary", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- ,
- );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("-");
-
- await lastEmit(onChange, "2025-02-28");
- });
-
- it("'-' rolls back over the year boundary (1 Jan → 31 Dec of the prior year)", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- ,
- );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("-");
-
- await lastEmit(onChange, "2024-12-31");
- });
-
- it("'=' and '+' both increment a day and roll over the year boundary", async () => {
- const user = userEvent.setup();
-
- const onChangeEq = vi.fn();
- const { unmount } = render(
- ,
- );
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("="); // unshifted plus
- await lastEmit(onChangeEq, "2026-01-01");
- unmount();
-
- const onChangePlus = vi.fn();
- render(
- ,
- );
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("+"); // shifted plus
- await lastEmit(onChangePlus, "2026-01-01");
- });
-
- it("'-' / '+' step from today when the field is empty", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render( );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("+");
-
- await lastEmit(onChange, today("UTC").add({ days: 1 }).toString());
- });
-
- it("expands a 2-digit year to the 2000s on blur ('26' → 2026)", async () => {
+ it("flags a typed date before minValue invalid, but still emits it", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- <>
-
- elsewhere
- >,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "month" }));
- await user.keyboard("06");
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("15");
- await user.click(screen.getByRole("spinbutton", { name: "year" }));
- await user.keyboard("26");
- await user.click(screen.getByRole("button", { name: "elsewhere" }));
-
- await lastEmit(onChange, "2026-06-15");
- });
-
- it("expands a 2-digit year as soon as the year segment is left (still inside the field)", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render( );
-
- await user.click(screen.getByRole("spinbutton", { name: "month" }));
- await user.keyboard("06");
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("15");
- await user.click(screen.getByRole("spinbutton", { name: "year" }));
- await user.keyboard("26");
- // Move focus back to a sibling segment — never leaving the field/group. The
- // year should still expand (mirrors tabbing to the calendar icon).
- await user.click(screen.getByRole("spinbutton", { name: "month" }));
+ await user.keyboard("06052025");
- await lastEmit(onChange, "2026-06-15");
+ await waitFor(() => {
+ expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-05");
+ });
+ expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true);
});
- // Regression: the field path must honour DatePicker's firstDayOfWeek for w/k,
- // not silently fall back to the locale default (which would disagree with the
- // calendar path). en-US defaults to Sunday; firstDayOfWeek="mon" forces Monday.
- it("honours firstDayOfWeek for 'w' in the field path (popover closed)", async () => {
+ it("flags a typed date invalid when isDateUnavailable rejects it", async () => {
const user = userEvent.setup();
- const onChange = vi.fn();
render(
- ,
+ d.toString() === "2025-06-12"} />,
);
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("w"); // Monday-start week → 16 Jun (not 15 Jun, the Sunday)
+ await user.click(screen.getByRole("spinbutton", { name: "month" }));
+ await user.keyboard("06122025");
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-16");
- });
- expect(screen.queryByRole("dialog")).toBeNull();
+ expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true);
});
- // The standalone DateField (no calendar) also exposes firstDayOfWeek so a
- // consumer in a non-default-week-start locale can steer the w/k shortcuts.
it("honours firstDayOfWeek for 'w' in a standalone DateField", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("w"); // Monday-start week → 16 Jun (not 15 Jun, the Sunday)
+ await user.keyboard("w");
await waitFor(() => {
expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-16");
});
});
-
- it("flags a shortcut target invalid when it lands outside minValue / maxValue (no clamp)", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- ,
- );
- const group = screen.getByRole("group");
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- 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.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 () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render( );
-
- // en order: month / day / year. Type a single "1" into the month, then "/"
- // to declare "that's the whole month" and move on to the day.
- await user.click(screen.getByRole("spinbutton", { name: "month" }));
- await user.keyboard("1/15");
- await user.keyboard("2025");
-
- await lastEmit(onChange, "2025-01-15");
- });
});
-// ─── DatePicker ───────────────────────────────────────────────────────────────
-
describe("DatePicker", () => {
- it("renders with a label", () => {
- render( );
- expect(screen.getByText("Ship date")).toBeDefined();
- });
-
- it("renders the calendar trigger button", () => {
- render( );
- const btn = screen.getAllByRole("button").find((b) => !b.closest('[role="grid"]'));
- expect(btn).toBeDefined();
+ it("renders standalone with an aria-label", () => {
+ render( );
+ expect(screen.getByRole("button", { name: "Open calendar" })).toBeDefined();
});
it("opens the popover when the trigger is clicked", async () => {
const user = userEvent.setup();
- render( );
+ render( );
- const triggerBtn = screen.getAllByRole("button")[0];
- await user.click(triggerBtn);
+ await user.click(screen.getByRole("button", { name: "Open calendar" }));
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeDefined();
});
});
- it("shows a calendar grid in the popover", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getAllByRole("button")[0]);
-
- await waitFor(() => {
- expect(screen.getByRole("grid")).toBeDefined();
- });
- });
-
it("fires onChange when a calendar date cell is clicked", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
- await user.click(screen.getAllByRole("button")[0]);
+ await user.click(screen.getByRole("button", { name: "Open calendar" }));
await waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
const enabled = getEnabledCalendarCells();
- if (enabled.length > 0) {
- await user.click(enabled[0]);
- await waitFor(() => {
- expect(onChange).toHaveBeenCalled();
- expect(onChange.mock.calls[0][0]).not.toBeNull();
- });
- }
+ await user.click(enabled[0]);
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalled();
+ expect(onChange.mock.calls[0][0]).not.toBeNull();
+ });
});
it("renders cells with data-disabled when minValue is set", async () => {
const user = userEvent.setup();
- const tomorrow = today(getLocalTimeZone()).add({ days: 1 });
- render( );
+ render(
+ ,
+ );
- await user.click(screen.getAllByRole("button")[0]);
+ await user.click(screen.getByRole("button", { name: "Open calendar" }));
await waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
- const disabled = getCalendarCells().filter((c) => c.hasAttribute("data-disabled"));
- expect(disabled.length).toBeGreaterThan(0);
+ expect(getCalendarCells().some((cell) => cell.hasAttribute("data-disabled"))).toBe(true);
});
it("renders cells with data-unavailable when isDateUnavailable returns true", async () => {
const user = userEvent.setup();
- render( true} />);
+ render(
+ isSameDay(date, new CalendarDate(2025, 6, 12))}
+ />,
+ );
- await user.click(screen.getAllByRole("button")[0]);
+ await user.click(screen.getByRole("button", { name: "Open calendar" }));
await waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
- const unavailable = getCalendarCells().filter((c) => c.hasAttribute("data-unavailable"));
- expect(unavailable.length).toBeGreaterThan(0);
+ expect(getCalendarCells().some((cell) => cell.hasAttribute("data-unavailable"))).toBe(true);
});
- it("renders error message when errorMessage is set", () => {
- render( );
- expect(screen.getByText("Date is required")).toBeDefined();
- });
-
- it("clears a controlled DatePicker when the value is reset to null", () => {
- const { rerender } = render(
+ it("flags a field shortcut invalid when it lands before minValue (popover closed)", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ render(
{}}
+ aria-label="Date"
+ defaultValue={new CalendarDate(2025, 6, 15)}
+ minValue={new CalendarDate(2025, 6, 10)}
+ onChange={onChange}
/>,
);
- expect(screen.getByRole("spinbutton", { name: "day" }).textContent).toBe("15");
- rerender( {}} />);
- expect(screen.getByRole("spinbutton", { name: "day" }).getAttribute("aria-valuetext")).toBe(
- "Empty",
- );
+ await user.click(screen.getByRole("spinbutton", { name: "day" }));
+ await user.keyboard("y");
+
+ await waitFor(() => {
+ 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();
+ });
+
+ it("supports manual invalid state", () => {
+ render( );
+ expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true);
});
it("localizes segment names and chrome from the AppShell locale (ja)", () => {
- render( , { wrapper: createAppShellWrapper("ja") });
- // Segment accessible name: month → 月.
+ render( , { wrapper: createAppShellWrapper("ja") });
expect(screen.getByRole("spinbutton", { name: "月" })).toBeDefined();
- // Popover trigger aria-label is localized too.
expect(screen.getByRole("button", { name: "カレンダーを開く" })).toBeDefined();
- // Empty segments announce the localized placeholder.
expect(screen.getByRole("spinbutton", { name: "月" }).getAttribute("aria-valuetext")).toBe(
"未入力",
);
});
});
-// ─── Keyboard: popover focus ─────────────────────────────────────────────────
-
-describe("DatePicker keyboard", () => {
- it("moves focus into the calendar grid when the popover opens", async () => {
+describe("RHF integration", () => {
+ it("submits DateField values and resets back to empty", async () => {
const user = userEvent.setup();
- render( );
- await user.click(screen.getAllByRole("button")[0]);
- await waitFor(() => {
- expect(document.activeElement?.closest('[role="grid"]')).not.toBeNull();
- });
- });
-
- it("opens the calendar with Alt+↓ from a focused segment", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("spinbutton", { name: "day" }));
- await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
+ const onSubmit = vi.fn();
- await waitFor(() => {
- expect(screen.getByRole("dialog")).toBeDefined();
+ renderRHFForm({
+ defaultValues: { invoiceDate: null as CalendarDate | null },
+ name: "invoiceDate",
+ onSubmit,
+ render: ({ field }) => (
+
+ ),
});
- });
-
- // While the popover is open, focus is in the grid — the shortcuts move the
- // highlight (like the arrows), and Enter confirms. This is the calendar path,
- // not the segment path.
- it("a shortcut moves the calendar highlight while the popover is open", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- ,
- );
- await user.click(screen.getAllByRole("button")[0]); // open (focus → 15 Jun)
- await waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
-
- await user.keyboard("m"); // month start → 1 Jun
- await user.keyboard("{Enter}"); // confirm
+ await user.click(screen.getByRole("spinbutton", { name: "month" }));
+ await user.keyboard("06152025");
+ await user.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-01");
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(onSubmit.mock.calls[0][0].invoiceDate?.toString()).toBe("2025-06-15");
});
- });
- it("clamps an open-popover shortcut to minValue (can't highlight before the min)", async () => {
- const user = userEvent.setup();
- const onChange = vi.fn();
- render(
- ,
+ await user.click(screen.getByRole("button", { name: "Reset" }));
+ expect(screen.getByRole("spinbutton", { name: "day" }).getAttribute("aria-valuetext")).toBe(
+ "Empty",
);
+ });
- await user.click(screen.getAllByRole("button")[0]);
- await waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
-
- await user.keyboard("y"); // year start (1 Jan) is before min → clamps to 10 Jun
- await user.keyboard("{Enter}");
-
- await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-10");
+ it("surfaces required errors for DatePicker and submits once a date is chosen", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ renderRHFForm({
+ defaultValues: { deliveryDate: null as CalendarDate | null },
+ name: "deliveryDate",
+ rules: { required: "Delivery date is required" },
+ onSubmit,
+ render: ({ field, fieldState }) => (
+ <>
+
+ {fieldState.error && {fieldState.error.message}
}
+ >
+ ),
});
- });
- 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(
- ,
- );
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ expect(await screen.findByText("Delivery date is required")).toBeDefined();
+ expect(onSubmit).not.toHaveBeenCalled();
- // Focus a segment (not the trigger) — the popover stays closed, so this is
- // 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 user.click(screen.getByRole("button", { name: "Open calendar" }));
+ await waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
+ await user.click(getEnabledCalendarCells()[0]);
+ await user.click(screen.getByRole("button", { name: "Submit" }));
await waitFor(() => {
- expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-01-01");
+ expect(onSubmit).toHaveBeenCalledTimes(1);
+ expect(onSubmit.mock.calls[0][0].deliveryDate).not.toBeNull();
});
- 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 4b81e872..6790fc32 100644
--- a/packages/core/src/components/date-field/date-field.tsx
+++ b/packages/core/src/components/date-field/date-field.tsx
@@ -1,44 +1,57 @@
-import { useCallback, useId, useRef, useState } from "react";
+import {
+ forwardRef,
+ useCallback,
+ useMemo,
+ useRef,
+ useState,
+ type ForwardedRef,
+ type ReactElement,
+ type Ref,
+} from "react";
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 DateFieldInvalidReason,
- type Granularity,
- type HourCycle,
-} from "./use-date-field-state";
+import { useDateFieldState, 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 {
- DateInputGroup,
- DatePopover,
- DatePickerPopoverTrigger,
- DatePickerLabel,
- DatePickerDescription,
- DatePickerError,
-} from "./date-input-group";
+import { DateInputGroup, DatePopover, DatePickerPopoverTrigger } from "./date-input-group";
import { useDateFieldT } from "./i18n";
/**
- * Public, closed-API date components — the @internationalized/date + Base UI
- * implementation. Same surface as the react-aria variant; only the internals
- * differ. Consumers never see Base UI or the date engines.
+ * Public date controls.
+ *
+ * These are standalone composite widgets built on plain accessible markup and a
+ * proxy input for form value / native validity. They intentionally do not hook
+ * into Base UI's internal Field/Form wiring.
*/
-// 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,
+ reason: "range" | "unavailable" | null | undefined,
): "dateUnavailable" | "dateOutOfRange" | null {
if (reason === "unavailable") return "dateUnavailable";
if (reason === "range") return "dateOutOfRange";
return null;
}
-// ─── Small controlled-state helper ────────────────────────────────────────────
+function assignRef(ref: Ref | undefined, value: T | null) {
+ if (!ref) return;
+ if (typeof ref === "function") {
+ ref(value);
+ return;
+ }
+ ref.current = value;
+}
+
+function useProxyInputRef(forwardedRef: Ref | undefined, customValidity: string) {
+ return useCallback(
+ (node: HTMLInputElement | null) => {
+ if (node) node.setCustomValidity(customValidity);
+ assignRef(forwardedRef, node);
+ },
+ [customValidity, forwardedRef],
+ );
+}
+
function useControlledState(
controlled: V | undefined,
defaultValue: V,
@@ -57,101 +70,84 @@ function useControlledState(
return [value, set];
}
-// ─── Shared prop types (names unchanged from react-aria) ──────────────────────
-
-interface DateFieldMetaProps {
- label?: LocalizedString;
- description?: LocalizedString;
- errorMessage?: LocalizedString;
- className?: string;
-}
-
-interface DateBehaviorProps {
+interface DateControlProps {
value?: T | null;
defaultValue?: T | null;
onChange?: (value: T | null) => void;
+ onBlur?: () => void;
granularity?: Granularity;
minValue?: DateValue;
maxValue?: DateValue;
- isDateUnavailable?: (date: DateValue) => boolean;
+ isDateUnavailable?: (date: T) => boolean;
isDisabled?: boolean;
isReadOnly?: boolean;
isRequired?: boolean;
isInvalid?: boolean;
autoFocus?: boolean;
hourCycle?: HourCycle;
- hideTimeZone?: boolean;
placeholderValue?: DateValue;
- /**
- * First day of the week (0 = Sunday … 6 = Saturday); defaults to the locale.
- * Only affects the `w`/`k` (start/end of week) keyboard shortcuts here.
- */
firstDayOfWeek?: FirstDayOfWeek;
name?: string;
- /** Accessible name when no visible `label` is provided (e.g. a compact filter input). */
+ className?: string;
+ id?: string;
+ /** Accessible name when there is no visible label. */
"aria-label"?: string;
+ /** ID of the element(s) that label the control. */
+ "aria-labelledby"?: string;
+ /** ID of the element(s) that describe the control. */
+ "aria-describedby"?: string;
/** BCP-47 locale override; defaults to the AppShell formatting locale. */
locale?: string;
}
-export type DateFieldProps = DateFieldMetaProps &
- DateBehaviorProps;
+export type DateFieldProps = DateControlProps;
-export type DatePickerProps = DateFieldProps & {
+export type DatePickerProps = DateControlProps & {
/** IANA timezone; defaults to the AppShell `timeZone`. */
timeZone?: string;
};
-// ─── DateField ────────────────────────────────────────────────────────────────
-
/**
* A segmented date/time input field with no popover.
*
- * @example
- * ```tsx
- * import { DateField } from "@tailor-platform/app-shell";
- *
- *
- *
- * ```
+ * Provide an accessible name with `aria-label` or `aria-labelledby`. For
+ * visible labels / descriptions / errors, wire them manually with standard
+ * HTML + ARIA attributes.
*/
-function DateField({
- label,
- description,
- errorMessage,
- className,
- locale: localeProp,
- value,
- defaultValue,
- onChange,
- granularity,
- hourCycle,
- placeholderValue,
- minValue,
- maxValue,
- isDateUnavailable,
- isDisabled,
- isReadOnly,
- isInvalid,
- isRequired,
- autoFocus,
- firstDayOfWeek,
- name,
- "aria-label": ariaLabel,
-}: DateFieldProps) {
- const { locale: shellLocale, language } = useResolvedLocale();
+const DateField = forwardRef(function DateField(
+ {
+ id,
+ className,
+ locale: localeProp,
+ value,
+ defaultValue,
+ onChange,
+ onBlur,
+ granularity,
+ minValue,
+ maxValue,
+ isDateUnavailable,
+ isDisabled,
+ isReadOnly,
+ isRequired,
+ isInvalid,
+ hourCycle,
+ placeholderValue,
+ autoFocus,
+ firstDayOfWeek,
+ name,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledby,
+ "aria-describedby": ariaDescribedby,
+ }: DateFieldProps,
+ ref: ForwardedRef,
+) {
+ const { locale: shellLocale } = useResolvedLocale();
const resolvedLocale = localeProp ?? shellLocale;
- const resolve = buildLocaleResolver(language);
+ const groupRef = useRef(null);
const t = useDateFieldT();
- const labelId = useId();
- const descId = useId();
- const errId = useId();
-
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
- // field with `value={null}` would be treated as uncontrolled and ignored.
value,
defaultValue,
onChange: onChange as (v: DateValue | null) => void,
@@ -159,31 +155,42 @@ function DateField({
locale: resolvedLocale,
hourCycle,
placeholderValue,
- // 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.
+ isDateUnavailable: isDateUnavailable as ((date: DateValue) => boolean) | undefined,
firstDayOfWeek,
isReadOnly,
});
- 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 localValidationMessage = useMemo(() => {
+ const key = invalidMessageKey(state.invalidReason);
+ return key ? t(key) : "";
+ }, [state.invalidReason, t]);
+ const derivedInvalid = !!isInvalid || !!localValidationMessage;
+
+ const setProxyRef = useProxyInputRef(ref, localValidationMessage);
- const describedBy = cn(descText && descId, derivedInvalid && shownError && errId) || undefined;
+ const focusFirstSegment = useCallback(() => {
+ const first = groupRef.current?.querySelector('[role="spinbutton"]');
+ first?.focus();
+ }, []);
return (
-
- {labelText &&
{labelText} }
+
+ {}}
+ onFocus={focusFirstSegment}
+ className="astw:pointer-events-none astw:absolute astw:size-px astw:overflow-hidden astw:opacity-0"
+ />
({
isInvalid={derivedInvalid}
isRequired={isRequired}
autoFocus={autoFocus}
- labelId={labelText ? labelId : undefined}
+ ariaLabelledby={ariaLabelledby}
ariaLabel={ariaLabel}
- describedById={describedBy}
+ describedById={ariaDescribedby}
+ groupRef={groupRef}
+ onGroupBlur={onBlur}
/>
- {descText && {descText} }
- {derivedInvalid && shownError && {shownError} }
- {name && }
);
-}
-
-// ─── DatePicker ───────────────────────────────────────────────────────────────
+}) as
(
+ props: DateFieldProps & { ref?: Ref },
+) => ReactElement;
/**
* A date/time input with a popover calendar.
*
- * Value type is driven by `granularity`:
- * - `"day"` (default) → `CalendarDate`
- * - `"hour" | "minute" | "second"` → `CalendarDateTime` (or `ZonedDateTime` when a `timeZone` is set)
- *
- * @example
- * ```tsx
- * import { DatePicker, today, getLocalTimeZone, type CalendarDate } from "@tailor-platform/app-shell";
- *
- * const [date, setDate] = useState(null);
- *
- * ```
+ * Provide an accessible name with `aria-label` or `aria-labelledby`. For
+ * visible labels / descriptions / errors, wire them manually with standard
+ * HTML + ARIA attributes.
*/
-function DatePicker({
- label,
- description,
- errorMessage,
- className,
- locale: localeProp,
- timeZone: timeZoneProp,
- value,
- defaultValue,
- onChange,
- granularity,
- hourCycle,
- placeholderValue,
- minValue,
- maxValue,
- isDateUnavailable,
- isDisabled,
- isReadOnly,
- isInvalid,
- isRequired,
- autoFocus,
- firstDayOfWeek,
- name,
- "aria-label": ariaLabel,
-}: DatePickerProps) {
- const { locale: shellLocale, language } = useResolvedLocale();
+const DatePicker = forwardRef(function DatePicker(
+ {
+ id,
+ className,
+ locale: localeProp,
+ timeZone: timeZoneProp,
+ value,
+ defaultValue,
+ onChange,
+ onBlur,
+ granularity,
+ minValue,
+ maxValue,
+ isDateUnavailable,
+ isDisabled,
+ isReadOnly,
+ isRequired,
+ isInvalid,
+ hourCycle,
+ placeholderValue,
+ autoFocus,
+ firstDayOfWeek,
+ name,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledby,
+ "aria-describedby": ariaDescribedby,
+ }: DatePickerProps,
+ ref: ForwardedRef,
+) {
+ const { locale: shellLocale } = useResolvedLocale();
const shellTz = useTimeZone();
const resolvedLocale = localeProp ?? shellLocale;
const resolvedTz = timeZoneProp ?? shellTz.value;
- 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 [open, setOpen] = useState(false);
const fieldRef = useRef(null);
const [val, setVal] = useControlledState(
- // `null` is controlled-empty; only `undefined` means uncontrolled (see above).
value,
defaultValue ?? null,
onChange as (v: DateValue | null) => void,
@@ -280,18 +272,12 @@ function DatePicker({
onChange: setVal,
granularity,
locale: resolvedLocale,
- // Use the resolved timezone (prop → AppShell → local), matching the calendar
- // below — otherwise the field falls back to UTC for its "today"/anchor while
- // the calendar uses the AppShell zone, and they disagree on defaults.
timeZone: resolvedTz,
hourCycle,
placeholderValue,
- // 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.
+ isDateUnavailable: isDateUnavailable as ((date: DateValue) => boolean) | undefined,
firstDayOfWeek,
isReadOnly,
});
@@ -304,7 +290,7 @@ function DatePicker({
},
minValue,
maxValue,
- isDateUnavailable,
+ isDateUnavailable: isDateUnavailable as ((date: DateValue) => boolean) | undefined,
isDisabled,
isReadOnly,
firstDayOfWeek,
@@ -312,25 +298,39 @@ function DatePicker({
timeZone: resolvedTz,
});
- // 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 localValidationMessage = useMemo(() => {
+ const key = invalidMessageKey(fieldState.invalidReason);
+ return key ? t(key) : "";
+ }, [fieldState.invalidReason, t]);
+ const derivedInvalid = !!isInvalid || !!localValidationMessage;
- const describedBy = cn(descText && descId, derivedInvalid && shownError && errId) || undefined;
- const accessibleName = labelText ?? ariaLabel;
- const popoverAriaLabel = accessibleName
- ? t("chooseDateFor", { name: accessibleName })
- : t("chooseDate");
+ const setProxyRef = useProxyInputRef(ref, localValidationMessage);
+
+ const focusFirstSegment = useCallback(() => {
+ const first = fieldRef.current?.querySelector('[role="spinbutton"]');
+ first?.focus();
+ }, []);
return (
-
- {labelText &&
{labelText} }
+
+ {}}
+ onFocus={focusFirstSegment}
+ className="astw:pointer-events-none astw:absolute astw:size-px astw:overflow-hidden astw:opacity-0"
+ />
({
isInvalid={derivedInvalid}
isRequired={isRequired}
autoFocus={autoFocus}
- labelId={labelText ? labelId : undefined}
+ ariaLabelledby={ariaLabelledby}
ariaLabel={ariaLabel}
- describedById={describedBy}
+ describedById={ariaDescribedby}
groupRef={fieldRef}
trigger={ }
+ onGroupBlur={onBlur}
/>
}
>
-
+
- {descText && {descText} }
- {derivedInvalid && shownError && {shownError} }
- {name && }
);
-}
+}) as
(
+ props: DatePickerProps & { ref?: Ref },
+) => ReactElement;
export { DateField, DatePicker };
diff --git a/packages/core/src/components/date-field/date-input-group.tsx b/packages/core/src/components/date-field/date-input-group.tsx
index 21fa3093..40c75000 100644
--- a/packages/core/src/components/date-field/date-input-group.tsx
+++ b/packages/core/src/components/date-field/date-input-group.tsx
@@ -1,4 +1,13 @@
-import * as React from "react";
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ type ComponentProps,
+ type KeyboardEvent,
+ type ReactNode,
+ type Ref,
+ type RefObject,
+} from "react";
import { Popover } from "@base-ui/react/popover";
import { CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -8,51 +17,15 @@ import { DATE_SHORTCUT_KEYS, type DateShortcut } from "@/lib/date-shortcuts";
import type { Segment } from "./use-date-field-state";
/**
- * Field presentation for the date components — the segmented spinbutton group,
- * its labels/description/error, and the popover wrapper used by `DatePicker`.
- * Built on Base UI primitives (`Popover`) + plain accessible markup, driven by
+ * Field presentation for the date components — the segmented spinbutton group
+ * and the popover wrapper used by `DatePicker`. Built on Base UI primitives
+ * (`Popover`) + plain accessible markup, driven by
* our own `useDateFieldState` engine. Not exported from the package.
*
* Styling mirrors the rest of the library (`astw:` tokens, dark mode, the same
* popover token set as our other Base UI popovers).
*/
-// ─── Field labels / description / error ───────────────────────────────────────
-
-// A composite spinbutton group can't be labelled by a native ,
-// so the label is a referenced via the group's `aria-labelledby` (the
-// APG date-field pattern).
-export function DatePickerLabel({ className, ...props }: React.ComponentProps<"span">) {
- return (
-
- );
-}
-
-export function DatePickerDescription({ className, ...props }: React.ComponentProps<"p">) {
- return (
-
- );
-}
-
-export function DatePickerError({ className, ...props }: React.ComponentProps<"p">) {
- return (
-
- );
-}
-
// ─── Segmented input group ────────────────────────────────────────────────────
const groupClasses = cn(
@@ -93,14 +66,19 @@ interface DateInputGroupProps {
isInvalid?: boolean;
isRequired?: boolean;
autoFocus?: boolean;
- labelId?: string;
+ /** ID of the element(s) that label the group. */
+ ariaLabelledby?: string;
/** Accessible name when there is no visible label (e.g. a compact filter input). */
ariaLabel?: string;
describedById?: string;
className?: string;
- trigger?: React.ReactNode;
+ trigger?: ReactNode;
/** Ref to the group element — used to anchor the popover to the whole field. */
- groupRef?: React.Ref;
+ groupRef?: Ref;
+ /** Called once when focus enters the group from outside. */
+ onGroupFocus?: () => void;
+ /** Called once when focus leaves the group entirely. */
+ onGroupBlur?: () => void;
}
export function DateInputGroup({
@@ -118,27 +96,29 @@ export function DateInputGroup({
isInvalid,
isRequired,
autoFocus,
- labelId,
+ ariaLabelledby,
ariaLabel,
describedById,
className,
trigger,
groupRef,
+ onGroupFocus,
+ onGroupBlur,
}: DateInputGroupProps) {
const t = useDateFieldT();
- const editableRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+ const editableRefs = useRef<(HTMLDivElement | null)[]>([]);
// Digits typed into the currently-focused segment this session. Reset on
// focus; the first digit (count 0) replaces, and the count decides when the
// segment is "full" and should auto-advance.
- const typedCountRef = React.useRef(0);
+ const typedCountRef = useRef(0);
- React.useEffect(() => {
+ useEffect(() => {
if (autoFocus && !isDisabled) editableRefs.current[0]?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Index editable segments for left/right focus movement.
- const editableIndexById = React.useMemo(() => {
+ const editableIndexById = useMemo(() => {
const map = new Map();
let i = 0;
segments.forEach((s, idx) => {
@@ -153,7 +133,7 @@ export function DateInputGroup({
};
const handleKeyDown = (
- e: React.KeyboardEvent,
+ e: KeyboardEvent,
segment: Segment,
editableIndex: number,
) => {
@@ -248,13 +228,16 @@ export function DateInputGroup({
ref={groupRef}
role="group"
data-slot="date-picker-group"
- aria-labelledby={labelId}
- aria-label={labelId ? undefined : ariaLabel}
+ aria-labelledby={ariaLabelledby}
+ aria-label={ariaLabelledby ? undefined : ariaLabel}
aria-describedby={describedById}
aria-disabled={isDisabled || undefined}
data-disabled={isDisabled || undefined}
data-invalid={isInvalid || undefined}
className={cn(groupClasses, className)}
+ onFocus={(e) => {
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) onGroupFocus?.();
+ }}
onBlur={(e) => {
// Leaving the year segment (to a sibling, the calendar icon, or out of
// the field) expands a 1–2 digit year to the 2000s right away — the icon
@@ -264,7 +247,10 @@ export function DateInputGroup({
// Focus left the whole group (not just moved between segments) →
// backfill the current month/year for a partial entry and clamp an
// impossible day.
- if (!e.currentTarget.contains(e.relatedTarget as Node | null)) commitOnBlur();
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
+ commitOnBlur();
+ onGroupBlur?.();
+ }
}}
>
@@ -344,7 +330,7 @@ const triggerClasses = cn(
export function DatePickerPopoverTrigger({
className,
...props
-}: React.ComponentProps
) {
+}: ComponentProps) {
const t = useDateFieldT();
return (
void;
/** The field group — must contain a `DatePickerPopoverTrigger`. */
- field: React.ReactNode;
- children: React.ReactNode;
+ field: ReactNode;
+ children: ReactNode;
ariaLabel?: string;
/**
* Element to position the calendar against. Defaults to the trigger; pass the
* field group so the calendar aligns to the field's edge (not the icon),
* overlapping it horizontally and shifting inward near the viewport edge.
*/
- anchor?: React.RefObject;
+ anchor?: RefObject;
}
export function DatePopover({
diff --git a/packages/core/src/components/field.test.tsx b/packages/core/src/components/field.test.tsx
index 1922394b..926ac639 100644
--- a/packages/core/src/components/field.test.tsx
+++ b/packages/core/src/components/field.test.tsx
@@ -1,5 +1,7 @@
-import { afterEach, describe, expect, it } from "vitest";
-import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderRHFForm } from "../../tests/rhf-test-utils";
import { Field } from "./field";
afterEach(() => {
@@ -120,6 +122,36 @@ describe("Field", () => {
);
expect(container.querySelector("[data-invalid]")).not.toBeNull();
});
+
+ it("works with Controller field and fieldState", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ renderRHFForm({
+ defaultValues: { name: "" },
+ name: "name",
+ rules: { required: "Name is required" },
+ onSubmit,
+ render: ({ field, fieldState }) => (
+
+ Name
+
+ {fieldState.error?.message}
+
+ ),
+ });
+
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ expect(await screen.findByText("Name is required")).toBeDefined();
+ expect(onSubmit).not.toHaveBeenCalled();
+
+ await user.type(screen.getByRole("textbox", { name: "Name" }), "Ada");
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ name: "Ada" });
+ });
+ });
});
describe("Validity", () => {
diff --git a/packages/core/src/components/form.test.tsx b/packages/core/src/components/form.test.tsx
index df7b268f..3578c177 100644
--- a/packages/core/src/components/form.test.tsx
+++ b/packages/core/src/components/form.test.tsx
@@ -1,5 +1,7 @@
-import { afterEach, describe, expect, it } from "vitest";
-import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { Controller, useForm } from "react-hook-form";
import { Form } from "./form";
import { Field } from "./field";
@@ -104,4 +106,50 @@ describe("Form", () => {
);
expect(screen.getByText("Server says invalid")).not.toBeNull();
});
+
+ it("works with react-hook-form handleSubmit", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ function TestForm() {
+ const form = useForm<{ email: string }>({ defaultValues: { email: "" } });
+
+ return (
+
+ );
+ }
+
+ render( );
+
+ await user.type(screen.getByRole("textbox", { name: "Email" }), "ada@example.com");
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith(
+ { email: "ada@example.com" },
+ expect.objectContaining({ _reactName: "onSubmit" }),
+ );
+ });
+
+ await user.click(screen.getByRole("button", { name: "Reset" }));
+ expect((screen.getByRole("textbox", { name: "Email" }) as HTMLInputElement).value).toBe("");
+ });
});
diff --git a/packages/core/src/components/input.test.tsx b/packages/core/src/components/input.test.tsx
index 8878673e..a2b024e6 100644
--- a/packages/core/src/components/input.test.tsx
+++ b/packages/core/src/components/input.test.tsx
@@ -1,5 +1,7 @@
-import { afterEach, describe, expect, it } from "vitest";
-import { cleanup, render } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderRHFForm } from "../../tests/rhf-test-utils";
import { Input } from "./input";
afterEach(() => {
@@ -38,4 +40,60 @@ describe("Input", () => {
expect(container.innerHTML).toMatchSnapshot();
});
});
+
+ describe("RHF integration", () => {
+ it("uses defaultValues, submits changes, and resets", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ renderRHFForm({
+ defaultValues: { email: "ada@example.com" },
+ name: "email",
+ onSubmit,
+ render: ({ field }) => (
+ <>
+ Email
+
+ >
+ ),
+ });
+
+ const input = screen.getByRole("textbox", { name: "Email" }) as HTMLInputElement;
+ expect(input.value).toBe("ada@example.com");
+
+ await user.clear(input);
+ await user.type(input, "grace@example.com");
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ email: "grace@example.com" });
+ });
+
+ await user.click(screen.getByRole("button", { name: "Reset" }));
+ expect(input.value).toBe("ada@example.com");
+ });
+
+ it("surfaces RHF validation errors after blur", async () => {
+ const user = userEvent.setup();
+
+ renderRHFForm({
+ defaultValues: { email: "" },
+ name: "email",
+ mode: "onBlur",
+ rules: { required: "Email is required" },
+ render: ({ field, fieldState }) => (
+ <>
+ Email
+
+ {fieldState.error && {fieldState.error.message}
}
+ >
+ ),
+ });
+
+ await user.click(screen.getByRole("textbox", { name: "Email" }));
+ await user.tab();
+
+ expect(await screen.findByText("Email is required")).toBeDefined();
+ });
+ });
});
diff --git a/packages/core/src/components/select-standalone.test.tsx b/packages/core/src/components/select-standalone.test.tsx
index 792c76f2..838ab5f1 100644
--- a/packages/core/src/components/select-standalone.test.tsx
+++ b/packages/core/src/components/select-standalone.test.tsx
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
+import { renderRHFForm } from "../../tests/rhf-test-utils";
+import { Field } from "./field";
import { Select } from "./select-standalone";
afterEach(() => {
@@ -278,6 +280,70 @@ describe("Select (standalone, grouped)", () => {
});
});
+describe("Select (standalone, RHF integration)", () => {
+ it("submits the selected item and resets to the placeholder", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+
+ renderRHFForm({
+ defaultValues: { fruit: null as string | null },
+ name: "fruit",
+ onSubmit,
+ render: ({ field, fieldState }) => (
+
+ Fruit
+
+ {fieldState.error?.message}
+
+ ),
+ });
+
+ await user.click(screen.getByText("Pick one"));
+ await waitFor(() => expect(screen.getByText("Banana")).toBeDefined());
+ await user.click(screen.getByText("Banana"));
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith({ fruit: "Banana" });
+ });
+
+ await user.click(screen.getByRole("button", { name: "Reset" }));
+ expect(screen.getByText("Pick one")).toBeDefined();
+ });
+
+ it("surfaces required errors on submit", async () => {
+ const user = userEvent.setup();
+
+ renderRHFForm({
+ defaultValues: { fruit: null as string | null },
+ name: "fruit",
+ rules: { required: "Fruit is required" },
+ render: ({ field, fieldState }) => (
+
+ Fruit
+
+ {fieldState.error?.message}
+
+ ),
+ });
+
+ await user.click(screen.getByRole("button", { name: "Submit" }));
+ expect(await screen.findByText("Fruit is required")).toBeDefined();
+ });
+});
+
describe("Select.Async (standalone)", () => {
it("does not enter modal mode when opened", async () => {
const user = userEvent.setup();
diff --git a/packages/core/tests/rhf-test-utils.tsx b/packages/core/tests/rhf-test-utils.tsx
new file mode 100644
index 00000000..f38b15d4
--- /dev/null
+++ b/packages/core/tests/rhf-test-utils.tsx
@@ -0,0 +1,76 @@
+import type { ReactNode } from "react";
+import { render, type RenderOptions, type RenderResult } from "@testing-library/react";
+import {
+ Controller,
+ useForm,
+ type ControllerFieldState,
+ type ControllerRenderProps,
+ type DefaultValues,
+ type FieldValues,
+ type Path,
+ type RegisterOptions,
+ type UseFormReturn,
+} from "react-hook-form";
+
+import { Form } from "@/components/form";
+
+type RHFFormApi = UseFormReturn;
+
+type RenderControlArgs> = {
+ field: ControllerRenderProps;
+ fieldState: ControllerFieldState;
+ form: RHFFormApi;
+};
+
+type RenderRHFFormOptions> = {
+ defaultValues: DefaultValues;
+ name: TName;
+ mode?: "onSubmit" | "onBlur" | "onChange" | "onTouched" | "all";
+ rules?: RegisterOptions;
+ onSubmit?: (values: TFieldValues) => void;
+ render: (args: RenderControlArgs) => ReactNode;
+ renderOptions?: Omit;
+};
+
+type RenderRHFFormResult = RenderResult & {
+ form: () => RHFFormApi;
+};
+
+export function renderRHFForm>({
+ defaultValues,
+ name,
+ mode,
+ rules,
+ onSubmit,
+ render: renderControl,
+ renderOptions,
+}: RenderRHFFormOptions): RenderRHFFormResult {
+ let formApi!: RHFFormApi;
+
+ function Harness() {
+ const form = useForm({ defaultValues, mode });
+ formApi = form;
+
+ return (
+
+ );
+ }
+
+ const result = render( , renderOptions);
+
+ return {
+ ...result,
+ form: () => formApi,
+ };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 64125741..0a9c9025 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -45,6 +45,9 @@ catalogs:
typescript:
specifier: ^5
version: 5.9.3
+ vite:
+ specifier: ^7.3.2
+ version: 7.3.5
vitest:
specifier: ^4.1.6
version: 4.1.7
@@ -315,6 +318,9 @@ importers:
react-dom:
specifier: 'catalog:'
version: 19.2.6(react@19.2.6)
+ react-hook-form:
+ specifier: ^7.71.2
+ version: 7.71.2(react@19.2.6)
tailwindcss:
specifier: 'catalog:'
version: 4.3.0
@@ -2973,12 +2979,6 @@ packages:
os: [linux]
libc: [glibc]
- '@rollup/rollup-linux-x64-gnu@4.60.2':
- resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
- cpu: [x64]
- os: [linux]
- libc: [glibc]
-
'@rollup/rollup-linux-x64-gnu@4.62.3':
resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==}
cpu: [x64]
@@ -8092,9 +8092,6 @@ snapshots:
'@rollup/rollup-linux-s390x-gnu@4.62.3':
optional: true
- '@rollup/rollup-linux-x64-gnu@4.60.2':
- optional: true
-
'@rollup/rollup-linux-x64-gnu@4.62.3':
optional: true
@@ -8618,7 +8615,7 @@ snapshots:
debug: 4.4.3(supports-color@8.1.1)
minimatch: 10.2.5
semver: 7.8.0
- tinyglobby: 0.2.16
+ tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -8743,7 +8740,7 @@ snapshots:
'@vue/shared': 3.5.33
estree-walker: 2.0.2
magic-string: 0.30.21
- postcss: 8.5.18
+ postcss: 8.5.23
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.33':
@@ -9148,11 +9145,11 @@ snapshots:
dependencies:
node-source-walk: 7.0.2
- detective-postcss@7.0.1(postcss@8.5.18):
+ detective-postcss@7.0.1(postcss@8.5.23):
dependencies:
is-url: 1.2.4
- postcss: 8.5.18
- postcss-values-parser: 6.0.2(postcss@8.5.18)
+ postcss: 8.5.23
+ postcss-values-parser: 6.0.2(postcss@8.5.23)
detective-sass@6.0.2:
dependencies:
@@ -9320,7 +9317,7 @@ snapshots:
estree-walker@3.0.3:
dependencies:
- '@types/estree': 1.0.8
+ '@types/estree': 1.0.9
esutils@2.0.3: {}
@@ -9999,7 +9996,7 @@ snapshots:
neverthrow@8.2.0:
optionalDependencies:
- '@rollup/rollup-linux-x64-gnu': 4.60.2
+ '@rollup/rollup-linux-x64-gnu': 4.62.3
next@16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@playwright/test@1.51.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
@@ -10303,11 +10300,11 @@ snapshots:
optionalDependencies:
'@inquirer/prompts': 8.5.2(@types/node@25.9.1)
- postcss-values-parser@6.0.2(postcss@8.5.18):
+ postcss-values-parser@6.0.2(postcss@8.5.23):
dependencies:
color-name: 1.1.4
is-url-superb: 4.0.0
- postcss: 8.5.18
+ postcss: 8.5.23
quote-unquote: 1.0.0
postcss@8.4.31:
@@ -10337,7 +10334,7 @@ snapshots:
detective-amd: 6.1.0
detective-cjs: 6.1.1
detective-es6: 5.0.2
- detective-postcss: 7.0.1(postcss@8.5.18)
+ detective-postcss: 7.0.1(postcss@8.5.23)
detective-sass: 6.0.2
detective-scss: 5.0.2
detective-stylus: 5.0.1
@@ -10345,7 +10342,7 @@ snapshots:
detective-vue2: 2.3.0(supports-color@8.1.1)(typescript@5.9.3)
module-definition: 6.0.2
node-source-walk: 7.0.2
- postcss: 8.5.18
+ postcss: 8.5.23
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
@@ -11135,11 +11132,11 @@ snapshots:
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
- picomatch: 4.0.4
+ picomatch: 4.0.5
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.2
- tinyglobby: 0.2.16
+ tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 7.3.5(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)
why-is-node-running: 2.3.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 34fb6f0b..12569cad 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -50,6 +50,7 @@ minimumReleaseAgeExclude:
- postcss@8.5.18
# Renovate security update: vite@7.3.5
- vite@7.3.5
+ - react-router@7.18.2
# Deny all lifecycle scripts (postinstall, install, preinstall).
# pnpm resolves optionalDependencies correctly so these fallback scripts are unnecessary.