();
@@ -157,7 +170,7 @@ export function DateFieldRow({
segment: Segment,
editableIndex: number,
) => {
- if (segment.type === "literal") return;
+ if (segment.type === "literal" || segment.type === "dayOfWeek") return;
const type = segment.type;
// Alt+↓ opens the calendar popover (APG date-picker pattern + QBO). No-op on
@@ -262,6 +275,22 @@ export function DateFieldRow({
);
}
+ if (segment.type === "dayOfWeek") {
+ // Derived from the composed date, so read-only. Hidden from assistive
+ // tech: the date segments' `aria-valuetext` carries it instead.
+ return (
+
+ {segment.text}
+
+ );
+ }
const editableIndex = editableIndexById.get(idx)!;
return (
// Editable date segment: the APG spinbutton pattern. A
@@ -288,10 +317,11 @@ export function DateFieldRow({
aria-valuemin={segment.minValue}
aria-valuemax={segment.maxValue}
aria-valuenow={segment.value}
- aria-valuetext={segment.isPlaceholder ? t("empty") : segment.text}
+ aria-valuetext={segmentValueText(segment)}
onFocus={() => {
typedCountRef.current = 0;
}}
+ onBlur={settleEntry}
onKeyDown={(e) => handleKeyDown(e, segment, editableIndex)}
className={cn(
"astw:rounded astw:px-0.5 astw:tabular-nums astw:caret-transparent astw:outline-none",
diff --git a/packages/core/src/components/date-field/date-range-input-group.tsx b/packages/core/src/components/date-field/date-range-input-group.tsx
index a1c98fec..9113fcaa 100644
--- a/packages/core/src/components/date-field/date-range-input-group.tsx
+++ b/packages/core/src/components/date-field/date-range-input-group.tsx
@@ -119,6 +119,7 @@ export function DateRangeInputGroup({
setDayPeriod={start.setDayPeriod}
clearSegment={start.clearSegment}
applyShortcut={start.applyShortcut}
+ settleEntry={start.settleEntry}
autoFocus={autoFocus}
segmentLabel={segmentLabel(t("startDate"))}
onNavigateOut={(edge) => {
@@ -144,6 +145,7 @@ export function DateRangeInputGroup({
setDayPeriod={end.setDayPeriod}
clearSegment={end.clearSegment}
applyShortcut={end.applyShortcut}
+ settleEntry={end.settleEntry}
segmentLabel={segmentLabel(t("endDate"))}
onNavigateOut={(edge) => {
if (edge === "prev") startHandle.current?.focusLast();
diff --git a/packages/core/src/components/date-field/date-range-picker.test.tsx b/packages/core/src/components/date-field/date-range-picker.test.tsx
index c499456d..6c3d3317 100644
--- a/packages/core/src/components/date-field/date-range-picker.test.tsx
+++ b/packages/core/src/components/date-field/date-range-picker.test.tsx
@@ -517,3 +517,27 @@ describe("12-hour time display", () => {
expect(text("end date", "AM/PM")).toBe("PM");
});
});
+
+describe("DateRangePicker dateFormat / showDayOfWeek", () => {
+ const range = { start: new CalendarDate(2025, 12, 19), end: new CalendarDate(2025, 12, 22) };
+
+ it("applies the format and day of the week to both ends", () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole("group").textContent).toBe("2025年12月19日(金)–2025年12月22日(月)");
+ });
+
+ it("takes the AppShell dateInputDateFormat by default", () => {
+ render(, {
+ wrapper: createAppShellWrapper("ja-JP", { dateInputDateFormat: "regional" }),
+ });
+ expect(screen.getByRole("group").textContent).toBe("2025年12月19日–2025年12月22日");
+ });
+});
diff --git a/packages/core/src/components/date-field/date-range-picker.tsx b/packages/core/src/components/date-field/date-range-picker.tsx
index 06f2cd40..16f6d960 100644
--- a/packages/core/src/components/date-field/date-range-picker.tsx
+++ b/packages/core/src/components/date-field/date-range-picker.tsx
@@ -12,7 +12,12 @@ import {
import { toCalendarDate, today, type DateValue } from "@internationalized/date";
import { cn } from "@/lib/utils";
import { useFieldRootContext } from "@base-ui/react/internals/field-root-context";
-import { useResolvedLocale, useTimeZone } from "@/contexts/appshell-context";
+import {
+ useDateInputDateFormat,
+ useResolvedLocale,
+ useTimeZone,
+ type DateInputDateFormat,
+} from "@/contexts/appshell-context";
import {
useDateFieldState,
type DateFieldInvalidReason,
@@ -88,6 +93,14 @@ export type DateRangePickerProps = {
locale?: string;
/** IANA timezone; defaults to the AppShell `timeZone`. */
timeZone?: string;
+ /**
+ * Segment layout for both ends; defaults to the AppShell `dateInputDateFormat`
+ * (itself `"numeric"`, planned to become `"regional"` in a future major
+ * release). See `DatePicker`.
+ */
+ dateFormat?: DateInputDateFormat;
+ /** Show the locale's short day of the week next to each date. */
+ showDayOfWeek?: boolean;
};
/** One-end snapshot the combined funnel reads to synthesize the range state. */
@@ -129,6 +142,8 @@ const DateRangePicker = forwardRef(function DateRangePicker = {
type Fields = Partial>;
export interface Segment {
- type: EditableSegmentType | "literal";
+ /** `dayOfWeek` is read-only, derived from the composed date. */
+ type: EditableSegmentType | "literal" | "dayOfWeek";
/** Display text (locale-formatted value, or placeholder when empty). */
text: string;
isEditable: boolean;
@@ -69,6 +71,8 @@ export interface Segment {
value?: number;
minValue?: number;
maxValue?: number;
+ /** Day-of-week only: the full day name for assistive tech, once settled. */
+ label?: string;
}
/**
@@ -131,6 +135,10 @@ export interface DateFieldStateOptions {
isDateUnavailable?: (date: DateValue) => boolean;
/** Week-start for the `w`/`k` shortcuts; defaults to the locale convention. */
firstDayOfWeek?: FirstDayOfWeek;
+ /** Segment layout; see {@link DateInputDateFormat}. Defaults to `"numeric"`. */
+ dateFormat?: DateInputDateFormat;
+ /** Append the locale's short day of the week as a read-only segment. */
+ showDayOfWeek?: boolean;
isDisabled?: boolean;
isReadOnly?: boolean;
}
@@ -144,6 +152,29 @@ const PLACEHOLDERS: Record = {
second: "––",
dayPeriod: "AM",
};
+const DAY_OF_WEEK_PLACEHOLDER = "––";
+
+const NUMERIC_PART = /^(\d+)(\D*)$/;
+
+/**
+ * The locale's written ("long") date parts, or `null` when that form spells the
+ * month as a word (en "December", de "Dezember") and so can't back numeric
+ * segments. A unit fused onto a number (ko "12월") is split off into a literal.
+ */
+function regionalParts(formatter: DateFormatter, date: Date): Intl.DateTimeFormatPart[] | null {
+ const out: Intl.DateTimeFormatPart[] = [];
+ for (const part of formatter.formatToParts(date)) {
+ if (part.type !== "year" && part.type !== "month" && part.type !== "day") {
+ out.push(part);
+ continue;
+ }
+ const match = NUMERIC_PART.exec(part.value);
+ if (!match) return null;
+ out.push({ type: part.type, value: match[1] });
+ if (match[2]) out.push({ type: "literal", value: match[2] });
+ }
+ return out;
+}
/**
* Snap an impossible day down to the entered month's real length — but only once
@@ -284,6 +315,8 @@ export function useDateFieldState(options: DateFieldStateOptions) {
maxValue,
isDateUnavailable,
firstDayOfWeek,
+ dateFormat = "numeric",
+ showDayOfWeek = false,
isReadOnly,
} = options;
@@ -316,6 +349,15 @@ export function useDateFieldState(options: DateFieldStateOptions) {
}
const fields = internalFields;
+ // The date segment mid-way through typed entry (a first digit that may still
+ // take a second), plus the fields from before that entry began. Until it
+ // settles, the day of the week keeps showing the pre-entry date's (muted), so typing
+ // "25" never flashes the 2nd's day of the week and the field doesn't reflow.
+ const [pendingEntry, setPendingEntry] = useState<{
+ type: EditableSegmentType;
+ fallback: Fields;
+ } | null>(null);
+
const lastEmitted = useRef(controlledValue ?? defaultValue ?? null);
// Anchor for placeholder formatting + sensible increment starting points.
@@ -417,6 +459,7 @@ export function useDateFieldState(options: DateFieldStateOptions) {
const f = intent === "edit" ? clampCompleteDay(next) : next;
const change = buildStateChange(f, intent === "clear" ? "clear" : "edit");
setInternalFields(f);
+ setPendingEntry(null);
onStateChange?.(change);
// While editing, only emit a *complete & valid* value — never `null` for a
@@ -453,6 +496,7 @@ export function useDateFieldState(options: DateFieldStateOptions) {
const nextFields = fieldsFromValue(cv, is12);
lastEmitted.current = cv;
setInternalFields(nextFields);
+ setPendingEntry(null);
onStateChange?.(buildStateChange(nextFields, "external"));
}
}, [buildStateChange, controlledValue, isControlled, is12, onStateChange]);
@@ -495,11 +539,20 @@ export function useDateFieldState(options: DateFieldStateOptions) {
// "02" advances, while "2" still waits for a possible second digit).
const advance = next * 10 > max || digitCount >= SEGMENT_MAX_DIGITS[type];
commit({ ...fields, [type]: next });
+ // Time segments never affect the day of the week, so only date entry can pend.
+ if (!advance && DATE_SEGMENTS.includes(type)) {
+ setPendingEntry((prev) => (prev?.type === type ? prev : { type, fallback: fields }));
+ } else {
+ setPendingEntry(null);
+ }
return { advance };
},
[fields, getLimits, commit, isReadOnly],
);
+ /** Typed entry in the focused segment is over (it lost focus). */
+ const settleEntry = useCallback(() => setPendingEntry(null), []);
+
const setDayPeriod = useCallback(
(pm: boolean) => {
if (isReadOnly) return;
@@ -620,14 +673,11 @@ export function useDateFieldState(options: DateFieldStateOptions) {
// ── Display segments (locale-ordered) ────────────────────────────────────────
// The Intl objects + the locale's part order/separators only depend on
- // locale / granularity / hour-cycle / timezone (via the anchor) — never on
- // `fields`. Build them once here so a keystroke (which only changes `fields`)
- // doesn't spin up a fresh DateFormatter + two NumberFormats + formatToParts.
+ // locale / granularity / hour-cycle / format / day of the week / timezone (via the
+ // anchor) — never on `fields`. Build them once here so a keystroke (which only
+ // changes `fields`) doesn't spin up a fresh DateFormatter + formatToParts.
const segmentFormat = useMemo(() => {
- const formatter = new DateFormatter(locale, {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
+ const shared: Intl.DateTimeFormatOptions = {
...(hasTime
? {
hour: "2-digit",
@@ -636,8 +686,32 @@ export function useDateFieldState(options: DateFieldStateOptions) {
hour12: is12,
}
: {}),
+ ...(showDayOfWeek ? { weekday: "short" } : {}),
...(timeZone ? { timeZone } : {}),
- });
+ };
+ // Format the (always-valid) anchor to get the locale's segment ORDER and the
+ // literal separators. Each editable segment's *text* is then formatted from
+ // its own value, so an in-progress out-of-range value can never build an
+ // invalid date.
+ const probe = anchor.toDate(timeZone ?? "UTC");
+ const parts =
+ (dateFormat === "regional"
+ ? regionalParts(
+ new DateFormatter(locale, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ ...shared,
+ }),
+ probe,
+ )
+ : null) ??
+ new DateFormatter(locale, {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ ...shared,
+ }).formatToParts(probe);
const pad2 = new Intl.NumberFormat(locale, { minimumIntegerDigits: 2, useGrouping: false });
const yearFmt = new Intl.NumberFormat(locale, { useGrouping: false });
const formatSegment = (type: EditableSegmentType, value: number): string => {
@@ -645,17 +719,29 @@ export function useDateFieldState(options: DateFieldStateOptions) {
if (type === "year") return yearFmt.format(value);
return pad2.format(value);
};
- // Format the (always-valid) anchor to get the locale's segment ORDER and the
- // literal separators. Each editable segment's *text* is then formatted from
- // its own value, so an in-progress out-of-range value can never build an
- // invalid date.
- const parts = formatter.formatToParts(anchor.toDate(timeZone ?? "UTC"));
- return { parts, formatSegment };
- }, [locale, hasTime, granularity, is12, timeZone, anchor]);
+ // Pinned to UTC on both sides so the day of the week is the calendar date's, never
+ // shifted by the host timezone.
+ const dayOfWeekFmt = new DateFormatter(locale, { weekday: "short", timeZone: "UTC" });
+ const dayOfWeekLongFmt = new DateFormatter(locale, { weekday: "long", timeZone: "UTC" });
+ const formatDayOfWeek = (value: DateValue, long = false): string =>
+ (long ? dayOfWeekLongFmt : dayOfWeekFmt).format(toCalendarDate(value as never).toDate("UTC"));
+ return { parts, formatSegment, formatDayOfWeek };
+ }, [locale, hasTime, granularity, is12, timeZone, anchor, dateFormat, showDayOfWeek]);
const segments = useMemo(() => {
return segmentFormat.parts.map((part) => {
const rawType = part.type;
+ if (rawType === "weekday") {
+ const settled = pendingEntry == null ? composeValue(fields) : null;
+ const shown = settled ?? (pendingEntry ? composeValue(pendingEntry.fallback) : null);
+ return {
+ type: "dayOfWeek",
+ text: shown ? segmentFormat.formatDayOfWeek(shown) : DAY_OF_WEEK_PLACEHOLDER,
+ isEditable: false,
+ isPlaceholder: settled == null,
+ label: settled ? segmentFormat.formatDayOfWeek(settled, true) : undefined,
+ };
+ }
if (!editableTypes.includes(rawType as EditableSegmentType)) {
return { type: "literal", text: part.value, isEditable: false, isPlaceholder: false };
}
@@ -673,7 +759,7 @@ export function useDateFieldState(options: DateFieldStateOptions) {
maxValue: max,
};
});
- }, [segmentFormat, fields, editableTypes, getLimits]);
+ }, [segmentFormat, fields, editableTypes, getLimits, pendingEntry, composeValue]);
const currentChange = buildStateChange(fields, "edit");
@@ -690,5 +776,6 @@ export function useDateFieldState(options: DateFieldStateOptions) {
applyShortcut,
expandShortYear,
commitOnBlur,
+ settleEntry,
};
}
diff --git a/packages/core/src/contexts/appshell-context.tsx b/packages/core/src/contexts/appshell-context.tsx
index e8e9b0ce..e5ef4d5a 100644
--- a/packages/core/src/contexts/appshell-context.tsx
+++ b/packages/core/src/contexts/appshell-context.tsx
@@ -59,6 +59,18 @@ export type RouteParams = AppShellRegister extends { routeParams: infer T }
? T
: Record>;
+/**
+ * How date inputs lay out their segments.
+ *
+ * - `"numeric"` — the locale's numeric short form (`2025/12/19`, `12/19/2025`,
+ * `19.12.2025`). The current default and historic behaviour; planned to give
+ * way to `"regional"` as the default in a future major release.
+ * - `"regional"` — the locale's written business form where it keeps the month
+ * numeric (`2025年12月19日` for ja / zh, `2025년 12월 19일` for ko); every other
+ * locale, whose written form spells the month as a word, stays numeric.
+ */
+export type DateInputDateFormat = "numeric" | "regional";
+
export type RootConfiguration = {
modules: Modules;
settingsResources: Resource[];
@@ -69,6 +81,8 @@ export type RootConfiguration = {
resolvedLocale?: string;
/** IANA timezone (e.g. "America/Los_Angeles"). Used by date/time components. */
timeZone?: string;
+ /** Default segment layout for date inputs. Falls back to `"numeric"`. */
+ dateInputDateFormat?: DateInputDateFormat;
};
export type AppInfoEntry = {
@@ -90,6 +104,7 @@ export type ConfigurationOptions = {
errorBoundary?: ErrorBoundaryComponent;
locale?: string;
timeZone?: string;
+ dateInputDateFormat?: DateInputDateFormat;
};
/**
@@ -107,6 +122,7 @@ export const buildConfigurations = (options: ConfigurationOptions): RootConfigur
locale: options.locale ? toLanguageSubtag(options.locale) : detectBrowserLocale(),
resolvedLocale: options.locale ?? detectBrowserFullLocale(),
timeZone: options.timeZone,
+ dateInputDateFormat: options.dateInputDateFormat,
});
/**
@@ -214,3 +230,12 @@ export const useTimeZone = (): TimeZone => {
const timeZone = configurations.timeZone ?? getLocalTimeZone();
return { value: timeZone, today: () => today(timeZone), now: () => now(timeZone) };
};
+
+/**
+ * Returns the configured default date-input format (`"numeric"` when unset or
+ * outside an AppShell).
+ */
+export const useDateInputDateFormat = (): DateInputDateFormat => {
+ const { configurations } = useContext(AppShellConfigContext);
+ return configurations.dateInputDateFormat ?? "numeric";
+};
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 9c978315..c1176922 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -50,6 +50,7 @@ export {
type AppInfo,
type AppInfoEntry,
type TimeZone,
+ type DateInputDateFormat,
} from "./contexts/appshell-context";
export { useAppShellScrollContainer } from "./contexts/scroll-container-context";
export { useTheme, type ColorTheme, type ResolvedColorTheme } from "./contexts/theme-context";
diff --git a/packages/core/tests/test-utils.tsx b/packages/core/tests/test-utils.tsx
index 8f473df4..2c3e4f48 100644
--- a/packages/core/tests/test-utils.tsx
+++ b/packages/core/tests/test-utils.tsx
@@ -1,14 +1,22 @@
import type { ReactNode } from "react";
-import { AppShellConfigContext, buildConfigurations } from "@/contexts/appshell-context";
+import {
+ AppShellConfigContext,
+ buildConfigurations,
+ type DateInputDateFormat,
+} from "@/contexts/appshell-context";
/**
* Test wrapper that provides AppShellConfigContext for components/hooks
* that depend on `useAppShellConfig()` (e.g., `useT()` from `defineI18nLabels`).
*/
-export function createAppShellWrapper(locale = "en") {
+export function createAppShellWrapper(
+ locale = "en",
+ options: { dateInputDateFormat?: DateInputDateFormat } = {},
+) {
const configurations = buildConfigurations({
modules: [],
locale,
+ dateInputDateFormat: options.dateInputDateFormat,
});
return function AppShellWrapper({ children }: { children: ReactNode }) {