// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file NativeTimeSegment.tsx * @input Uses React, native picker segment styles, and shared time utilities * @output Exports NativeTimeSegment — the shared OS-picker time control * @position Internal native control used by DateTimeInput and TimeInput * * Hands time picking to the platform through ``. The input * is deliberately uncontrolled and observed through native event listeners, * matching NativeDateSegment's iOS-safe value ownership: external values only * reconcile while the picker is unfocused, and native edits do not depend on * React's synthetic change event. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/DateTimeInput/DateTimeInput.tsx (DateTimeInput integration) * - /packages/core/src/TimeInput/TimeInput.tsx (TimeInput integration) * - /packages/core/src/DateTimeInput/NativePickerSegments.test.tsx (DateTimeInput tests) * - /packages/core/src/TimeInput/NativeTimeInput.test.tsx (TimeInput tests) * - /packages/core/src/DateTimeInput/DateTimeInput.doc.mjs (DateTimeInput prop docs) * - /packages/core/src/TimeInput/TimeInput.doc.mjs (TimeInput prop docs) */ import { useCallback, useEffect, useRef, useState } from 'react'; import * as stylex from "../style-runtime.js"; import { hasEditableDateSegments } from "../DateInput/nativeDateSegments.js"; import type { InputStatusType } from "../Field/index.js"; import { useMediaQuery } from "../hooks/useMediaQuery.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; import { Icon } from "../Icon/index.js"; import { useTranslator } from "../i18n/index.js"; import type { ThemeProps } from "../utils/themeProps.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { focusOutlineStyles, formatDisplayTime12h, formatDisplayTime24h, formatISOTime, isTimeInRange, parseISOTime, type ISOTimeString } from "../utils/index.js"; import { nativePickerSegmentStyles as styles } from "./nativePickerSegmentStyles.js"; const FRACTIONAL_SECONDS = /\.\d+$/; function normalizeNativeTime(value: string | undefined): ISOTimeString | undefined { if (!value) { return undefined; } const withoutFraction = value.replace(FRACTIONAL_SECONDS, '') as ISOTimeString; const parsed = parseISOTime(withoutFraction); return parsed ? formatISOTime(parsed, false) : undefined; } export type NativeTimeSegmentProps = { id: string; inputRef: React.Ref; value?: ISOTimeString; onChange: (value: ISOTimeString | undefined) => void; placeholder: string; inputLabel?: string; openPickerLabel: string; ariaLabelledBy?: string; iconThemeProps?: ThemeProps; hasAutoFocus?: boolean; min?: ISOTimeString; max?: ISOTimeString; hourFormat: '12h' | '24h'; isEffectivelyDisabled: boolean; hasDisabledMessage: boolean; isEffectivelyRequired: boolean; isBusy: boolean; statusType?: InputStatusType; ariaDescribedBy?: string; }; /** The shared native time control rendered inside Astryx field chrome. */ export function NativeTimeSegment({ id, inputRef, value, onChange, placeholder, inputLabel, openPickerLabel, ariaLabelledBy, iconThemeProps, hasAutoFocus = false, min, max, hourFormat, isEffectivelyDisabled, hasDisabledMessage, isEffectivelyRequired, isBusy, statusType, ariaDescribedBy }: NativeTimeSegmentProps) { const t = useTranslator(); 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; // Preserve the accepted DOM value while the OS wheel is open so an // optimistic rollback cannot replay that edit when the field blurs. if (!isFocused) { lastCommitRef.current = null; } if (rejectedValue !== null) { setRejectedValue(null); } } const nativeValue = normalizeNativeTime(value) ?? ''; const isInputValid = rejectedValue === null; const formatDisplayTime = hourFormat === '12h' ? formatDisplayTime12h : formatDisplayTime24h; const overlayText = nativeValue ? formatDisplayTime(nativeValue, false) : placeholder; const showsOverlay = !!overlayText && !(isFocused && isSegmentEditable); const commitValue = useCallback((newValue: string) => { if (isEffectivelyDisabled || lastCommitRef.current === newValue) { return; } if (!newValue) { lastCommitRef.current = newValue; setRejectedValue(null); if (value !== undefined) { onChange(undefined); } return; } const normalized = normalizeNativeTime(newValue); if (!normalized) { return; } if (!isTimeInRange(normalized, min, max)) { setRejectedValue(newValue); return; } setRejectedValue(null); lastCommitRef.current = newValue; if (normalized !== value) { onChange(normalized); } }, [isEffectivelyDisabled, max, min, 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. Capture it // once, then reconcile through the unfocused-only effect below. 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; // The engine probe classifies whether WebKit/Blink exposes editable native // segments. Date and time controls share that engine-level behavior. 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 raises the iOS picker and is the fallback when showPicker needs // user activation or is unavailable. } } }, [isEffectivelyDisabled]); return <> {showsOverlay && } {!isInputValid ? t('@astryx.timeInput.invalidTime') : ''} ; } NativeTimeSegment.displayName = 'NativeTimeSegment';