// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file TextInput.tsx * @input Uses React, useId, ChangeEvent, Field, Icon, InputGroupContext * @output Exports TextInput component, TextInputProps * @position Core implementation; consumed by index.ts, tested by TextInput.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/TextInput/TextInput.doc.mjs (props table, features, implementation notes) * - /packages/core/src/TextInput/TextInput.test.tsx (tests for new/changed behavior) * - /packages/core/src/TextInput/index.ts (exports if types change) * - /apps/storybook/stories/TextInput.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/TextInput/ (showcase blocks) */ import { useId, useOptimistic, useTransition, useCallback, useRef, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, sizeVars, typographyVars, typeScaleVars } from "../theme/tokens.stylex.js"; import { Field, InputClearButton, type InputStatus, inputWrapperStyles, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputStatusFocusWithinStyles, type FieldStatusVariant } from "../Field/index.js"; import { renderIconSlot, type IconType } from "../Icon/index.js"; import { Spinner } from "../Spinner/index.js"; import { useTooltip } from "../Tooltip/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { getInputARIA, isImeKeyEvent } from "../utils/index.js"; const sizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; export type TextInputSize = keyof typeof sizeStyles; import { groupStyles } from "../InputGroup/groupStyles.js"; // Re-export shared types for convenience export type { InputStatus as TextInputStatus, InputStatusType as TextInputStatusType } from "../Field/index.js"; import { mergeProps } from "../utils/index.js"; import { useSize } from "../SizeContext/SizeContext.js"; import { useInputContainer } from "../hooks/useInputContainer.js"; import { useInputStatusIcon } from "../hooks/useInputStatusIcon.js"; import { useResolvedRequired } from "../hooks/useResolvedRequired.js"; import { useInputGroup } from "../InputGroup/InputGroupContext.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; export type TextInputType = 'text' | 'password' | 'email'; export interface TextInputProps extends Omit { /** Ref forwarded to the root element */ ref?: React.Ref; /** * The HTML input type. * @default 'text' */ type?: TextInputType; /** * Label text for the input (always rendered for accessibility). */ label: string; /** * Whether to visually hide the label (still accessible to screen readers). * @default false */ isLabelHidden?: boolean; /** * Description text displayed between the label and input. */ description?: string; /** * Whether the field is optional. Mutually exclusive with isRequired. * @default false */ isOptional?: boolean; /** * Whether the field is required. Mutually exclusive with isOptional. * @default false */ isRequired?: boolean; /** * Whether the input is disabled. * @default false */ isDisabled?: boolean; /** * Whether the input is read-only. * The value is shown at full opacity and still submits with the form, but * cannot be edited. Unlike `isDisabled`, a read-only input is not dimmed and * stays in the tab order — use it for a value the user should see and send * but not change. `isDisabled` takes precedence when both are set. * @default false */ isReadOnly?: boolean; /** * Explains why the input is disabled. When set together with `isDisabled`, * the input shows a tooltip with this text on hover and keyboard focus, and * stays focusable (via `aria-disabled`) so the reason is discoverable by * keyboard and assistive technology. The field cannot be edited (it becomes * read-only) while disabled. * * Use this instead of wrapping a disabled input in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. * * @example * ``` * * ``` */ disabledMessage?: string; /** * Icon to display at the start of the input. * Accepts a ReactNode (e.g. ``) or an SVG icon component directly. */ startIcon?: ReactNode | IconType; /** * Status indicator for the input. * When set, displays a colored border and status icon. * If message is provided, displays a floating message box below the input. */ status?: InputStatus; /** * How the status message is placed relative to the input. * - 'attached': message overlaps directly below the input (bordered treatment) * - 'detached': message floats below as a separate element with spacing * - 'tooltip': no message box; the status icon becomes a focusable info-tip button that reveals the message on hover, keyboard focus, or tap * @default 'attached' */ statusVariant?: FieldStatusVariant; /** * The size of the input. * - 'sm': Compact size (18px height) * - 'md': Default size (26px height) * @default 'md' */ size?: TextInputSize; /** * Callback fired when the input value changes. */ onChange?: (value: string, e: ChangeEvent) => void; /** Async action on change. Fires after onChange if not prevented. */ changeAction?: (value: string, e: ChangeEvent) => void | Promise; /** Whether the input is in a loading state. @default false */ isLoading?: boolean; /** * The current value of the input. */ value: string; /** * Placeholder text shown when the input is empty. */ placeholder?: string; /** * Width of the field. Numbers are treated as pixels, strings are used as-is * (e.g. `'100%'`). Sizes the whole field (label, control, and status) so they * stay aligned, unlike setting width via `xstyle`/`className`/`style`. */ width?: SizeValue; /** * Tooltip text to display in an info icon at the end of the label. */ labelTooltip?: string; /** * Whether to show a clear button when a value is set. * When clicked, resets the value to an empty string and returns focus to the input. * @default false */ hasClear?: boolean; /** * Whether to automatically focus the input on mount. * @default false */ hasAutoFocus?: boolean; /** * The HTML name attribute for the input. * Useful for form submissions. */ htmlName?: string; /** * Callback fired when the user presses the Enter key. */ onEnter?: () => void; /** * Callback fired on keydown events on the input. */ onKeyDown?: (e: KeyboardEvent) => void; /** * The native `autocomplete` attribute, forwarded to the input unchanged. * The value stays React-controlled regardless — this only hints the * browser/password manager's suggestion behavior (e.g. `'off'` for a * session-scoped field that must not reuse a prior value). */ autoComplete?: React.InputHTMLAttributes['autoComplete']; } /** * A text input component for collecting user input. * * @example * ``` * * * ``` */ export function TextInput({ type = 'text', label, isLabelHidden = false, description, isOptional = false, isRequired = false, isDisabled = false, isReadOnly = false, disabledMessage, startIcon, status, statusVariant = 'attached', size: sizeProp, onChange, changeAction, isLoading = false, value, placeholder, labelTooltip, hasClear = false, hasAutoFocus = false, htmlName, onEnter, onKeyDown, width, xstyle, className, style, ref, ...rest }: TextInputProps) { const t = useTranslator(); const isEffectivelyRequired = useResolvedRequired({ isRequired, isOptional }); const size = useSize(sizeProp, 'md'); const id = useId(); const inputLabelID = useId(); const descriptionID = useId(); const statusMessageID = useId(); const inputRef = useRef(null); const containerRef = useRef(null); const inputGroup = useInputGroup(); const [, startTransition] = useTransition(); const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isBusy = isLoading || optimisticValue !== value; // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the input container (which already exists) and // the input stays perceivable via aria-disabled instead of the native // disabled attribute. The field is made read-only so it can't be typed into, // and value mutation is blocked by the isDisabled guard in handleChange. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The container div is not naturally focusable; focusin bubbles up from // the input, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); const { statusIcon, describedBy: statusTooltipDescribedBy } = useInputStatusIcon({ status, statusVariant, isInGroup: !!inputGroup }); const { ariaLabelledBy, ariaDescribedBy } = getInputARIA(inputLabelID, [description ? descriptionID : null, // The status message element is rendered by Field, which is skipped // inside an InputGroup — only reference it when it actually exists. !inputGroup && statusVariant !== 'tooltip' && status?.message ? statusMessageID : null, // The tooltip variant renders no message box; describe the input by the // tooltip's content instead so the status is still announced. statusTooltipDescribedBy, showsDisabledMessage ? disabledMessageTooltip.describedBy : null], inputGroup); const handleChange = (e: ChangeEvent) => { // Value can't change while showing a disabled message (the field is // read-only and non-native-disabled), but guard the handler too so the // optimistic value and callbacks never fire. if (isDisabled || isReadOnly) { return; } const newValue = e.target.value; onChange?.(newValue, e); if (changeAction && !e.defaultPrevented) { startTransition(async () => { setOptimisticValue(newValue); await changeAction(newValue, e); }); } }; // Handle clear button click const handleClear = useCallback((e?: React.MouseEvent) => { onChange?.('', null as unknown as ChangeEvent); if (!e || e.detail === 0) { inputRef.current?.focus(); } else { // Defer focus restoration past the button's unmount task so iOS Safari // and touch browsers don't jump the page scroll to 0 on tap. requestAnimationFrame(() => { inputRef.current?.focus({ preventScroll: true }); }); } }, [onChange]); // Focus input when clicking anywhere on the wrapper (icons, padding, etc.) const { onClick: handleWrapperClick, onMouseUp: handleWrapperMouseUp } = useInputContainer({ containerRef, inputRef, disabled: isDisabled }); const inputWrapper =
{ containerRef.current = el; // Anchor + hover/focus listeners for the disabled-message tooltip. // Handlers are gated internally by isEnabled, and anchor names // compose, so attaching unconditionally is safe. disabledMessageTooltip.ref(el); }} onClick={handleWrapperClick} onMouseUp={handleWrapperMouseUp} {...mergeProps(themeProps('text-input', { size, status: status?.type ?? null, disabled: isDisabled ? 'disabled' : null, readonly: isReadOnly ? 'readonly' : null }), stylex.props(inputWrapperStyles.base, sizeStyles[size], isDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type], inputGroup && groupStyles.inGroup, xstyle), className, style)}> {startIcon && renderIconSlot(startIcon, { size: 'sm', color: 'secondary' })} {inputGroup && {label}} { // The composing keydown fires before compositionend, so without // this guard pressing Enter to commit a Japanese/Chinese/Korean // IME conversion would trigger onEnter (submit/save actions) // before the user intends to submit. onKeyDown still receives // the raw event. See utils/ime.ts (#6082). if (e.key === 'Enter' && !isImeKeyEvent(e.nativeEvent)) { onEnter?.(); } onKeyDown?.(e); } : undefined} placeholder={placeholder} // With a disabledMessage the input keeps focusability via aria-disabled // so the reason is focus-discoverable; readOnly + the handleChange guard // keep the value from changing. disabled={isDisabled && !showsDisabledMessage} aria-disabled={showsDisabledMessage ? 'true' : undefined} readOnly={isReadOnly || showsDisabledMessage || undefined} autoFocus={hasAutoFocus} data-autofocus={hasAutoFocus || undefined} aria-describedby={ariaDescribedBy} aria-required={isEffectivelyRequired ? 'true' : undefined} aria-invalid={status?.type === 'error' ? 'true' : undefined} aria-busy={isBusy || undefined} aria-labelledby={ariaLabelledBy} {...{ 0: { className: "x1lliihq x98rzlu xeuugli xc342km xng3xce x1717udv x9ynric xjm74w1 xf9zqsd xw6l6zx x1tgivj0 xjbqb8w x1a2a7pz xeyghm5" }, 1: { className: "x1lliihq x98rzlu xeuugli xc342km xng3xce x1717udv x9ynric xjm74w1 xf9zqsd xw6l6zx x1tgivj0 xjbqb8w x1a2a7pz xeyghm5 xt0e3qv" } }[Number(!!isDisabled) << 0]} /> {hasClear && value !== '' && !isDisabled && !isReadOnly && } {isBusy && } {statusIcon}
; if (inputGroup) { return <> {inputWrapper} {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} ; } return {inputWrapper} {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} ; } TextInput.displayName = 'TextInput';