// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file NativeDateSegment.tsx * @input Uses React, Icon, nativeDateSegments, and DateTimeInput's date state * @output Exports NativeDateSegment — the OS-picker date half of DateTimeInput * @position Internal DateTimeInput surface selected by `nativePicker` * * Hands date picking to the platform through ``. Its * sibling NativeTimeSegment hands time picking to ``, so * both halves of DateTimeInput follow the same `nativePicker` choice. * * The input is deliberately uncontrolled and observed through native event * listeners. On iOS, writing its value while the picker sheet is open can * detach the sheet, and React's synthetic change event does not reliably see * picker edits. External values therefore reconcile only while unfocused. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/DateTimeInput/DateTimeInput.tsx (surface selection) * - /packages/core/src/DateTimeInput/NativePickerSegments.test.tsx (tests) * - /packages/core/src/DateTimeInput/DateTimeInput.doc.mjs (prop docs) */ import { useCallback, useEffect, useRef, useState } from 'react'; import * as stylex from "../style-runtime.js"; import { hasEditableDateSegments } from "../DateInput/nativeDateSegments.js"; import { useMediaQuery } from "../hooks/useMediaQuery.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import { Icon } from "../Icon/index.js"; import { useLocale, useTranslator } from "../i18n/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { focusOutlineStyles, formatSharedDate, parseDateInput, plainDateFromISO, plainDateToISO, themeProps, type ISODateString } from "../utils/index.js"; import type { InputStatusType } from "../Field/index.js"; import { nativePickerSegmentStyles as styles } from "./nativePickerSegmentStyles.js"; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; export type NativeDateSegmentProps = { id: string; inputRef: React.Ref; value?: ISODateString; onChange: (value: ISODateString | undefined) => void; placeholder: string; min?: ISODateString; max?: ISODateString; isDateDisabled: (date: ReturnType) => boolean; isEffectivelyDisabled: boolean; hasDisabledMessage: boolean; isEffectivelyRequired: boolean; isBusy: boolean; statusType?: InputStatusType; ariaDescribedBy?: string; }; /** The native date half rendered inside DateTimeInput's date wrapper. */ export function NativeDateSegment({ id, inputRef, value, onChange, placeholder, min, max, isDateDisabled, isEffectivelyDisabled, hasDisabledMessage, isEffectivelyRequired, isBusy, statusType, ariaDescribedBy }: NativeDateSegmentProps) { const t = useTranslator(); const locale = useLocale(); const isTouchPointer = useMediaQuery('(pointer: coarse)'); const internalInputRef = useRef(null); const mergedInputRef = useMergedRefs(inputRef, internalInputRef); const [rejectedValue, setRejectedValue] = useState(null); const [isFocused, setIsFocused] = useState(false); const [isSegmentEditable, setIsSegmentEditable] = useState(false); const valueAtFocusRef = useRef(null); const lastCommitRef = useRef(null); const prevValueRef = useRef(value); if (value !== prevValueRef.current) { prevValueRef.current = value; // Keep the accepted DOM value cached while a native picker is open. An // optimistic rollback changes the prop but leaves the focused control alone; // clearing this cache would replay that rejected edit on blur. if (!isFocused) { lastCommitRef.current = null; } if (rejectedValue !== null) { setRejectedValue(null); } } const nativeValue = value && ISO_DATE.test(value) ? value : ''; const isInputValid = rejectedValue === null; const overlayText = nativeValue ? formatSharedDate(plainDateFromISO(nativeValue), 'date_long', locale) : placeholder; const showsOverlay = !!overlayText && !(isFocused && isSegmentEditable); const commitValue = useCallback((newValue: string) => { if (isEffectivelyDisabled) { return; } if (lastCommitRef.current === newValue) { return; } if (!newValue) { lastCommitRef.current = newValue; setRejectedValue(null); if (value !== undefined) { onChange(undefined); } return; } const parsed = parseDateInput(newValue, locale); if (!parsed) { return; } if (isDateDisabled(parsed)) { setRejectedValue(newValue); return; } setRejectedValue(null); lastCommitRef.current = newValue; const parsedISO = plainDateToISO(parsed); if (parsedISO !== value) { onChange(parsedISO); } }, [isDateDisabled, isEffectivelyDisabled, locale, onChange, value]); const handleChange = useCallback((event: React.ChangeEvent) => { commitValue(event.target.value); }, [commitValue]); // React's synthetic change system does not reliably observe iOS picker edits. const commitRef = useRef(commitValue); useEffect(() => { commitRef.current = commitValue; }); useEffect(() => { const input = internalInputRef.current; if (!input) { return; } const handleNative = () => commitRef.current(input.value); input.addEventListener('input', handleNative); input.addEventListener('change', handleNative); return () => { input.removeEventListener('input', handleNative); input.removeEventListener('change', handleNative); }; }, []); // React touches defaultValue after mount when the prop changes, so capture it // once. The effect below owns every later write and skips focused controls. const initialValueRef = useRef(null); if (initialValueRef.current === null) { initialValueRef.current = nativeValue; } useEffect(() => { if (isFocused) { return; } const input = internalInputRef.current; if (input && input.value !== nativeValue) { input.value = nativeValue; } }, [isFocused, nativeValue]); const handleFocus = useCallback(() => { valueAtFocusRef.current = nativeValue; setIsSegmentEditable(hasEditableDateSegments(isTouchPointer)); setIsFocused(true); }, [isTouchPointer, nativeValue]); const handleBlur = useCallback(() => { const domValue = internalInputRef.current?.value; const valueChangedWhileFocused = valueAtFocusRef.current !== null && valueAtFocusRef.current !== nativeValue; const wasRejected = rejectedValue !== null; valueAtFocusRef.current = null; setIsFocused(false); setRejectedValue(null); if (!valueChangedWhileFocused && !wasRejected && domValue !== undefined && domValue !== nativeValue) { commitValue(domValue); } lastCommitRef.current = null; }, [commitValue, nativeValue, rejectedValue]); const openPicker = useCallback(() => { if (isEffectivelyDisabled) { return; } const input = internalInputRef.current; if (!input) { return; } input.focus(); if (typeof input.showPicker === 'function') { try { input.showPicker(); } catch { // Focus is the fallback when showPicker needs user activation or the // browser does not expose it for date controls. } } }, [isEffectivelyDisabled]); return <> {showsOverlay && } {!isInputValid ? t('@astryx.dateInput.invalidDate') : ''} ; } NativeDateSegment.displayName = 'NativeDateSegment';