// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Typeahead.tsx * @input Uses React, BaseTypeahead, Field, Token, InputGroupContext * @output Exports Typeahead styled typeahead component * @position Styled wrapper; composes BaseTypeahead with Field or InputGroup * * Owns the input wrapper (border, padding, status styles), selected value * token with spacing compensation, and edit mode behavior. Delegates * search, keyboard navigation, and dropdown to BaseTypeahead. * * SYNC: When modified, update: * - /packages/core/src/Typeahead/index.ts * - /apps/storybook/stories/Typeahead.stories.tsx * - /packages/cli/assets/templates/blocks/components/Typeahead/ (showcase blocks) */ import React, { useCallback, useId, useRef, useMemo, useState, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import { BusyIndicatorLaneProvider, createBusyIndicatorLane, useIsBusy, type BusyIndicatorLane } from "./busyIndicatorLane.js"; import { BaseTypeahead } from "./BaseTypeahead.js"; import { useSize } from "../SizeContext/SizeContext.js"; import { Field, InputClearButton, type InputStatus, inputWrapperStyles, inputStatusBorderStyles, inputStatusHoverShadowStyles, inputStatusFocusWithinStyles, type FieldStatusVariant } from "../Field/index.js"; import { Token } from "../Token/index.js"; import { useTooltip } from "../Tooltip/index.js"; import { renderIconSlot, type IconType } from "../Icon/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { Spinner } from "../Spinner/index.js"; import "../theme/tokens.stylex.js"; import { spacingVars, sizeVars } from "../theme/tokens.stylex.js"; import { groupStyles } from "../InputGroup/groupStyles.js"; import { useInputGroup } from "../InputGroup/InputGroupContext.js"; import { getInputARIA, isImeKeyEvent, mergeProps } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import type { SearchableItem, SearchSource } from "./types.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; export type { InputStatus as TypeaheadStatus, InputStatusType as TypeaheadStatusType } from "../Field/index.js"; export type TypeaheadSize = 'sm' | 'md' | 'lg'; export interface TypeaheadProps extends Omit, 'onChange'> { ref?: React.Ref; /** Accessible label (required). */ label: string; /** Visually hide the label. @default false */ isLabelHidden?: boolean; /** Helper text. */ description?: string; /** Required field. @default false */ isRequired?: boolean; /** Optional field. @default false */ isOptional?: boolean; /** Validation status. */ 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 * @default 'attached' */ statusVariant?: FieldStatusVariant; /** * Icon to display at the start of the input. * Accepts a ReactNode (e.g. ``) or an SVG icon component directly. */ startIcon?: ReactNode | IconType; /** * 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; /** Label tooltip. */ labelTooltip?: string; /** Search source providing items. */ searchSource: SearchSource; /** Currently selected item (null = nothing selected). */ value: T | null; /** Callback when selection changes. */ onChange: (item: T | null) => void; /** Render function for dropdown items. Default: TypeaheadItem. */ renderItem?: (item: T) => ReactNode; /** Placeholder text. */ placeholder?: string; /** Show results on focus before typing. @default false */ hasEntriesOnFocus?: boolean; /** Max dropdown items. @default 10 */ maxMenuItems?: number; /** * Minimum query length before the search source is queried. Below it no * search runs and the menu stays closed — useful for remote sources where * one or two characters match too much to be worth fetching. * @default 1 */ minQueryLength?: number; /** Text shown when no results found. @default 'No results found' */ emptySearchResultsText?: string; /** Whether the input is disabled. @default false */ isDisabled?: 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 * the field stays focusable (via `aria-disabled`) so the reason is * discoverable by keyboard and assistive technology. Editing and selection * stay blocked. * * 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; /** Show clear button. @default true */ hasClear?: boolean; /** Auto-focus on mount. @default false */ hasAutoFocus?: boolean; /** Input size. @default 'md' */ size?: TypeaheadSize; /** * Debounce delay in ms before triggering search after typing. * Set to 0 for synchronous/local search sources that don't need debouncing. * @default 150 */ debounceMs?: number; /** Query change callback. */ onChangeQuery?: (query: string) => void; /** Callback when dropdown opens/closes. */ onOpenChange?: (isOpen: boolean) => void; } // ============================================================================= // Styles // ============================================================================= const styles = { wrapper: { kVAEAm: "x1n2onr6", kOIVth: "xzye2dw", k8WAf4: "x1x7cmac", kkrTdU: "x1ed109x x16khyan", $$css: true as const }, inputHidden: { kSiTet: "xg01cxk", kfzvcC: "x47corl", $$css: true as const }, tokenOverlay: { kVAEAm: "x10l6tqk", k87sOh: "x13vifvy", kLqNvP: "xmt1zuk", kt4wiu: "xtijo5x", kzqmXN: "xeq5yr9", kqGvvJ: "x10im51j", keTefX: "x1lziwak", k71WvV: "x11t971q", $$css: true as const } }; const wrapperSizeStyles = { sm: { kAzted: "x1gdqauq", $$css: true as const }, md: { kAzted: "x1uogy3y", $$css: true as const }, lg: { kAzted: "x1xf4nls", $$css: true as const } }; // ============================================================================= // Component // ============================================================================= /** * A search-as-you-type component for selecting an item from a search source. * * Wraps BaseTypeahead with Field for label, description, and status. * Owns the input wrapper styling, selected value token, and edit mode. * * Edit mode: clicking the token or input area removes the token, populates * the input with the value's label, and selects all text. Blurring without * selecting restores the original token. Escape also restores. * * @example * ``` * * ``` */ /** * The field's inline-end lane: the busy Spinner, then the clear button. * * A separate component so that subscribing to the busy state re-renders THIS * and nothing else. Subscribing from `Typeahead` itself would re-render the * whole field — and, in the Tokenizer that shares this design, every selected * token — twice per search for one glyph. * * Renders nothing when there is neither a spinner nor a clear button, which is * what keeps an empty flex item from taking a gap in the row. */ function EndLane({ lane, loadingLabel, clear }: { lane: BusyIndicatorLane; loadingLabel: string; clear: ReactNode; }) { const isBusy = useIsBusy(lane); if (!isBusy && clear == null) { return null; } return
{isBusy && } {clear}
; } export function Typeahead({ ref, label, isLabelHidden = false, description, isRequired = false, isOptional = false, status, statusVariant = 'attached', startIcon, labelTooltip, searchSource, value, onChange, renderItem, placeholder, hasEntriesOnFocus, maxMenuItems, minQueryLength, emptySearchResultsText, isDisabled = false, disabledMessage, hasClear = true, hasAutoFocus, size: sizeProp, debounceMs, onChangeQuery, onOpenChange, width, xstyle, className, style, 'data-testid': testId }: TypeaheadProps) { const t = useTranslator(); const size = useSize(sizeProp, 'md'); const inputId = useId(); const inputLabelId = useId(); const descriptionId = useId(); const statusMessageId = useId(); const inputGroup = useInputGroup(); const wrapperRef = useRef(null); const inputRef = useRef(null); const tokenRef = useRef(null); // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the input wrapper (which already exists) and // the input stays perceivable via aria-disabled + readOnly instead of the // disabled attribute. Editing and selection stay blocked by the isDisabled // guards (handleWrapperClick, handleEnterEditMode, and BaseTypeahead's own // focus/change guards). const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The wrapper div is not naturally focusable; focusin bubbles up from the // input, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); // Edit mode: when the user clicks the token to edit the selected value // Reported by BaseTypeahead so the indicator can live in this field's own // end lane, beside the clear button, rather than in the engine's row. // The base owns the busy state; this field only paints it. Held in a store // rather than this component's state so a search transition re-renders the // one leaf that shows the Spinner, not the whole field. See // busyIndicatorLane.tsx. const busyLane = useMemo(() => createBusyIndicatorLane(), []); const [isEditing, setIsEditing] = useState(false); const [editingValue, setEditingValue] = useState(null); // Show token when value is selected and not in edit mode const showToken = value != null && !isEditing; // Enter edit mode: remove token visually, populate input with value label const handleEnterEditMode = useCallback(() => { if (isDisabled || !value) { return; } setEditingValue(value); setIsEditing(true); // The base will receive onChangeQuery with the value's label onChangeQuery?.(value.label); requestAnimationFrame(() => { const input = inputRef.current; if (input) { // Set the input value directly since the base manages its own query state // We trigger a synthetic change to sync the base's internal state const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set; nativeInputValueSetter?.call(input, value.label); input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); input.setSelectionRange(0, input.value.length); } }); }, [isDisabled, value, onChangeQuery]); // Handle blur: restore token if editing and no selection was made const handleBlur = useCallback((e: React.FocusEvent) => { // Don't restore if focus is moving within the wrapper (e.g. to dropdown) if (wrapperRef.current?.contains(e.relatedTarget)) { return; } if (editingValue && isEditing) { setIsEditing(false); setEditingValue(null); // Value was never cleared from parent, so no onChange needed } }, [editingValue, isEditing]); // Handle selection from dropdown — clears edit mode const handleChange = useCallback((item: T | null) => { setIsEditing(false); setEditingValue(null); onChange(item); // After selection, focus the token so keyboard users stay in the component. // Use requestAnimationFrame because the token renders on the next cycle. if (item) { requestAnimationFrame(() => { const tokenEl = tokenRef.current; if (tokenEl) { // Focus the internal button inside the token const button = tokenEl.querySelector('button'); (button ?? tokenEl).focus(); } }); } }, [onChange]); // Handle clear (explicit X button on token) const handleClear = useCallback(() => { setIsEditing(false); setEditingValue(null); onChange(null); inputRef.current?.focus(); }, [onChange]); // Handle Escape during edit mode — restore token const handleKeyDown = useCallback((e: React.KeyboardEvent) => { // BaseTypeahead invokes this external handler *before* its own IME // guard, so we must guard here too: an IME candidate window uses Escape // to cancel the pending composition, and that composing Escape fires // before compositionend. Without this, a Korean/Japanese/Chinese user // cancelling a candidate would instead exit edit mode and blur the // field. See utils/ime.ts. if (isImeKeyEvent(e.nativeEvent)) { return; } if (e.key === 'Escape' && editingValue) { e.preventDefault(); setIsEditing(false); setEditingValue(null); inputRef.current?.blur(); } }, [editingValue]); // Click wrapper to focus input or enter edit mode const handleWrapperClick = useCallback(() => { if (isDisabled) { return; } if (showToken) { handleEnterEditMode(); } else { inputRef.current?.focus(); } }, [isDisabled, showToken, handleEnterEditMode]); const { ariaLabelledBy, ariaDescribedBy } = getInputARIA(inputLabelId, [description ? descriptionId : null, status?.message ? statusMessageId : null, showsDisabledMessage ? disabledMessageTooltip.describedBy : null], inputGroup); const sizeStyle = wrapperSizeStyles[size]; const typeaheadContent = <>
{startIcon && renderIconSlot(startIcon, { size: 'sm', color: 'secondary' })} {inputGroup && {label}} {/* The base reports its busy state through this lane, so the indicator lands in the end controls below beside the clear button rather than as a second one inside the base. */}
{showToken && }
{ e.stopPropagation(); handleClear(); }} /> : null} />
{showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} ; if (inputGroup) { return typeaheadContent; } return {typeaheadContent} ; } Typeahead.displayName = 'Typeahead';