// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file TouchDateField.tsx * @input Uses React, Field, BottomSheet, Button, Icon, Calendar hooks, MonthScroller, MonthYearWheels * @output Exports TouchDateField — the touch surface behind DateInput * @position Internal component; consumed by DateInput.tsx * * The touch half of `DateInput`, holding `DateInput`'s whole prop * contract so the two are interchangeable. Everything field-shaped — * `Field` wrapper, status treatment, optimistic `changeAction`, the * disabled-reason tooltip, `InputGroup` membership — behaves exactly as it * does on the desktop control; only the picker differs. * * ## The closed field is deliberately the same control * * It is a real ``, not a button: same element, same `role="combobox"`, * same border, same clear button, so `ref` (typed `Ref` by * `DateInputProps`) is honestly a reference to an input, the label's `for` * names it natively, and the switch between surfaces moves nothing on screen. * * It just cannot be typed into: `readOnly` blocks entry, and `inputMode="none"` * stops the virtual keyboard from opening over the sheet. Text entry is the * one part of the desktop control that has no place here — the keyboard it * summons would cover the picker it is meant to fill in. * * ## Three ideas in the picker * * 1. One month per screen. Every pane is exactly the height of the scrollport * and snaps to its start, so the picker is a fixed height and there is no * resting position showing half of two months. See MonthScroller. * 2. Swiping is the month control, and the arrows are the backup. A flick * reaches a neighbouring month in the direction you already think of it; * the pair of arrows in the header's trailing corner is there for a * deliberate single step, and for anyone not swiping at all. * 3. The title is the escape hatch. Tap it and the same box becomes a month * wheel and a year wheel — a flick each to reach 2019 instead of forty. * * Reset is chrome, so it sits in the header beside the arrows rather than in * the footer: the footer is where the task ends, and an undo of equal weight * beside Save is a mis-tap that throws away the date just chosen. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/DateInput/DateInput.tsx * - /packages/core/src/DateInput/DateInput.doc.mjs * - /packages/core/src/DateInput/DateInputTouch.test.tsx */ import { useCallback, useEffect, useId, useMemo, useOptimistic, useRef, useState, useTransition } from 'react'; import * as stylex from "../style-runtime.js"; import { BottomSheet } from "../BottomSheet/index.js"; import { Button } from "../Button/index.js"; import { useCalendarConstraints } from "../Calendar/index.js"; import type { DateInputProps } from "./DateInput.js"; import { Field, InputClearButton, inputWrapperStyles, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputStatusFocusWithinStyles } from "../Field/index.js"; import { useInputStatusIcon, useMergedRefs } from "../hooks/index.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import { Icon } from "../Icon/index.js"; import { IconButton } from "../IconButton/index.js"; import { useLocale, useTranslator } from "../i18n/index.js"; import { useInputGroup } from "../InputGroup/index.js"; import { groupStyles } from "../InputGroup/groupStyles.js"; import { stableClassName } from "../naming.js"; import { useSize } from "../SizeContext/index.js"; import { Spinner } from "../Spinner/index.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, sizeVars, borderVars, fontWeightVars, typeScaleVars, typographyVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import { useTooltip } from "../Tooltip/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { focusOutlineStyles, getInputARIA, isImeKeyEvent, mergeProps, rtlStyles, themeProps, formatSharedDate, plainDateFromISO, plainDateToday, plainDateFormat, DATE_FORMAT_MONTH_YEAR, DATE_FORMAT_WEEKDAY_ONLY, type ISODateString } from "../utils/index.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import { normalizeDayOfWeek } from "../utils/dateTypes.js"; import { MonthScroller, type MonthScrollerHandle } from "./MonthScroller.js"; import { MonthYearWheels } from "./MonthYearWheels.js"; import { DEFAULT_MONTH_REACH, clampIndex, fromMonthIndex, monthIndexOf } from "./monthGeometry.js"; import "./tokens.stylex.js"; import { dateInputTouchSizes, dateInputTouchGeometry } from "./tokens.stylex.js"; /** * The comfortable minimum tap target on both iOS and Android, honoured by * every target inside the sheet. */ const TOUCH_TARGET = dateInputTouchSizes.daySize; /** * The whole surface swap, in one leg. * * It used to be two: the outgoing surface faded out, then the incoming one * faded in, 110ms each. The sequencing existed for one reason — the wheels' * panel was transparent, so overlapping the two put the wheels' translucent * selection band over the calendar grid and tinted a band-shaped strip of it, * which read as "the grey area animates differently from the content". * * Giving the wheels an opaque background removes the reason. Nothing shows * through them, so they can simply fade in ON TOP of a calendar that does not * move at all — no empty middle, no outgoing animation, and one duration * instead of a wait plus a fade. * * `--duration-fast` rather than a literal now that this is a whole duration * and not half of one. */ const SWAP_DURATION = durationVars['--duration-fast']; const sizeStyles = { sm: { kZKoxP: "x6k0iem", k7Eaqz: "xfb3i0g", $$css: true as const }, md: { kZKoxP: "x1ueg155", k7Eaqz: "xfb3i0g", $$css: true as const }, lg: { kZKoxP: "xssyfek", k7Eaqz: "xfb3i0g", $$css: true as const } }; const styles = { wrapper: { kOIVth: "x1txdalj", $$css: true as const }, iconButton: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kmVPX3: "x1717udv", kogj98: "x1ghz6dp", kMzoRj: "xc342km", ksu8eU: "xng3xce", kWkggS: "xjbqb8w", kkrTdU: "x1ypdohk x16khyan", kaIpWk: "xh6dtrn", $$css: true as const }, iconButtonDisabled: { kkrTdU: "xt0e3qv", $$css: true as const }, monthArrowUnavailable: { k33iCy: "xlshs6z", $$css: true as const }, monthArrow: { kAzted: "xygd9yz", k7Eaqz: "xngvp7v", $$css: true as const }, monthArrowIcon: { k1xSpc: "x3nfvp2", $$css: true as const }, resetButton: { kAzted: "xygd9yz", $$css: true as const }, title: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "xzye2dw", kZKoxP: "x5yr21d", kg3NbH: "xf314gf", keTefX: "x1s1akpx", kMzoRj: "xc342km", ksu8eU: "xng3xce", kaIpWk: "xh6dtrn", kWkggS: "xjbqb8w", kMwMTN: "x1tgivj0", kGuDYH: "x18juvz8", k63SB2: "x2mo6ok", kkrTdU: "x1ypdohk x16khyan", khDVqt: "xuxw1ft", k7Eaqz: "xeuugli", kVQacm: "xb3r6kr", $$css: true as const }, titleChevron: { k1xSpc: "x3nfvp2", kmuXW: "x2lah0s", k1ekBW: "x11xpdln", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", k6CgDc: "xzg1mie", $$css: true as const }, titleChevronOpen: { k3aq6I: "x19jd1h0", $$css: true as const } }; /** * The touch surface. Takes `DateInput`'s props verbatim; see * {@link DateInput} for when it is chosen over the desktop control. */ export function TouchDateField({ label, isLabelHidden = false, description, isOptional = false, isRequired = false, isDisabled = false, disabledMessage, value, onChange, changeAction, isLoading = false, min, max, dateConstraints, placeholder: placeholderFromProps, size: sizeProp, status, statusVariant = 'attached', labelTooltip, hasClear = false, // Desktop-only: the scroller is a single continuously paged column, so a // second month would be the month already one flick away. Accepted (the // prop types are shared) and ignored. numberOfMonths: _numberOfMonths, weekStartsOn: weekStartsOnProp = 0, format = 'date_long', width, xstyle, className, style, ref, ...rest }: DateInputProps) { const t = useTranslator(); const locale = useLocale(); const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); const placeholder = placeholderFromProps ?? t('@astryx.dateInput.placeholder'); const size = useSize(sizeProp, 'md'); const weekStartsOn = normalizeDayOfWeek(weekStartsOnProp); const id = useId(); const inputLabelID = useId(); const descriptionID = useId(); const statusMessageID = useId(); const inputRef = useRef(null); const mergedInputRef = useMergedRefs(ref, inputRef); const inputGroup = useInputGroup(); const [, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isBusy = isLoading || optimisticValue !== value; const isEffectivelyDisabled = isDisabled || isBusy; // Disabled-reason tooltip, same contract as DateInput: a disabled control // swallows pointer events, so the listeners attach to the wrapper and the // input stays focusable via aria-disabled rather than the disabled // attribute. Only the persistent disabled state surfaces a reason, never // the transient busy one. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', focusTrigger: 'always', isEnabled: showsDisabledMessage }); const { isDateDisabled } = useCalendarConstraints({ min, max, dateConstraints }); const { statusIcon, describedBy: statusTooltipDescribedBy } = useInputStatusIcon({ status, statusVariant, isInGroup: !!inputGroup }); const { ariaLabelledBy, ariaDescribedBy } = getInputARIA(inputLabelID, [description ? descriptionID : null, statusVariant !== 'tooltip' && status?.message ? statusMessageID : null, statusTooltipDescribedBy, showsDisabledMessage ? disabledMessageTooltip.describedBy : null], inputGroup); const [isSheetOpen, setIsSheetOpen] = useState(false); const [isWheelOpen, setIsWheelOpen] = useState(false); const scrollerHandleRef = useRef(null); // Pending focus handoff from the clear button; see handleClear. const clearFocusTimerRef = useRef(null); useEffect(() => () => { if (clearFocusTimerRef.current != null) { clearTimeout(clearFocusTimerRef.current); } }, []); const today = useMemo(() => plainDateToday(), []); const selectedDate = useMemo(() => optimisticValue != null && /^\d{4}-\d{2}-\d{2}$/.test(optimisticValue) ? plainDateFromISO(optimisticValue) : null, [optimisticValue]); // The reachable range. Explicit bounds win; otherwise the scroller reaches a // century in each direction from wherever it opened. Anchored once, in a // state initializer: recomputing it as the selection moves would shift every // pane's scroll offset under the user mid-gesture. const [anchorMonthIndex] = useState(() => monthIndexOf(value != null && /^\d{4}-\d{2}-\d{2}$/.test(value) ? plainDateFromISO(value) : plainDateToday())); const minMonthIndex = min != null ? monthIndexOf(plainDateFromISO(min)) : anchorMonthIndex - DEFAULT_MONTH_REACH; const maxMonthIndex = max != null ? monthIndexOf(plainDateFromISO(max)) : anchorMonthIndex + DEFAULT_MONTH_REACH; const [monthIndex, setMonthIndex] = useState(() => clampIndex(anchorMonthIndex, minMonthIndex, maxMonthIndex)); const { year, month } = fromMonthIndex(monthIndex); /** * Three-letter weekday names — "Sun", not Calendar's "Su". * * The sheet is full width and the columns are ~51px, so there is room for * the form people actually read, and a picker operated by thumb should not * make anyone decode "Tu" against "Th". * * This is CLDR's `abbreviated` width, which `Intl` produces natively — no * truncation, so the 28 non-English locales stay correct rather than being * sliced to three characters. (Verified against the CLDR tables for all 30 * locales in the catalog.) Calendar's 2-letter row is CLDR's *short* width, * which `Intl` cannot express, which is exactly why that surface needs a * generated table and this one does not. * * Built here rather than taken from `useCalendarDays`, which supplies the * short form for Calendar's own header. The rotation matches: day 4 of * January 1970 was a Sunday, so offsetting from it by `weekStartsOn` walks * the week in the same order the panes lay out their columns. */ const dayNames = useMemo(() => Array.from({ length: 7 }, (_, offset) => plainDateFormat({ year: 1970, month: 1, day: 4 + (weekStartsOn + offset) % 7 }, DATE_FORMAT_WEEKDAY_ONLY, locale)), [locale, weekStartsOn]); const monthYearLabel = plainDateFormat({ year, month, day: 1 }, DATE_FORMAT_MONTH_YEAR, locale); // Formats the committed value only. A function format is called with the ISO // value; a named one reuses Timestamp's shared date mapping, so the same // literal renders the same shape here and on the desktop control. const displayValue = optimisticValue != null && /^\d{4}-\d{2}-\d{2}$/.test(optimisticValue) ? typeof format === 'function' ? format(optimisticValue) : formatSharedDate(plainDateFromISO(optimisticValue), format, locale) : ''; const fireChange = useCallback((newValue: ISODateString | undefined) => { if (isBusy) { return; } onChange?.(newValue); if (changeAction) { startTransition(async () => { setOptimisticValue(newValue); await changeAction(newValue); }); } }, [isBusy, onChange, changeAction, startTransition, setOptimisticValue]); const openSheet = useCallback(() => { if (!isEffectivelyDisabled) { // Always onto the calendar, whatever was showing last time. The wheels // are a detour taken to reach a far month, not a mode to be left in: // reopening into them would answer a question the user has not asked // yet, and hide the dates they came back for behind another tap. setIsWheelOpen(false); setIsSheetOpen(true); } }, [isEffectivelyDisabled]); const handleClear = useCallback(() => { fireChange(undefined); // Focus goes back to the field on the NEXT task, not synchronously. // // Clearing unmounts this button (it only renders while there is a value), // and focusing another element in the same task as that unmount makes iOS // Safari scroll the whole document to the top — the user is thrown from // wherever the field sat to the start of the page. Measured on the iOS 26 // simulator against the live docsite, field at scrollY 2055: synchronous // focus lands at 0, deferred focus stays at 2055. // // `preventScroll` alone does NOT fix it (verified: still 0) — this is not // the browser's ordinary scroll-the-focused-element-into-view step, so the // deferral is the load-bearing half. It is kept because the reveal scroll // is real too, and unwanted for the same reason: the field the user just // tapped is already on screen (+12px on a plain page without it). // // Skipping the focus entirely would also stop the scroll, but then focus // dies with the unmounting button and lands on . const field = inputRef.current; if (field == null) { return; } clearFocusTimerRef.current = window.setTimeout(() => { clearFocusTimerRef.current = null; field.focus({ preventScroll: true }); }, 0); }, [fireChange]); /** * Put the picker back to how it opens: no date, current month. * * Two things, because clearing a date and then being left staring at the * month of the date you just cleared is a half-finished action — the * calendar should look the way it does before anything is chosen. * * "If possible" is load-bearing: a range can exclude the current month * entirely (a booking window starting next quarter), and there is no * honest place to go in that case. `clampIndex` would silently land on the * nearest edge, which is a different month presented as if it were today's, * so the move is skipped instead and the calendar stays where it is. The * value is still cleared either way — that half never depends on the range. */ const handleResetInSheet = useCallback(() => { fireChange(undefined); const currentMonth = monthIndexOf(today); if (currentMonth < minMonthIndex || currentMonth > maxMonthIndex) { return; } if (currentMonth === monthIndex) { return; } setMonthIndex(currentMonth); scrollerHandleRef.current?.scrollToMonth(currentMonth, 'smooth'); }, [fireChange, today, monthIndex, minMonthIndex, maxMonthIndex]); // Selection commits on the tap and leaves the sheet up, so a mistake can be // corrected in place and a nearby date reconsidered without reopening. // Dismissal is the footer's Done (and the handle, the scrim, Escape) — none // of which commit anything, because this already has. const handleSelect = useCallback((next: ISODateString) => { fireChange(next); }, [fireChange]); // Whether there is anywhere to step. An arrow with nowhere to go is hidden // rather than disabled: a disabled control still says "this is a thing you // could do", and at the end of a range it is not — the range is the whole // truth, and there is no state the user can reach where it becomes // available. A greyed chevron sitting there permanently reads as broken. const canStepBack = monthIndex > minMonthIndex; const canStepForward = monthIndex < maxMonthIndex; // One month either way, clamped to the reachable range. Goes through the // same scrollToMonth the swipe settles on, so the arrows and the gesture // cannot disagree about where a month rests. const stepMonth = useCallback((delta: number) => { const target = clampIndex(monthIndex + delta, minMonthIndex, maxMonthIndex); if (target === monthIndex) { return; } setMonthIndex(target); scrollerHandleRef.current?.scrollToMonth(target, 'smooth'); }, [monthIndex, minMonthIndex, maxMonthIndex]); // A wheel commit steers the scroller immediately, even though it is behind // the wheels: it keeps its layout box while hidden, so by the time the // wheels close it is already resting on the new month. const handleWheelChange = useCallback((next: number) => { setMonthIndex(next); scrollerHandleRef.current?.scrollToMonth(next, 'auto'); }, []); /** * The calendar reports the month it has scrolled to — but only while it is * the surface being scrolled. * * While the wheels are up the wheels are the source of truth, and the * calendar is being STEERED by them: a wheel commit calls `scrollToMonth` * above, the calendar scrolls, and it would report that month straight back * here. That closes a cycle — wheel commits, calendar echoes, the echo moves * the wheel's selected row, the wheel is repositioned onto it, and the * resulting scroll reads as another commit. * * Whether that cycle converges depends on how precisely a browser reports * "scrolling stopped", which is not something to rely on. With `scrollend` * (Chrome) it settles at once; on iOS below Safari 26 there is no * `scrollend` and momentum runs on for a second or more after the finger * lifts, so each lap committed the next month along and the value climbed * on its own. Ignoring the echo removes the cycle instead of damping it. */ const handleVisibleMonthChange = useCallback((next: number) => { if (isWheelOpen) { return; } setMonthIndex(next); }, [isWheelOpen]); /** * Put the calendar back where it belongs when the wheels close. * * The wheels steer it while it is hidden, and a hidden scroller is not a * reliable place to leave a scroll position: `visibility: hidden` keeps the * layout box, but iOS re-snaps the scroller when it becomes visible again, * and it does not necessarily re-snap to the pane we put it on. That fires * a scroll at the exact moment reports start being trusted again — which is * why the month drifted on the way back to the dates. * * Re-asserting is cheap when nothing moved (the scroller is already there, * so nothing scrolls) and exactly right when something did. `scrollToMonth` * marks the target as steered, so this correction does not report itself * back either. */ // Read through a ref so this effect depends on the surface change alone. // Listing `monthIndex` would re-run it on every month the user swipes to, // yanking the scroller back mid-gesture. const monthIndexRef = useRef(monthIndex); monthIndexRef.current = monthIndex; useEffect(() => { if (isWheelOpen) { return; } scrollerHandleRef.current?.scrollToMonth(monthIndexRef.current, 'auto'); }, [isWheelOpen]); // APG combobox keys. The field takes no text, so every printable key is // free — but only the documented openers are wired, so a stray keystroke // does not pop a sheet. const handleInputKeyDown = useCallback((event: React.KeyboardEvent) => { // Same guard core's pointer surface carries. This field is readOnly and // takes no composition of its own, but an IME sitting over it still // sends its committing Enter here first — and opening a date sheet on // the keystroke that finishes a Korean syllable is the same wrong // answer. See utils/ime.ts. if (isImeKeyEvent(event.nativeEvent)) { return; } if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { event.preventDefault(); openSheet(); } }, [openSheet]); const surface =
{/* Both arrows at the trailing corner, as a pair — and only while the calendar is the surface they step. `IconButton` gives them Button's optical centring, focus ring, disabled treatment and hit area; the hand-rolled version had the glyph off-centre. Mirrored under RTL by the shared helper: "previous" is the earlier month, which sits on the right when the inline axis runs that way, and the panes mirror with it. */} {/* Reset, past the arrows, and gone with them on the wheels: the wheels choose a month, and there is no date there to put back. Hidden rather than unmounted, for the arrows' reason — the corner keeps its size, so the header cannot change height mid-swap. */}
{/* Decorative: each day carries its weekday in its accessible name, so this row is not a header row for assistive technology — and it must live outside the scroller, or it would scroll away with the month. */}
{/* Done does NOT commit: a tap on a day has already fired onChange by the time it is reachable. It is a close button, exactly equivalent to the grab handle, the scrim and Escape — which is why it is safe for those to remain, and why there is no Cancel to pair it with. It sits alone. A "Today" button was here and only moved the calendar to the current month WITHOUT selecting it, which read as broken: the one thing a button called Today should obviously do is the thing it did not. Navigating and selecting are two different intents and it conflated them, so it is gone until it can be one or the other on purpose. Reaching today by scrolling still works. */} {/* No `inert` here: the two cells below take turns, so the footer as a whole is always live. It carried one while the footer was hidden entirely on the wheels, and leaving it behind made the wheels' own Done button unreachable — an inert ancestor disables everything inside it. */}
{/* Both actions share one grid cell, the same way the panels above them do, and follow the same base/overlay rule — so the footer never changes height and the two never read as superimposed. Each belongs to a surface: Save finishes on the calendar, Done finishes the detour through the wheels. */}
; const inputWrapper =
{ // Anchor + hover/focus listeners for the disabled-message tooltip. // Gated internally by isEnabled, so attaching unconditionally is safe. disabledMessageTooltip.ref(el); }} {...rest} {...mergeProps(themeProps('date-input', { size, status: status?.type ?? null, disabled: isDisabled ? 'disabled' : null }), stylex.props(inputWrapperStyles.base, sizeStyles[size], styles.wrapper, isEffectivelyDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isEffectivelyDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], inputGroup && groupStyles.inGroup, xstyle), className, style)}> {inputGroup && {label}} {}} onClick={openSheet} onKeyDown={handleInputKeyDown} placeholder={placeholder} disabled={isEffectivelyDisabled && !showsDisabledMessage} aria-disabled={showsDisabledMessage ? 'true' : undefined} aria-labelledby={ariaLabelledBy} aria-describedby={ariaDescribedBy} aria-required={isEffectivelyRequired ? 'true' : undefined} aria-invalid={status?.type === 'error' ? 'true' : undefined} aria-busy={isBusy || undefined} aria-expanded={isSheetOpen} aria-haspopup="dialog" aria-autocomplete="none" autoComplete="off" {...{ 0: { className: "x1lliihq x98rzlu xeuugli xc342km xng3xce x1717udv x9ynric xjm74w1 xf9zqsd xw6l6zx x1tgivj0 xjbqb8w x1a2a7pz xbuiw85 x1ypdohk x16khyan x87ps6o xeyghm5" }, 1: { className: "x1lliihq x98rzlu xeuugli xc342km xng3xce x1717udv x9ynric xjm74w1 xf9zqsd xw6l6zx x1tgivj0 xjbqb8w x1a2a7pz xbuiw85 x87ps6o xeyghm5 xt0e3qv" } }[Number(!!isEffectivelyDisabled) << 0]} /> {hasClear && value !== undefined && !isEffectivelyDisabled && } {isBusy && } {statusIcon}
{surface}
{showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)}
; if (inputGroup) { return inputWrapper; } return {inputWrapper} ; } TouchDateField.displayName = 'TouchDateField';