"use client" import * as React from "react" import { X } from "lucide-react" import { cn } from "../../lib/utils" /** * `FormTokenField` is replacement for `@wordpress/components`' `FormTokenField` - 9 call sites - * and `TokenItem`, the type those sites import alongside it - 5 files. * * AN ADAPTER, NOT A RENAME, and the reason is the `onChange` payload. WP does NOT hand the handler * the token that was just added or removed. It hands it the WHOLE NEW ARRAY: * * onChange={(tokens) => field.onChange(tokens.map(toId))} * * Every call site here already builds its whole form value from that array, and one of them reads * `tokens[tokens.length - 1]` to find what was just picked. Emitting a single token instead would * type-check, render fine, and quietly wipe the field down to one entry on the next keystroke. * * The array also keeps its ELEMENT shapes. Tokens that arrived as objects go back out as the same * objects; only newly typed ones are added as strings. That is why the call sites all guard with * `typeof token === 'string' ? token: token.value`, and why they still can. * * `label` and `help` are rendered here, as WP renders them, so a conversion stays a one-line import * swap instead of 9 sites growing 9 slightly different label rows. * * `maxLength` is a TOKEN COUNT, not a character count - WP's meaning, kept deliberately. No call * site passes it, but reading it as characters is the obvious wrong guess. * * The `__experimental*` props are kept with WP's names because they all still DO something here. * `__next40pxDefaultSize` and `__nextHasNoMarginBottom` are not accepted: they were opt-ins to * WordPress's own styling, and accepting-and-ignoring them would leave dead WP-era props on every * site forever. Call sites drop them, as they did for InputControl and ToggleControl. * * `ui/badge` is not used for the chips. Its `[&>svg]:pointer-events-none` and `overflow-hidden` are * right for a static badge and wrong for a chip whose remove control must be clickable. */ /** WP's token shape. Sites pass plain strings; the object form arrives from saved values. */ export type TokenItem = { value: string status?: "error" | "success" | "validating" title?: string isBorderless?: boolean onMouseEnter?: () => void onMouseLeave?: () => void } export interface FormTokenFieldMessages { added?: string removed?: string remove?: string __experimentalInvalid?: string } export interface FormTokenFieldProps { label?: React.ReactNode help?: React.ReactNode value?: (string | TokenItem)[] suggestions?: string[] /** WP's contract: the FULL NEW ARRAY of tokens, not the one that changed. */ onChange?: (tokens: (string | TokenItem)[]) => void /** Fires on every keystroke. Two sites use it to drive a remote search. */ onInputChange?: (input: string) => void placeholder?: string disabled?: boolean /** Maximum number of TOKENS, as in WP. Not a character limit. */ maxLength?: number maxSuggestions?: number tokenizeOnSpace?: boolean messages?: FormTokenFieldMessages className?: string id?: string "aria-label"?: string /** Show the suggestion list on focus, before anything is typed. */ __experimentalExpandOnFocus?: boolean /** Highlight the first match so Enter picks it. */ __experimentalAutoSelectFirstMatch?: boolean /** WP's "separate with commas" hint under the field. Defaults on, as in WP. */ __experimentalShowHowTo?: boolean /** Return false to refuse the typed text. */ __experimentalValidateInput?: (input: string) => boolean } const DEFAULT_MESSAGES: Required = { added: "Item added.", removed: "Item removed.", remove: "Remove item", __experimentalInvalid: "Invalid item", } /** Tokens are strings or objects. Everything that compares or displays goes through here. */ function tokenText(token: string | TokenItem): string { return typeof token === "string" ? token : token.value } function FormTokenField({ label, help, value = [], suggestions = [], onChange, onInputChange, placeholder, disabled, maxLength, maxSuggestions = 100, tokenizeOnSpace = false, messages, className, id, "aria-label": ariaLabel, __experimentalExpandOnFocus = false, __experimentalAutoSelectFirstMatch = false, __experimentalShowHowTo = true, __experimentalValidateInput, }: FormTokenFieldProps) { // useId emits colons, which are not valid in a CSS selector, so they are stripped - the same // reasoning as ui/input-control. const generated = React.useId().replace(/:/g, "") const fieldId = id ?? `form-token-field-${generated}` const listId = `${fieldId}-list` const helpId = help ? `${fieldId}-help` : undefined const howToId = __experimentalShowHowTo ? `${fieldId}-howto` : undefined // Not cn: that merges Tailwind classes, and these are ids. const describedBy = [helpId, howToId].filter(Boolean).join(" ") || undefined const [input, setInput] = React.useState("") const [focused, setFocused] = React.useState(false) const [activeIndex, setActiveIndex] = React.useState(-1) // Escape closes the list until the next keystroke or focus, even where it expands on focus. const [dismissed, setDismissed] = React.useState(false) const [announcement, setAnnouncement] = React.useState("") const inputRef = React.useRef(null) const text = { ...DEFAULT_MESSAGES, ...messages } const atLimit = maxLength !== undefined && value.length >= maxLength const taken = React.useMemo( () => new Set(value.map((token) => tokenText(token).toLowerCase())), [value] ) const matches = React.useMemo(() => { const query = input.trim().toLowerCase() return suggestions .filter((suggestion) => !taken.has(suggestion.toLowerCase())) .filter((suggestion) => query === "" || suggestion.toLowerCase().includes(query)) .slice(0, maxSuggestions) }, [suggestions, taken, input, maxSuggestions]) const open = !disabled && !atLimit && matches.length > 0 && !dismissed && (input.trim() !== "" || (__experimentalExpandOnFocus && focused)) // Auto-select points Enter at the first match without moving a visible cursor there first. const highlighted = open && activeIndex < 0 && __experimentalAutoSelectFirstMatch ? 0 : activeIndex function addToken(raw: string) { const next = raw.trim() if (!next || atLimit) return if (taken.has(next.toLowerCase())) { setInput("") onInputChange?.("") return } if (__experimentalValidateInput && !__experimentalValidateInput(next)) { setAnnouncement(text.__experimentalInvalid) return } onChange?.([...value, next]) setInput("") onInputChange?.("") setActiveIndex(-1) setAnnouncement(text.added) } function removeAt(index: number) { if (disabled) return onChange?.(value.filter((_, i) => i !== index)) setAnnouncement(text.removed) } function handleKeyDown(event: React.KeyboardEvent) { if (event.key === "Enter") { event.preventDefault() if (open && highlighted >= 0 && matches[highlighted]) addToken(matches[highlighted]) else addToken(input) return } if (event.key === "," || (tokenizeOnSpace && event.key === " ")) { if (input.trim()) { event.preventDefault() addToken(input) } return } // Backspace on an empty input removes the last token, as in WP. if (event.key === "Backspace" && input === "" && value.length > 0) { event.preventDefault() removeAt(value.length - 1) return } if (event.key === "ArrowDown" && open) { event.preventDefault() setActiveIndex((current) => (current + 1) % matches.length) return } if (event.key === "ArrowUp" && open) { event.preventDefault() setActiveIndex((current) => (current <= 0 ? matches.length - 1 : current - 1)) return } if (event.key === "Escape") { setDismissed(true) setActiveIndex(-1) setInput("") onInputChange?.("") } } return (
{label ? ( ) : null}
{value.map((token, index) => { const content = tokenText(token) const invalid = typeof token !== "string" && token.status === "error" return ( {content} ) })} {/* No type attribute, deliberately. index.css gives `#portal-root input[type="text"]` the full 42px field shell - own border, white background, 14px padding, width 100% - and carves WordPress's token input back out by class name. An id selector beats a utility class, so a `type="text"` here would render a whole second field inside this one and push the chips onto their own line. Omitting the attribute means the selector never matches; the input is still text. */} = 0 ? `${listId}-option-${highlighted}` : undefined } value={input} placeholder={value.length === 0 ? placeholder : undefined} disabled={disabled || atLimit} onChange={(event) => { setInput(event.target.value) setDismissed(false) setActiveIndex(-1) onInputChange?.(event.target.value) }} onKeyDown={handleKeyDown} onFocus={() => { setFocused(true) setDismissed(false) }} onBlur={() => { setFocused(false) setActiveIndex(-1) }} // bg-card, not bg-transparent: index.css forces `background-color: #fff` on every input // in the portal root, and that beats a plain utility class. Matching the wrapper is the // way to stop a white rectangle showing through it. className="min-w-[6rem] flex-1 bg-card py-1 text-sm text-ink placeholder:text-faint outline-none disabled:cursor-not-allowed" />
{open ? (
    {matches.map((suggestion, index) => (
  • { event.preventDefault() addToken(suggestion) }} onMouseEnter={() => setActiveIndex(index)} className={cn( "cursor-pointer px-2 py-1.5", index === highlighted && "bg-accent" )} > {/* The size sits on a span, not the li: index.css sets a font-size on every li in the portal root and an id selector beats a utility class, so `text-sm` on the li itself would do nothing and the list would read a size larger than the field it belongs to. */} {suggestion}
  • ))}
) : null}
{help ? (

{help}

) : null} {__experimentalShowHowTo ? (

Separate with commas or the Enter key.

) : null} {announcement}
) } export { FormTokenField }