();
@@ -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 a1c98fec8..9113fcaa9 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 52ec70259..95f7390bf 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,13 @@ describe("12-hour time display", () => {
expect(text("end date", "AM/PM")).toBe("PM");
});
});
+
+describe("DateRangePicker showDayOfWeek", () => {
+ it("shows the day of the week for both ends", () => {
+ const range = { start: new CalendarDate(2025, 12, 19), end: new CalendarDate(2025, 12, 22) };
+ render(
+ ,
+ );
+ 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 06f2cd404..0334240dc 100644
--- a/packages/core/src/components/date-field/date-range-picker.tsx
+++ b/packages/core/src/components/date-field/date-range-picker.tsx
@@ -88,6 +88,8 @@ export type DateRangePickerProps = {
locale?: string;
/** IANA timezone; defaults to the AppShell `timeZone`. */
timeZone?: string;
+ /** 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 +131,7 @@ 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 +70,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 +134,8 @@ export interface DateFieldStateOptions {
isDateUnavailable?: (date: DateValue) => boolean;
/** Week-start for the `w`/`k` shortcuts; defaults to the locale convention. */
firstDayOfWeek?: FirstDayOfWeek;
+ /** Append the locale's short day of the week as a read-only segment. */
+ showDayOfWeek?: boolean;
isDisabled?: boolean;
isReadOnly?: boolean;
}
@@ -144,6 +149,7 @@ const PLACEHOLDERS: Record = {
second: "––",
dayPeriod: "AM",
};
+const DAY_OF_WEEK_PLACEHOLDER = "––";
/**
* Snap an impossible day down to the entered month's real length — but only once
@@ -284,6 +290,7 @@ export function useDateFieldState(options: DateFieldStateOptions) {
maxValue,
isDateUnavailable,
firstDayOfWeek,
+ showDayOfWeek = false,
isReadOnly,
} = options;
@@ -316,6 +323,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 +433,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 +470,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 +513,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 +647,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 / 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 +660,19 @@ 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 parts = new DateFormatter(locale, {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ ...shared,
+ }).formatToParts(anchor.toDate(timeZone ?? "UTC"));
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 +680,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, 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 +720,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 +737,6 @@ export function useDateFieldState(options: DateFieldStateOptions) {
applyShortcut,
expandShortYear,
commitOnBlur,
+ settleEntry,
};
}