// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Tokenizer.tsx * @input Uses React, BaseTypeahead, Field, Token, useAnnounce * @output Exports Tokenizer multi-select typeahead component * @position Composed component; forwards DOM ref and exposes focus control via * handleRef * * SYNC: When modified, update: * - /packages/core/src/Tokenizer/index.ts * - /apps/storybook/stories/Tokenizer.stories.tsx * - /packages/cli/assets/templates/blocks/components/Tokenizer/ (showcase blocks) */ import React, { useCallback, useId, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import { BusyIndicatorLaneProvider, createBusyIndicatorLane, useIsBusy, type BusyIndicatorLane } from "../Typeahead/busyIndicatorLane.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import { BaseTypeahead } from "../Typeahead/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 { Spinner } from "../Spinner/index.js"; import { useEndLaneReserve } from "./useEndLaneReserve.js"; import { renderIconSlot, type IconType } from "../Icon/index.js"; import { OverflowList } from "../OverflowList/index.js"; import { useLayer } from "../Layer/useLayer.js"; import { useTooltip } from "../Tooltip/index.js"; import { useAnnounce } from "../hooks/useAnnounce.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, sizeVars, typeScaleVars } from "../theme/tokens.stylex.js"; import type { SearchableItem, SearchSource } from "../Typeahead/types.js"; import { mergeProps } from "../utils/index.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; // Re-export status types for convenience export type { InputStatus as TokenizerStatus, InputStatusType as TokenizerStatusType } from "../Field/index.js"; // ============================================================================= // Types // ============================================================================= /** * Change metadata for onChange callback. */ export type TokenizerChange = { item: T; type: 'add'; } | { item: T; type: 'create'; } | { item: T; type: 'remove'; } | { type: 'reorder'; }; export type TokenizerSize = 'sm' | 'md' | 'lg'; /** * Controls overflow behavior when tokens exceed the available width. * - `'none'`: All tokens wrap normally (default). * - `'unfocusedInline'`: Shows a single line with "+ N more" when unfocused, expands inline on focus. * - `'unfocusedLayer'`: Shows a single line with "+ N more" when unfocused, expands as an overlay on focus. */ export type TokenizerOverflowBehavior = 'none' | 'unfocusedInline' | 'unfocusedLayer'; /** * Imperative handle for Tokenizer handleRef. */ export interface TokenizerHandle { /** Focus the typeahead input. */ focus(): void; /** Blur the typeahead input. */ blur(): void; } export interface TokenizerProps extends Omit, 'onChange'> { /** 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 items. */ value: T[]; /** * The HTML name attribute for form submissions. When set, hidden inputs * carry one entry per selected item's id under this name. */ htmlName?: string; /** Callback when selection changes. Includes change metadata. */ onChange: (items: T[], change: TokenizerChange) => void; /** Render function for dropdown items. Default: TypeaheadItem. */ renderItem?: (item: T) => ReactNode; /** Render function for selected tokens. Default: Token with label + onRemove. */ renderToken?: (item: T, onRemove: () => void) => ReactNode; /** Max number of selections. */ maxEntries?: number; /** Placeholder text (shown when no tokens selected). */ placeholder?: string; /** Show results on focus before typing. @default false */ hasEntriesOnFocus?: boolean; /** Max dropdown items. @default 10 */ maxMenuItems?: number; /** Fixed dropdown width in pixels. Never shrinks below the input width. */ menuWidth?: 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. * * With `hasCreate`, the "Create" entry rides on the search results, so it * also waits for the threshold. * * @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 tokenizer is disabled. When set together with * `isDisabled`, the tokenizer shows a tooltip with this text on hover and * keyboard focus, and the input stays focusable (via `aria-disabled`) so the * reason is discoverable by keyboard and assistive technology. Input stays * blocked. * * Use this instead of wrapping a disabled tokenizer in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. */ disabledMessage?: string; /** Show clear button (clears all tokens). @default false */ hasClear?: boolean; /** * Content to display at the end of the input row. * Useful for buttons, result counts, or other controls. */ endContent?: ReactNode; /** Auto-focus on mount. @default false */ hasAutoFocus?: boolean; /** Input size. @default 'md' */ size?: TokenizerSize; /** * Controls how tokens overflow when the container is too narrow. * - `'none'`: Tokens wrap to multiple lines (default). * - `'unfocusedInline'`: Single line with "+ N more" when unfocused; expands inline on focus. * - `'unfocusedLayer'`: Single line with "+ N more" when unfocused; expands as overlay on focus. * @default 'none' */ tokenOverflowBehavior?: TokenizerOverflowBehavior; /** * 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; /** * Allow users to create new tokens from free-text input. * When true, pressing Enter with text in the input commits the typed value * as a new token — even if the search source returned no results. * @default false */ hasCreate?: boolean; /** Query change callback. */ onChangeQuery?: (query: string) => void; /** Fires when focus enters the tokenizer from outside. */ onFocus?: (e: React.FocusEvent) => void; /** Fires when focus leaves the tokenizer entirely. */ onBlur?: (e: React.FocusEvent) => void; /** Ref forwarded to the root field element. */ ref?: React.Ref; /** Imperative handle ref for focus/blur control. */ handleRef?: React.Ref; } // ============================================================================= // Styles // ============================================================================= // How far the end lane sits from the field's inline-end border, named once // because the input's reserve is derived from the same value. const END_LANE_INSET = spacingVars['--spacing-2']; const styles = { wrapper: { kVAEAm: "x1n2onr6", kwnvtZ: "x1a02dak", kOIVth: "xzye2dw", kkrTdU: "x1ed109x x16khyan", kZKoxP: "xt7dq6l", $$css: true as const }, wrapperWithTokens: { k8WAf4: "x1x7cmac", kg3NbH: "xx2oswg", khm7nJ: "xajebq8", $$css: true as const }, token: { k1xSpc: "x78zum5", kmuXW: "x2lah0s", $$css: true as const }, endSection: { kVAEAm: "x10l6tqk", kt4wiu: "x72tfeb", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", kmuXW: "x2lah0s", $$css: true as const }, inputAtMax: { kzqmXN: "xnalus7", k7Eaqz: "xeuugli", kUk6DE: "x1somy94", kmVPX3: "x1717udv", kSiTet: "xg01cxk", kVAEAm: "x10l6tqk", $$css: true as const }, inputCompact: { k7Eaqz: "x1fns5xo", kUk6DE: "xfoc8du", kzqmXN: "xnalus7", kZCmMZ: "x2a0j69", $$css: true as const }, truncatedWrapper: { kwnvtZ: "xozqiw3", kVQacm: "xb3r6kr", $$css: true as const }, layerPopover: { kzqmXN: "x1xgy17j", $$css: true as const } }; const sizeStyles = { sm: { kAzted: "x1gdqauq", $$css: true as const }, md: { kAzted: "x1uogy3y", $$css: true as const }, lg: { kAzted: "x1xf4nls", $$css: true as const } }; const endSectionSizeStyles = { sm: { k87sOh: "x1968x6z", k3aq6I: "x1cb1t30", $$css: true as const }, md: { k87sOh: "xkmsrut", k3aq6I: "x1cb1t30", $$css: true as const }, lg: { k87sOh: "x1oysc8t", k3aq6I: "x1cb1t30", $$css: true as const } }; const truncatedSizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; const layerPlaceholderSizeStyles = { sm: { kZKoxP: "x6k0iem", $$css: true as const }, md: { kZKoxP: "x1ueg155", $$css: true as const }, lg: { kZKoxP: "xssyfek", $$css: true as const } }; // ============================================================================= // Component // ============================================================================= // Sentinel prefix for creatable items — used to distinguish // "Create: X" suggestions from real search results. const CREATABLE_ID_PREFIX = '__xds_create__'; /** * Multi-select input with token chips and typeahead search. * * Composes BaseTypeahead for search and Token for selected items. * Tokens render inline before the text input. Selecting an item adds a token * and clears the query. Backspace on empty input removes the last token. * * @example * ``` * const [members, setMembers] = useState([]); * { * setMembers(items); * if (change.type === 'add') { * console.log('Added:', change.item.label); * } * }} * placeholder="Search people..." * /> * setTags(items)} * renderToken={(item, onRemove) => ( * * )} * maxEntries={5} * /> * ``` */ /** * The field's inline-end lane: the busy Spinner, then `endContent`, then the * clear button — pinned to the field's first row while tokens wrap below it. * * A separate component so that subscribing to the busy state re-renders THIS * and nothing else. Subscribed from `Tokenizer`, a search transition * re-rendered every selected token twice — twenty tokens meant forty renders * per search for one glyph none of them contain. * * Renders nothing when the lane would be empty, so `useEndLaneReserve` sees no * element, publishes no width, and the input keeps its full content box. */ function EndLane({ lane, laneRef, laneXStyle, loadingLabel, hasStaticContent, children }: { lane: BusyIndicatorLane; laneRef: (node: HTMLElement | null) => void; laneXStyle: stylex.StyleXStyles[]; loadingLabel: string; hasStaticContent: boolean; children: ReactNode; }) { const isBusy = useIsBusy(lane); if (!isBusy && !hasStaticContent) { return null; } return
{isBusy && } {children}
; } export function Tokenizer({ label, isLabelHidden = false, description, isRequired = false, isOptional = false, status, statusVariant = 'attached', startIcon, labelTooltip, searchSource, value, onChange, renderItem, renderToken, maxEntries, placeholder, hasEntriesOnFocus, maxMenuItems, menuWidth, minQueryLength, emptySearchResultsText, isDisabled = false, htmlName, disabledMessage, hasClear = false, endContent, hasAutoFocus, size: sizeProp, tokenOverflowBehavior = 'none', debounceMs, hasCreate = false, onChangeQuery, onFocus, onBlur, width, xstyle, className, style, 'data-testid': testId, ref, handleRef }: TokenizerProps) { const t = useTranslator(); const size = useSize(sizeProp, 'md'); const inputId = useId(); const descriptionId = useId(); const statusMessageId = useId(); const inputRef = useRef(null); const wrapperRef = useRef(null); // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the input wrapper and the typeahead input stays // perceivable via aria-disabled instead of the disabled attribute. Input is // blocked by the isDisabled guards in BaseTypeahead and handleWrapperClick. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', // The wrapper is not naturally focusable; focusin bubbles up from the // input, so always attach focus listeners. focusTrigger: 'always', isEnabled: showsDisabledMessage }); useImperativeHandle(handleRef, () => ({ focus() { inputRef.current?.focus(); }, blur() { inputRef.current?.blur(); } })); // Focus-within state for overflow truncation // Reported by BaseTypeahead so the indicator can live in this field's own // end lane, beside endContent and the clear button. // The base owns the busy state; this field only paints it. Held in a store // rather than this component's state — held here, a search transition // re-rendered every selected token twice, for a glyph no token contains. // See busyIndicatorLane.tsx. const busyLane = useMemo(() => createBusyIndicatorLane(), []); // What sits in the end lane varies most here — a spinner, arbitrary // `endContent`, a clear button, or all three — so its width is measured // rather than assumed, and the input reserves it. const [laneRef, laneReserve] = useEndLaneReserve(END_LANE_INSET); // The half of the lane's contents this component knows about. The busy half // is the leaf's own business — folding it in here would mean reading the // busy state during this render, which is exactly the re-render the store // exists to avoid. const hasStaticEndLane = Boolean(endContent || hasClear && value.length > 0 && !isDisabled); const [isFocusedWithin, setIsFocusedWithin] = useState(false); const isTruncated = !isFocusedWithin && tokenOverflowBehavior !== 'none' && value.length > 0; // Layer for unfocusedLayer mode — promotes expanded content to the top layer // so it isn't clipped by ancestor overflow. const isLayerMode = tokenOverflowBehavior === 'unfocusedLayer'; const layer = useLayer({ mode: 'context' }); const layerContentRef = useRef(null); // Anchor the layer to the placeholder element const placeholderRef = useCallback((el: HTMLElement | null) => { if (isLayerMode) { layer.ref(el); } }, [isLayerMode, layer]); // For the layer variant, focus can be in either the placeholder or the // popover content. We track both to decide when focus has truly left. const isFocusInTokenizer = useCallback((target: Node | null): boolean => { if (!target) { return false; } if (wrapperRef.current?.contains(target)) { return true; } if (layerContentRef.current?.contains(target)) { return true; } // Also check the popover element itself (the layer wrapper) const popoverEl = document.getElementById(layer.id); if (popoverEl?.contains(target)) { return true; } return false; }, [layer.id]); const handleFocusCapture = useCallback((e: React.FocusEvent) => { const comingFromOutside = !isFocusInTokenizer(e.relatedTarget); setIsFocusedWithin(true); if (isLayerMode) { layer.show(); } if (comingFromOutside) { onFocus?.(e); // Redirect to the input so the user doesn't have to tab through // every token remove button. if (e.target !== inputRef.current) { inputRef.current?.focus(); } } }, [isLayerMode, layer, isFocusInTokenizer, onFocus]); const handleBlurCapture = useCallback((e: React.FocusEvent) => { if (!isFocusInTokenizer(e.relatedTarget)) { setIsFocusedWithin(false); onBlur?.(e); if (isLayerMode) { layer.hide(); } } }, [isLayerMode, layer, isFocusInTokenizer, onBlur]); const isAtMax = maxEntries != null && value.length >= maxEntries; // Filter out already-selected items from search results const selectedIds = useMemo(() => new Set(value.map(item => item.id)), [value]); const filteredSource: SearchSource = useMemo(() => ({ search: async (query: string) => { const results = await searchSource.search(query); return results.filter(item => !selectedIds.has(item.id)); }, bootstrap: async () => { const results = await searchSource.bootstrap(); return results.filter(item => !selectedIds.has(item.id)); } }), [searchSource, selectedIds]); /** * The "Create X" entry. It is derived from the typed text, not fetched for * it, so it is offered through `__queryEntries` rather than appended to the * search results — which is what keeps it available when the query is too * short to search. `minQueryLength` is there to avoid a fetch too broad to * be worth making; creating `QA` costs no fetch, and a field that can * create it should not stop being able to. */ const createEntries = useCallback((query: string, results: T[]): T[] => { const trimmed = query.trim(); if (!hasCreate || trimmed === '') { return []; } const alreadyExists = selectedIds.has(trimmed) || results.some(item => item.label.toLowerCase() === trimmed.toLowerCase()); if (alreadyExists) { return []; } return [{ id: `${CREATABLE_ID_PREFIX}${trimmed}`, label: `Create "${trimmed}"`, auxiliaryData: { __createdValue: trimmed } } as unknown as T]; }, [hasCreate, selectedIds]); const emptySource: SearchSource = useMemo(() => ({ search: async () => [], bootstrap: async () => [] }), []); // Announce token add/remove politely via the persistent live region. // Tokens previously appeared and disappeared silently — Backspace on an // empty input removes the trailing token, and the per-token remove buttons // gave no audible feedback either. const announce = useAnnounce(); // Handle adding an item — detect creatable synthetic items const handleAdd = useCallback((item: T | null) => { if (!item) { return; } if (isAtMax) { return; } // Detect "Create: X" synthetic items from the creatable source if (hasCreate && typeof item.id === 'string' && item.id.startsWith(CREATABLE_ID_PREFIX)) { const createdValue = item.id.slice(CREATABLE_ID_PREFIX.length); if (selectedIds.has(createdValue)) { return; } const base = { id: createdValue, label: createdValue }; const realItem = base as T; const newItems = [...value, realItem]; onChange(newItems, { item: realItem, type: 'create' }); announce(t('@astryx.tokenizer.tokenAdded', { label: createdValue })); return; } if (selectedIds.has(item.id)) { return; } const newItems = [...value, item]; onChange(newItems, { item, type: 'add' }); announce(t('@astryx.tokenizer.tokenAdded', { label: item.label })); }, [value, onChange, isAtMax, selectedIds, hasCreate, announce, t]); // Handle removing an item. Single removal path: both Backspace on an empty // input and the per-token remove buttons route through here, so the // announcement covers both. const handleRemove = useCallback((item: T) => { const newItems = value.filter(v => v.id !== item.id); onChange(newItems, { item, type: 'remove' }); announce(t('@astryx.tokenizer.tokenRemoved', { label: item.label })); inputRef.current?.focus(); }, [value, onChange, announce, t]); // Handle clearing all items const handleClearAll = useCallback(() => { if (value.length === 0) { return; } // Report the last item as removed (convention) const lastItem = value[value.length - 1]; onChange([], { item: lastItem, type: 'remove' }); inputRef.current?.focus(); }, [value, onChange]); // Handle backspace on empty input — remove last token const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Backspace' && e.currentTarget.value === '' && value.length > 0) { e.preventDefault(); const lastItem = value[value.length - 1]; handleRemove(lastItem); } }, [value, handleRemove]); // Click wrapper to focus input const handleWrapperClick = useCallback(() => { if (!isDisabled) { if (isLayerMode) { // The input always lives in the popover. Show it and focus. layer.show(); setIsFocusedWithin(true); // The input is already mounted in the popover (not conditional), // so we can focus it directly. inputRef.current?.focus(); } else { inputRef.current?.focus(); } } }, [isDisabled, isLayerMode, layer]); const ariaDescribedBy = [description ? descriptionId : null, status?.message ? statusMessageId : null, showsDisabledMessage ? disabledMessageTooltip.describedBy : null].filter(Boolean).join(' ') || undefined; const sizeStyle = sizeStyles[size]; // Render tokens const tokens = value.map(item => { const onRemoveItem = () => handleRemove(item); if (renderToken) { return {renderToken(item, onRemoveItem)} ; } return ; }); // Self-authored position styles (positioning: 'custom' below): explicit // anchor() insets pin the expanded layer over the field itself. // `left` is physical, so this popover does not yet mirror in RTL — // known follow-up from #3389. const popoverOverrideStyle: React.CSSProperties = { top: 'anchor(top)', left: 'anchor(start)' }; const wrapperContent =
{ wrapperRef.current = el; // Anchor + hover/focus listeners for the disabled-message tooltip. // Handlers are gated internally by isEnabled, so attaching // unconditionally is safe. disabledMessageTooltip.ref(el); }} role="group" aria-label={label} onClick={handleWrapperClick} onFocusCapture={handleFocusCapture} onBlurCapture={handleBlurCapture} data-testid={testId} {...mergeProps(themeProps('tokenizer', { size, status: status?.type, disabled: isDisabled ? 'disabled' : null }), stylex.props(inputWrapperStyles.base, styles.wrapper, value.length > 0 && styles.wrapperWithTokens, isTruncated ? truncatedSizeStyles[size] : sizeStyle, isTruncated && styles.truncatedWrapper, isDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type]))}> {startIcon && 0)) << 0]}> {renderIconSlot(startIcon, { size: 'sm', color: 'secondary' })} } {isTruncated ? +{items.length} more }> {tokens} : tokens} {/* 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. */} 0 ? styles.inputCompact : undefined, // Not for the collapsed states above: those give the input no width // to pad, and `inputAtMax` zeroes its padding outright. // Applied whether or not a lane is up: the reserve resolves to // zero until the lane publishes a width, so this does not need to // know about the busy state. !(isAtMax || isTruncated) && laneReserve]} /> {htmlName != null && value.map(item => )} {endContent} {hasClear && value.length > 0 && !isDisabled && { e.stopPropagation(); handleClearAll(); }} />}
; let tokenizerContent: ReactNode; if (isLayerMode) { const placeholderSizeStyle = layerPlaceholderSizeStyles[size]; tokenizerContent = <>
0 && styles.wrapperWithTokens, placeholderSizeStyle, isTruncated && styles.truncatedWrapper, isDisabled && inputWrapperStyles.disabled, status && inputStatusBorderStyles[status.type], status && !isDisabled && inputStatusHoverShadowStyles[status.type], status && inputStatusFocusWithinStyles[status.type]))}> {isTruncated && <> {startIcon && renderIconSlot(startIcon, { size: 'sm', color: 'secondary' })} +{items.length} more }> {tokens} }
{layer.render(
{wrapperContent}
, { positioning: 'custom', xstyle: styles.layerPopover, style: popoverOverrideStyle })} ; } else { tokenizerContent = wrapperContent; } return {tokenizerContent} {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)} ; } Tokenizer.displayName = 'Tokenizer';