// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file BaseTypeahead.tsx * @input Uses React, StyleX, usePopover, TypeaheadItem * @output Exports BaseTypeahead combobox engine component * @position Core implementation; used by Typeahead and Tokenizer * * Pure combobox engine: input, search, keyboard navigation, dropdown. * No wrapper div, no border styling, no token rendering. * Consumers provide their own wrapper and pass anchorRef for dropdown positioning. * * SYNC: When modified, update: * - /packages/core/src/Typeahead/index.ts * - /packages/cli/assets/templates/blocks/components/Typeahead/ (showcase blocks) */ import React, { useCallback, useEffect, useId, useRef, useState, type ReactNode, type RefObject } from 'react'; import * as stylex from "../style-runtime.js"; import { useBusyIndicatorLane } from "./busyIndicatorLane.js"; import type { StyleXStyles } from "../style-runtime.js"; import { usePopover } from "../Popover/usePopover.js"; import { useAnnounce } from "../hooks/useAnnounce.js"; import { useHighlightedOptionScroll } from "../hooks/useHighlightedOptionScroll.js"; import { useIsomorphicLayoutEffect } from "../hooks/useIsomorphicLayoutEffect.js"; import { isImeKeyEvent } from "../utils/ime.js"; import { TypeaheadItem } from "./TypeaheadItem.js"; import { Icon } from "../Icon/index.js"; import { Spinner } from "../Spinner/index.js"; import "../theme/tokens.stylex.js"; import { borderVars, colorVars, spacingVars, radiusVars, typographyVars, fontWeightVars, typeScaleVars } from "../theme/tokens.stylex.js"; import { characterCount, composeEventHandlers, getKey, groupItems, mergeProps } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.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"; // ============================================================================= // Types // ============================================================================= export interface BaseTypeaheadProps extends Omit, 'onChange'> { ref?: React.Ref; /** * 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 to display. * @default 10 */ maxMenuItems?: number; /** Requested dropdown width in pixels before viewport clamping. */ menuWidth?: number; /** * Minimum query length before the search source is queried. Below it no * search runs and the menu stays closed, so a remote source is not asked * for a result set that cannot be meaningful yet — and the user does not * see "no results" for a query that was never searched. * * Measured by grapheme cluster, so one visible character counts once even * when JavaScript represents it with multiple UTF-16 code units. * * @default 1 — every non-empty query is searched. */ minQueryLength?: number; /** * Text shown when no results found. * @default 'No results found' */ emptySearchResultsText?: string; /** * Whether the input is disabled. * @default false */ isDisabled?: boolean; /** * When disabled with a reason, keeps the input focusable via `aria-disabled` * (instead of the native `disabled` attribute) and `readOnly` so an * associated disabled-reason tooltip stays discoverable by keyboard and * assistive technology. Query and text mutation are blocked, but an * already-open highlighted option can still be selected with Enter after a * transition into this state. Consumers (Typeahead) own the tooltip and * wrapper. * @default false */ isFocusableDisabled?: boolean; /** * Auto-focus on mount. * @default false */ hasAutoFocus?: boolean; /** * Query change callback (for logging/external use). */ onChangeQuery?: (query: string) => void; /** * Callback when dropdown opens/closes. */ onOpenChange?: (isOpen: boolean) => void; /** * Entries derived from the query text rather than fetched for it — today, * Tokenizer's "Create ...". * * They are appended to whatever the search returned, and they are offered * whatever `minQueryLength` says: that threshold exists to avoid a fetch * that is too broad to be worth making, and these cost no fetch. A field * that can create `QA` should not stop being able to just because a search * for `QA` would match too much. * * Receives the results they will be appended to, so a caller can decline to * offer an entry that duplicates one. * * Underscored and `@internal`: `BaseTypeaheadProps` is re-exported from the * package entry point, so anything named on it ships as public API at the * next cut. This is a wiring detail between Tokenizer and the base — the * same reason `DefinedTheme.__inputTokens` carries its prefix. * * @internal */ __queryEntries?: (query: string, results: T[]) => T[]; /** * 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; /** * Legacy input-specific alias for the native `id` prop. When provided, this * alias takes precedence; otherwise the native prop is preserved. */ inputId?: string; /** * Legacy input-specific alias for native `aria-describedby`. When provided, * this alias takes precedence; otherwise the native prop is preserved. */ ariaDescribedBy?: string; /** * Legacy input-specific alias for native `aria-labelledby`. When provided, * this alias takes precedence; otherwise the native prop is preserved. */ ariaLabelledBy?: string; /** * Additional StyleX styles for the input element. */ inputXStyle?: StyleXStyles; /** * Legacy input-specific alias for native `tabIndex`. When provided, this * alias takes precedence; otherwise the native prop is preserved. Typeahead * passes `-1` while its selected-value token is shown: the input is visually * collapsed (width 0 / opacity 0) but must stay programmatically focusable * for token edit/clear interactions, so removing it from the Tab order is * what prevents an invisible tab stop (WCAG 2.4.3 / 2.4.7). The input remains * focusable via `.focus()` regardless of this value. */ inputTabIndex?: number; /** * Ref to the anchor element for dropdown positioning. * The dropdown will be positioned relative to this element. * If not provided, the input itself is used as the anchor. */ anchorRef?: RefObject; /** * Additional keydown handler called before internal keyboard navigation. * If the handler calls `e.preventDefault()`, internal handling is skipped. */ onKeyDown?: (e: React.KeyboardEvent) => void; /** * Size of the typeahead, used to scale dropdown item padding. * When 'sm', items get compact padding to match the trigger size. * @default 'md' */ size?: 'sm' | 'md' | 'lg'; } // ============================================================================= // Styles // ============================================================================= const TYPEAHEAD_VIEWPORT_GUTTER = spacingVars['--spacing-4']; const TYPEAHEAD_POSITION_AREA_MAX_INLINE_SIZE = `calc(100% - max(${TYPEAHEAD_VIEWPORT_GUTTER}, env(safe-area-inset-left, 0px), env(safe-area-inset-right, 0px)))`; const TYPEAHEAD_POSITION_AREA_MAX_INLINE_SIZE_FALLBACK = `calc(100% - ${TYPEAHEAD_VIEWPORT_GUTTER})`; const _temp = { kzqmXN: "x5lhr3w", "$$css": true }; const styles = { input: { k1xSpc: "x1lliihq", kUk6DE: "x98rzlu", k7Eaqz: "xfvyar9", kMzoRj: "xc342km", ksu8eU: "xng3xce", kmVPX3: "x1717udv", kMv6JI: "x9ynric", kGuDYH: "xjm74w1 xf9zqsd", kLWn49: "xw6l6zx", kMwMTN: "x1tgivj0", kWkggS: "xjbqb8w", kI3sdo: "x1a2a7pz", k8Qsv1: "xeyghm5", $$css: true as const }, inputDisabled: { kkrTdU: "xt0e3qv", $$css: true as const }, popover: { kB7OPa: "x9f619", k7Eaqz: "xrzjruh", ks0D6T: "xbd96yr", $$css: true as const }, popoverCustomWidth: (width: number) => [_temp, { "--x-width": (val => typeof val === "number" ? val + "px" : val != null ? val : undefined)(`${width}px`) }], item: { kB7OPa: "x9f619", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kzqmXN: "xh8yej3", kmVPX3: "xlsj2fj", kaIpWk: "xh6dtrn", kkrTdU: "x1ypdohk x16khyan", kI3sdo: "x1a2a7pz", kWkggS: "xjbqb8w", k9WMMc: "x1yc453h", $$css: true as const }, itemHighlighted: { kWkggS: "x1lmrjuc", kjBf7l: "x1x84bn5", k3XXqK: "x815t2r", kMeerF: "x1xvue0r", $$css: true as const }, itemSelected: { k63SB2: "x1e4wzip", $$css: true as const }, defaultItem: { k7Eaqz: "xeuugli", kzqmXN: "xh8yej3", $$css: true as const } }; /** * Size-specific overrides for dropdown list items. * Matches the pattern used by DropdownMenuItem / Selector so that * an `sm` typeahead renders compact list items. */ const itemSizeStyles = { sm: { k8WAf4: "xu0wf1k", kg3NbH: "xf314gf", $$css: true as const }, md: { k8WAf4: "x1vofgu7", $$css: true as const }, lg: { k8WAf4: "xce4md1", $$css: true as const } }; // ============================================================================= // Helpers // ============================================================================= /** * A query that has been typed but is still shorter than the caller's * threshold. An empty query is not "below the minimum" — it is the * untouched state, and `hasEntriesOnFocus` owns what happens there. */ function isBelowMinQueryLength(query: string, minQueryLength: number): boolean { const length = characterCount(query); return length > 0 && length < minQueryLength; } // ============================================================================= // Component // ============================================================================= /** * Combobox engine: input, search, keyboard navigation, and dropdown. * * Renders only the `` and the dropdown popover. No wrapper div, * no border styling, no token rendering. Consumers (Typeahead, * Tokenizer) provide their own wrapper and pass `anchorRef` for * dropdown positioning. * * @example * ``` * * ``` */ export const BaseTypeahead = function BaseTypeahead({ searchSource, value, onChange, renderItem, placeholder: placeholderFromProps, hasEntriesOnFocus = false, maxMenuItems = 10, menuWidth, minQueryLength = 1, emptySearchResultsText: emptySearchResultsTextFromProps, isDisabled = false, isFocusableDisabled = false, hasAutoFocus = false, onChangeQuery, onOpenChange, __queryEntries, inputId: externalInputId, ariaDescribedBy, ariaLabelledBy, inputXStyle, inputTabIndex, anchorRef, onKeyDown: externalOnKeyDown, debounceMs = 150, size = 'md', xstyle, className, style, onPointerDown: onPointerDownProp, onFocus: onFocusProp, onBlur: onBlurProp, id: nativeInputId, 'aria-describedby': nativeAriaDescribedBy, 'aria-labelledby': nativeAriaLabelledBy, tabIndex: nativeInputTabIndex, ref, ...rest }: BaseTypeaheadProps) { const t = useTranslator(); const placeholder = placeholderFromProps ?? t('@astryx.typeahead.searchPlaceholder'); const emptySearchResultsText = emptySearchResultsTextFromProps ?? t('@astryx.typeahead.emptySearchResults'); const generatedId = useId(); // Keep the released input-specific aliases authoritative when a caller uses // them, but do not let an omitted alias erase the equivalent native BaseProp. const inputId = externalInputId ?? nativeInputId ?? generatedId; const inputAriaDescribedBy = ariaDescribedBy ?? nativeAriaDescribedBy; const inputAriaLabelledBy = ariaLabelledBy ?? nativeAriaLabelledBy; const resolvedInputTabIndex = inputTabIndex ?? nativeInputTabIndex; const listboxId = useId(); const inputRef = useRef(null); const fallbackAnchorRef = useRef(null); // Announce result counts / "no results" to screen readers via a persistent // live region (comboboxes-6). The combobox's own popup carries no working // live region, so highlight/result changes were previously silent. const announce = useAnnounce(); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [highlightedIndex, setHighlightedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); const [hasSearched, setHasSearched] = useState(false); // Report the busy state to a wrapper that has taken the indicator over. // // Through a ref, and at the call site rather than from an effect: an effect // would run after this component had already committed, so the wrapper's // own state change landed in a second commit — two renders of the whole // field per transition, four across a search. Called here, the wrapper's // setState batches with ours into the one commit that React was already // doing. The ref keeps the identity of a caller's inline arrow from // mattering, and the guard makes the report edge-triggered: the redundant // `false` on every keystroke below the query threshold reports nothing. // // The ref is synced in a layout effect rather than during render, following // `onMotionStartRef` in BottomSheetPanel — a render that React discards // must not leave the ref pointing at the callback from the abandoned pass. // This effect only writes a ref, so it commits nothing and no wrapper // re-renders for it; every caller of `setLoading` runs from an event or an // awaited continuation, long after the first commit. // A wrapper that owns the inline-end lane subscribes through context; see // busyIndicatorLane.tsx for why this is not a prop. const busyLane = useBusyIndicatorLane(); const onLoadingChangeRef = useRef(busyLane?.onBusyChange); useIsomorphicLayoutEffect(() => { onLoadingChangeRef.current = busyLane?.onBusyChange; }, [busyLane]); const loadingRef = useRef(false); const setLoading = useCallback((next: boolean) => { if (loadingRef.current === next) { return; } loadingRef.current = next; setIsLoading(next); onLoadingChangeRef.current?.(next); }, []); // Track active pointer to defer popover.show() past click events. // With popover="auto", showing the popover between pointerdown and // pointerup/click causes the browser's light-dismiss to immediately // close it (the click is seen as "outside" the newly-opened popover). const pointerActiveRef = useRef(false); // Debounce ref const searchTimeoutRef = useRef | null>(null); // Monotonic counter incremented on selection, query-clear, and source // replacement. Async searches that resolve afterwards compare their // captured generation to the current value and discard stale results. const searchGenRef = useRef(0); // The generation at which results were last populated. handleFocus // compares this to searchGenRef — if they differ, the cached results // in the closure are stale (a selection cleared them) and shouldn't // be re-shown. const resultsGenRef = useRef(0); // Results still arriving from a replaced source must not land in the new // one's menu. const prevSearchSourceRef = useRef(searchSource); if (prevSearchSourceRef.current !== searchSource) { prevSearchSourceRef.current.cancel?.(); prevSearchSourceRef.current = searchSource; searchGenRef.current++; } // Layer for dropdown const handleLayerShow = useCallback(() => { onOpenChange?.(true); }, [onOpenChange]); const handleLayerHide = useCallback(() => { onOpenChange?.(false); setHighlightedIndex(-1); searchSource.cancel?.(); }, [onOpenChange, searchSource]); const popover = usePopover({ onShow: handleLayerShow, onHide: handleLayerHide, hasLightDismiss: true, hasCloseButton: false, hasAutoFocus: false, // The popup's own role="listbox" is the exposed semantics; the input keeps // DOM focus, so wrapping it in a modal dialog would misrepresent it. role: 'none' }); // Show the layer, deferring past the active click if a pointer is down. // Without this, popover="auto" light-dismiss immediately closes the // dropdown when it opens between pointerdown and pointerup/click. const showLayer = useCallback(() => { if (pointerActiveRef.current) { document.addEventListener('click', () => requestAnimationFrame(() => popover.show()), { once: true }); } else { popover.show(); } }, [popover]); // Set up anchor on the provided anchorRef or fall back to the input itself useEffect(() => { const el = anchorRef?.current ?? fallbackAnchorRef.current; if (el) { popover.triggerRef(el); } return () => { popover.triggerRef(null); }; }, [popover, anchorRef]); // Perform search const performSearch = useCallback(async (searchQuery: string) => { searchSource.cancel?.(); // Claim a new generation so overlapping searches can't race: an // in-flight response for an older query fails the gen check below // instead of overwriting the newer results. const gen = ++searchGenRef.current; setLoading(true); setHasSearched(true); try { const searchResults = await searchSource.search(searchQuery); if (searchGenRef.current !== gen) { return; } resultsGenRef.current = gen; const fetched = searchResults.slice(0, maxMenuItems); const shown = [...fetched, ...(__queryEntries?.(searchQuery, fetched) ?? [])]; setResults(shown); setHighlightedIndex(shown.length > 0 ? 0 : -1); if (searchResults.length > 0 || searchQuery.length > 0) { showLayer(); } // Announce the outcome only for an active query (not the initial // focus-open), so screen-reader users hear result counts / no-results. if (searchQuery.length > 0) { announce(shown.length === 0 ? emptySearchResultsText : t('@astryx.typeahead.resultCount', { count: shown.length })); } } catch { if (searchGenRef.current !== gen) { return; } setResults([]); setHighlightedIndex(-1); } finally { if (searchGenRef.current === gen) { setLoading(false); } } }, [searchSource, maxMenuItems, showLayer, announce, emptySearchResultsText, __queryEntries, setLoading, t]); const applyBootstrapResults = useCallback((bootstrapResults: T[], gen: number) => { if (searchGenRef.current !== gen) { return; } resultsGenRef.current = gen; const shown = bootstrapResults.slice(0, maxMenuItems); const nextHighlightedIndex = shown.length > 0 ? 0 : -1; if (results.length === 0 && shown.length === 0 && highlightedIndex === -1) { return; } setResults(shown); setHighlightedIndex(nextHighlightedIndex); if (bootstrapResults.length > 0) { showLayer(); } }, [highlightedIndex, maxMenuItems, results.length, showLayer]); // Perform bootstrap const performBootstrap = useCallback(async () => { const gen = ++searchGenRef.current; let bootstrapResult: T[] | Promise; try { bootstrapResult = searchSource.bootstrap(); } catch { if (searchGenRef.current === gen) { setResults([]); setLoading(false); } return; } if (Array.isArray(bootstrapResult)) { setLoading(false); applyBootstrapResults(bootstrapResult, gen); return; } setLoading(true); try { applyBootstrapResults(await bootstrapResult, gen); } catch { if (searchGenRef.current === gen) { setResults([]); } } finally { if (searchGenRef.current === gen) { setLoading(false); } } }, [searchSource, applyBootstrapResults, setLoading]); // Handle query change const handleQueryChange = useCallback((newQuery: string) => { setQuery(newQuery); onChangeQuery?.(newQuery); if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } // Nothing to search: either the field was emptied, or the query is // still shorter than `minQueryLength`. Both drop stale results and // close the menu — showing the empty state for a query that was never // searched would report "no results" that nobody looked for. if (newQuery.length === 0 && !hasEntriesOnFocus || isBelowMinQueryLength(newQuery, minQueryLength)) { searchGenRef.current++; searchSource.cancel?.(); // A query too short to search can still carry entries derived from // the text itself. `hasSearched` stays false either way, so the menu // never reports "no results" for a query nobody looked for. const derived = __queryEntries?.(newQuery, []) ?? []; setResults(derived); setHighlightedIndex(derived.length > 0 ? 0 : -1); setHasSearched(false); // Bumping the generation abandons any in-flight search, which means // its own `finally` will decline to clear this — so clear it here or // the field spins forever. Backspacing below the threshold on a remote // source is the everyday way to hit that. setLoading(false); // Clear any lingering result-count / no-results announcement. announce(''); if (derived.length > 0) { showLayer(); } else { popover.hide(); } return; } const triggerSearch = () => { if (newQuery.length > 0) { void performSearch(newQuery); } else if (hasEntriesOnFocus) { void performBootstrap(); } }; if (debounceMs <= 0) { triggerSearch(); } else { searchTimeoutRef.current = setTimeout(triggerSearch, debounceMs); } }, [onChangeQuery, hasEntriesOnFocus, minQueryLength, __queryEntries, showLayer, performSearch, performBootstrap, popover, debounceMs, searchSource, announce, setLoading]); // Handle input change const handleInputChange = useCallback((e: React.ChangeEvent) => { handleQueryChange(e.target.value); }, [handleQueryChange]); // Handle item selection const handleSelect = useCallback((item: T) => { // Bump generation to invalidate any in-flight async searches searchGenRef.current++; if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); searchTimeoutRef.current = null; } searchSource.cancel?.(); onChange(item); setQuery(''); setResults([]); setHasSearched(false); // Same reason as in handleQueryChange: the invalidated search will not // clear this itself. Selecting a stale result while the next search is // still in flight would otherwise leave the field spinning. setLoading(false); popover.hide(); inputRef.current?.focus(); }, [onChange, popover, searchSource, setLoading]); // Handle focus const handleFocus = useCallback(() => { if (isDisabled) { return; } if (hasEntriesOnFocus && results.length === 0 && query.length === 0) { void performBootstrap(); } else if (results.length > 0 && (query.length > 0 || hasEntriesOnFocus) && // Only re-show cached results if they haven't been invalidated by // a selection. Refs are always current, so this check isn't affected // by React's closure staleness the way results.length is. resultsGenRef.current === searchGenRef.current) { showLayer(); } }, [isDisabled, hasEntriesOnFocus, results.length, query.length, performBootstrap, showLayer]); // Handle blur — close the dropdown when focus leaves the input for an // element that is neither inside the field wrapper (anchor) nor inside the // dropdown popover. The native popover="auto" light-dismiss only fires on // outside pointer clicks and Escape; it does not close when focus moves away // via the keyboard (Tab) or programmatically, which would otherwise leave an // orphaned open menu. Clicking a result moves focus onto the option (it is // tabIndex={-1}, so it lives inside the popover) and selection re-focuses the // input, so this only closes on a genuine focus-out of the whole field. const handleBlur = useCallback((e: React.FocusEvent) => { if (!popover.isOpen) { return; } const next = e.relatedTarget as Node | null; if (next) { const anchorEl = anchorRef?.current ?? fallbackAnchorRef.current; const popoverEl = document.getElementById(popover.id); if (anchorEl?.contains(next) || popoverEl?.contains(next)) { return; } } popover.hide(); }, [popover, anchorRef]); // Keyboard navigation const handleKeyDown = useCallback((e: React.KeyboardEvent) => { externalOnKeyDown?.(e); if (e.defaultPrevented) { return; } // An IME candidate window uses Enter to commit the composition and // Escape/ArrowUp/ArrowDown/Home/End to navigate its own candidates. // Without this guard, a composing Enter both fires handleSelect AND // clears the input via handleSelect's setQuery(''), so the IME's // subsequent compositionend then writes the still-pending syllable // into the freshly-cleared field -- producing a second, spurious // selection on the next real Enter. if (isImeKeyEvent(e.nativeEvent)) { return; } if (!popover.isOpen) { if (e.key === 'ArrowDown' && (hasEntriesOnFocus || query.length > 0)) { e.preventDefault(); if (results.length > 0) { popover.show(); setHighlightedIndex(0); } else if (hasEntriesOnFocus && // A below-threshold query was never searched; falling back to the // bootstrap entries here would open a menu of suggestions that // ignore what the user has already typed. !isBelowMinQueryLength(query, minQueryLength)) { void performBootstrap(); } } return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); if (results.length > 0) { setHighlightedIndex(prev => prev < results.length - 1 ? prev + 1 : 0); } break; case 'ArrowUp': e.preventDefault(); if (results.length > 0) { setHighlightedIndex(prev => prev > 0 ? prev - 1 : results.length - 1); } break; case 'Enter': e.preventDefault(); if (highlightedIndex >= 0 && highlightedIndex < results.length) { handleSelect(results[highlightedIndex]); } break; case 'Escape': e.preventDefault(); popover.hide(); break; case 'Tab': // Dismiss here rather than from the blur this press produces: // hiding a top-layer popover during the focusout makes Chrome // abandon the in-flight focus move and drop focus to , so the // user's Tab appears to do nothing. Selector and MultiSelector // already dismiss on this keydown. popover.hide(); break; case 'Home': if (popover.isOpen) { e.preventDefault(); if (results.length > 0) { setHighlightedIndex(0); } } break; case 'End': if (popover.isOpen) { e.preventDefault(); if (results.length > 0) { setHighlightedIndex(results.length - 1); } } break; } }, [popover, results, highlightedIndex, handleSelect, hasEntriesOnFocus, query, minQueryLength, performBootstrap, externalOnKeyDown]); // Generate item ID for accessibility const getItemId = useCallback((index: number) => `${listboxId}-option-${index}`, [listboxId]); // Keep the highlighted option visible during keyboard navigation; hover // highlights never scroll (#6077). Both sides live in useHighlightedOptionScroll. const highlightOnHover = useHighlightedOptionScroll({ isOpen: popover.isOpen, highlightedIndex, setHighlightedIndex, getOptionId: getItemId, itemCount: results.length }); const selectedKey = value == null ? null : getKey(value.id, () => results.indexOf(value)); // Unmount: clear the pending debounce and cancel in-flight work. useEffect(() => { return () => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } prevSearchSourceRef.current.cancel?.(); }; }, []); return <> = 0 && highlightedIndex < results.length ? getItemId(highlightedIndex) : undefined} aria-autocomplete="list" aria-busy={isLoading || undefined} aria-describedby={inputAriaDescribedBy} aria-labelledby={inputAriaLabelledBy} aria-disabled={isFocusableDisabled ? 'true' : undefined} tabIndex={resolvedInputTabIndex} value={query} onChange={handleInputChange} onPointerDown={composeEventHandlers(() => { pointerActiveRef.current = true; document.addEventListener('click', () => { pointerActiveRef.current = false; }, { once: true }); }, onPointerDownProp)} onFocus={composeEventHandlers(handleFocus, onFocusProp)} onBlur={composeEventHandlers(handleBlur, onBlurProp)} onKeyDown={handleKeyDown} placeholder={placeholder} // When a disabled-reason tooltip is shown the input keeps focusability // via aria-disabled + readOnly instead of the native disabled // attribute. Query and text mutation are blocked, but an already-open // highlighted option can still be selected with Enter after transition. disabled={isDisabled && !isFocusableDisabled} readOnly={isFocusableDisabled || undefined} autoFocus={hasAutoFocus} data-autofocus={hasAutoFocus || undefined} autoComplete="off" {...mergeProps(stylex.props(styles.input, isDisabled && styles.inputDisabled, inputXStyle, xstyle), className, style)} /> {isLoading && busyLane == null && } {popover.render(
{results.length === 0 && hasSearched ?
{emptySearchResultsText}
: (() => { let flatIndex = 0; const renderOption = (item: T) => { const index = flatIndex++; const itemKey = getKey(item.id, index); const isSelected = itemKey === selectedKey; return
handleSelect(item)} onMouseEnter={() => highlightOnHover(index)} {...stylex.props(styles.item, itemSizeStyles[size], index === highlightedIndex && styles.itemHighlighted, isSelected && styles.itemSelected)}> {renderItem ? renderItem(item) : } {isSelected && }
; }; return groupItems(results, { ungroupedFirst: true }).map(group => { const options = group.items.map(renderOption); if (group.heading == null) { return options; } return
{options}
; }); })()}
, { placement: 'below', alignment: 'start', offset: spacingVars['--spacing-1'], xstyle: [styles.popover, menuWidth != null && styles.popoverCustomWidth(menuWidth)] })} ; } as (props: BaseTypeaheadProps) => React.ReactElement; (BaseTypeahead as { displayName?: string; }).displayName = 'BaseTypeahead';