// // Copyright 2023 DXOS.org // import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import { createContext } from '@radix-ui/react-context'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import React, { type ComponentPropsWithRef, type ForwardRefExoticComponent, PropsWithChildren, forwardRef, useCallback, useEffect, useRef, useState, } from 'react'; import { useTranslation } from 'react-i18next'; import { DescriptionAndValidation as DescriptionAndValidationPrimitive, type DescriptionAndValidationProps as DescriptionAndValidationPrimitiveProps, Description as DescriptionPrimitive, type DescriptionProps as DescriptionPrimitiveProps, INPUT_NAME, InputRoot, type InputRootProps, type InputScopedProps, Label as LabelPrimitive, type LabelProps as LabelPrimitiveProps, PinInput as PinInputPrimitive, type PinInputProps as PinInputPrimitiveProps, TextArea as TextAreaPrimitive, type TextAreaProps as TextAreaPrimitiveProps, TextInput as TextInputPrimitive, type TextInputProps as TextInputPrimitiveProps, Validation as ValidationPrimitive, type ValidationProps as ValidationPrimitiveProps, useInputContext, } from '@dxos/react-input'; import { type Density, type Elevation, type Size } from '@dxos/ui-types'; import { translationKey } from '#translations'; import { useDensityContext, useElevationContext, useThemeContext } from '../../hooks'; import { type ThemedClassName } from '../../util'; import { IconButton, IconButtonProps } from '../Button'; import { Icon } from '../Icon'; import { SegmentedDate, type SegmentedDateProps, SegmentedDateTime, type SegmentedDateTimeProps, SegmentedTime, type SegmentedTimeProps, } from './SegmentedInput'; type InputVariant = 'default' | 'subdued'; type InputSharedProps = Partial<{ density: Density; elevation: Elevation; variant: InputVariant }>; // // Trigger context — lets a sibling `Input.TriggerIcon` open a picker registered by a field inside // the same `Input.Root`. Each registered handler is keyed; the most recent registration wins. // type InputTriggerHandler = () => void; type InputTriggerContextValue = { registerTrigger: (handler: InputTriggerHandler) => () => void; trigger: () => void; hasTrigger: boolean; }; // Default context makes the trigger registry a no-op outside `Input.Root` (consumers opt in). const [InputTriggerProvider, useInputTriggerContext] = createContext(INPUT_NAME, { registerTrigger: () => () => {}, trigger: () => {}, hasTrigger: false, }); /** * Field hook. Pass an opener function; while the field is mounted, an `Input.TriggerIcon` * sibling will call this opener on press. Returns a no-op when used outside `Input.Root`. */ const useInputTrigger = (handler: InputTriggerHandler | undefined) => { const ctx = useInputTriggerContext('useInputTrigger'); useEffect(() => { if (!handler) { return; } return ctx.registerTrigger(handler); }, [ctx, handler]); }; // // Root — wraps the @dxos/react-input primitive root with the trigger registry. // const Root = (props: InputRootProps) => { const handlerRef = useRef(null); const [hasTrigger, setHasTrigger] = useState(false); const registerTrigger = useCallback((handler: InputTriggerHandler) => { handlerRef.current = handler; setHasTrigger(true); return () => { if (handlerRef.current === handler) { handlerRef.current = null; setHasTrigger(false); } }; }, []); const trigger = useCallback(() => { handlerRef.current?.(); }, []); return ( ); }; Root.displayName = 'Input.Root'; // // TriggerIcon — sibling button that opens the picker of the registered field. Renders nothing // when no field in the surrounding `Input.Root` has registered an opener. // // `label` and `icon` have defaults below, so both are optional for callers (e.g. ``). // `onClick` is reserved — the trigger always opens the registered picker. type TriggerIconProps = Omit & { label?: string }; const TriggerIcon = forwardRef( ({ classNames, icon = 'ph--calendar--regular', 'aria-label': ariaLabel, label, ...props }, forwardedRef) => { const { t } = useTranslation(translationKey); const ctx = useInputTriggerContext('Input.TriggerIcon'); if (!ctx.hasTrigger) { return null; } return ( ); }, ); TriggerIcon.displayName = 'Input.TriggerIcon'; // // Label // type LabelProps = ThemedClassName & { srOnly?: boolean }; const Label = forwardRef(({ classNames, children, srOnly, ...props }, forwardedRef) => { const { tx } = useThemeContext(); return ( {children} ); }); Label.displayName = 'Input.Label'; // // Description // type DescriptionProps = ThemedClassName & { srOnly?: boolean }; const Description = forwardRef( ({ classNames, children, srOnly, ...props }, forwardedRef) => { const { tx } = useThemeContext(); return ( {children} ); }, ); Description.displayName = 'Input.Description'; // // Validation // type ValidationProps = ThemedClassName & { srOnly?: boolean }; const Validation = forwardRef>( ({ __inputScope, classNames, children, srOnly, ...props }, forwardedRef) => { const { tx } = useThemeContext(); const { validationValence } = useInputContext(INPUT_NAME, __inputScope); return ( {children} ); }, ); Validation.displayName = 'Input.Validation'; // // DescriptionAndValidation // type DescriptionAndValidationProps = ThemedClassName & { srOnly?: boolean }; const DescriptionAndValidation = forwardRef( ({ classNames, children, srOnly, ...props }, forwardedRef) => { const { tx } = useThemeContext(); return ( {children} ); }, ); DescriptionAndValidation.displayName = 'Input.DescriptionAndValidation'; // // PinInput // type PinInputProps = ThemedClassName>; const PinInput = forwardRef( ({ classNames, density: propsDensity, elevation: propsElevation, ...props }, forwardedRef) => { const { hasIosKeyboard } = useThemeContext(); const { tx } = useThemeContext(); const density = useDensityContext(propsDensity); const elevation = useElevationContext(propsElevation); return ( ); }, ); PinInput.displayName = 'Input.PinInput'; // // TextInput // TODO(burdon): Implement inline icon within button: e.g., https://www.radix-ui.com/themes/playground#text-field // type AutoFillProps = { noAutoFill?: boolean; }; type TextInputProps = InputSharedProps & ThemedClassName & AutoFillProps; const TextInput = forwardRef>( ( { __inputScope, classNames, density: densityProp, elevation: elevationProp, variant, noAutoFill, ...props }, forwardedRef, ) => { const { hasIosKeyboard } = useThemeContext(); const { tx } = useThemeContext(); const density = useDensityContext(densityProp); const elevation = useElevationContext(elevationProp); const { validationValence } = useInputContext(INPUT_NAME, __inputScope); return ( ); }, ); TextInput.displayName = 'Input.TextInput'; // // TextArea // type TextAreaProps = InputSharedProps & ThemedClassName; const TextArea = forwardRef>( ({ __inputScope, classNames, density: propsDensity, elevation: propsElevation, variant, ...props }, forwardedRef) => { const { hasIosKeyboard } = useThemeContext(); const { tx } = useThemeContext(); const density = useDensityContext(propsDensity); const elevation = useElevationContext(propsElevation); const { validationValence } = useInputContext(INPUT_NAME, __inputScope); return ( ); }, ); TextArea.displayName = 'Input.TextArea'; // // Checkbox // type CheckboxProps = ThemedClassName> & { size?: Size; }; const Checkbox: ForwardRefExoticComponent = forwardRef< HTMLButtonElement, InputScopedProps >( ( { __inputScope, classNames, checked: propsChecked, defaultChecked: propsDefaultChecked, onCheckedChange: propsOnCheckedChange, size, ...props }, forwardedRef, ) => { const [checked, onCheckedChange] = useControllableState({ prop: propsChecked, defaultProp: propsDefaultChecked, onChange: propsOnCheckedChange, }); const { id, validationValence, descriptionId, errorMessageId } = useInputContext(INPUT_NAME, __inputScope); const { tx } = useThemeContext(); return ( ); }, ); Checkbox.displayName = 'Input.Checkbox'; // // Switch // type SwitchProps = ThemedClassName< Omit, 'children' | 'onChange'> & { onCheckedChange?: (checked: boolean) => void } >; const Switch = forwardRef>( ( { __inputScope, classNames, checked: propsChecked, defaultChecked: propsDefaultChecked, onCheckedChange: propsOnCheckedChange, ...props }, forwardedRef, ) => { const { tx } = useThemeContext(); const [checked, onCheckedChange] = useControllableState({ prop: propsChecked, defaultProp: propsDefaultChecked ?? false, onChange: propsOnCheckedChange, }); const { id, validationValence, descriptionId, errorMessageId } = useInputContext(INPUT_NAME, __inputScope); return ( { onCheckedChange(event.target.checked); }} id={id} aria-describedby={descriptionId} {...props} {...(validationValence === 'error' && { 'aria-invalid': 'true' as const, 'aria-errormessage': errorMessageId, })} ref={forwardedRef} /> ); }, ); Switch.displayName = 'Input.Switch'; // // Wrapper for Switch/Checkbox to center them within the input row height. // const Block = forwardRef(({ children, ...props }, forwardedRef) => { const { tx } = useThemeContext(); return (
{children}
); }); Block.displayName = 'Input.Block'; // // Date / Time / DateTime — segmented react-aria-components fields with locale-aware ordering, // spinbutton semantics, and immutable separators. ISO string API: // - Date `YYYY-MM-DD` // - Time `HH:mm` // - DateTime `YYYY-MM-DDTHH:mm` // Pair `Input.Date` or `Input.DateTime` with a sibling `Input.TriggerIcon` inside an // `Input.Root` to expose a calendar popover; `Input.Time` has no picker. // const Time = SegmentedTime; const Date = SegmentedDate; const DateTime = SegmentedDateTime; type TimeProps = SegmentedTimeProps; type DateInputProps = SegmentedDateProps; type DateTimeInputProps = SegmentedDateTimeProps; // // Input // export const Input = { Root, TriggerIcon, PinInput, TextInput, TextArea, Time, Date, DateTime, Checkbox, Switch, Block, Label, Description, Validation, DescriptionAndValidation, }; export { useInputTrigger }; export type { CheckboxProps, DateInputProps, DateTimeInputProps, DescriptionAndValidationProps, DescriptionProps, InputRootProps, InputSharedProps, InputVariant, LabelProps, PinInputProps, SwitchProps, TextAreaProps, TextInputProps, TimeProps, ValidationProps, };