HextaUI

BetaHecho sobre el UI SDK 0.1.0-beta.1 de Vercel. Su API puede cambiar.

Token input

Un campo de texto sobre el UI SDK de Vercel que mezcla texto con tokens para personas, archivos y comandos, con sugerencias con @ y /.

Depende de
  • @tabler/icons-react
  • @ui-sdk/core
  • @ui-sdk/react
Thanks ​Inès Moreau mention​, can you pair with
"use client"

import { IconAt } from "@tabler/icons-react"

import {
  TokenInput,
  TokenInputEditor,
  TokenInputToken,
  TokenInputTokenRemove,
  TriggerMenu,
  TriggerMenuContent,
  TriggerMenuEmpty,
  TriggerMenuItem,
  TriggerMenuList,
  type TokenInputValue,
  type TriggerMenuDataItem,
} from "@/components/ui/ui-sdk-token-input"

const people = [
  { value: "hollis", label: "Hollis Grant", keywords: ["design"] },
  { value: "ines", label: "Inès Moreau", keywords: ["engineering"] },
  { value: "kofi", label: "Kofi Mensah", keywords: ["engineering"] },
  { value: "wren", label: "Wren Takahashi", keywords: ["product"] },
  { value: "tomasz", label: "Tomasz Wójcik", keywords: ["support"] },
]

const draft: TokenInputValue = [
  "Thanks ",
  { type: "mention", value: "ines", label: "Inès Moreau" },
  ", can you pair with ",
]

function UiSdkTokenInputDemo() {
  return (
    <div className="w-full max-w-md">
      <TokenInput
        defaultValue={draft}
        renderToken={(token) => (
          <TokenInputToken>
            <IconAt aria-hidden="true" />
            {token.label}
            <TokenInputTokenRemove />
          </TokenInputToken>
        )}
      >
        <TokenInputEditor
          aria-label="Message"
          placeholder="Type @ to mention someone"
        />
        <TriggerMenu triggers={[{ char: "@", type: "mention", items: people }]}>
          <TriggerMenuContent>
            <TriggerMenuEmpty>No one matches.</TriggerMenuEmpty>
            <TriggerMenuList>
              {(state) =>
                (state.items as TriggerMenuDataItem[]).map((item) => (
                  <TriggerMenuItem
                    key={item.value}
                    value={item.value}
                    label={item.label}
                  >
                    <span
                      aria-hidden="true"
                      className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-muted-foreground"
                    >
                      {item.label.charAt(0)}
                    </span>
                    {item.label}
                  </TriggerMenuItem>
                ))
              }
            </TriggerMenuList>
          </TriggerMenuContent>
        </TriggerMenu>
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-token-input.json

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

UI SDK añade un poco de CSS propio en una capa de cascada llamada ui-sdk. Declara esa capa al principio de tu hoja de estilos global, encima del import de Tailwind, para que tus clases ganen.

@layer ui-sdk;

@import "tailwindcss";
import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"
const draft: TokenInputValue = [
  "Explain ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
]

<TokenInput defaultValue={draft}>
  <TokenInputEditor aria-label="Prompt" />
</TokenInput>

Un valor es un array de texto y tokens. Cada token tiene un type, un value para tu app y un label que leen las personas. El editor gestiona su contenido, selección y deshacer, así que deja que renderice y pasa renderToken para cambiar cómo se ve un token. Sin él, cada token muestra su label y un botón de quitar.

TokenInput
├── TokenInputEditor
└── TriggerMenu
    ├── TriggerMenuHint
    └── TriggerMenuContent
        ├── TriggerMenuEmpty
        └── TriggerMenuList
            └── TriggerMenuItem
                └── TriggerMenuItemIcon

renderToken
└── TokenInputToken
    └── TokenInputTokenRemove

Un TriggerMenu dentro del input sugiere tokens cuando alguien escribe un disparador como @ o /. Omítelo cuando tu código aporte los tokens.

Escribe @ para archivos o / al principio para skills. Un archivo queda pendiente mientras carga, y al enviar cada skill se marca como en curso y luego como hecha con updateToken(). Enter envía y Shift+Enter añade una línea.

"use client"

import * as React from "react"
import {
  IconArrowUp,
  IconCheck,
  IconFile,
  IconLoader2,
  IconTerminal2,
} from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  createTokenInputController,
  TokenInput,
  TokenInputEditor,
  TokenInputToken,
  TokenInputTokenRemove,
  toLabel,
  TriggerMenu,
  TriggerMenuContent,
  TriggerMenuEmpty,
  TriggerMenuHint,
  TriggerMenuItem,
  TriggerMenuItemIcon,
  TriggerMenuList,
  type Token,
  type TokenInputValue,
  type TriggerMenuDataItem,
} from "@/components/ui/ui-sdk-token-input"

const files = [
  { value: "src/app.tsx", label: "app.tsx", keywords: ["src"] },
  { value: "src/routes.ts", label: "routes.ts", keywords: ["src"] },
  { value: "src/styles.css", label: "styles.css", keywords: ["src"] },
  { value: "README.md", label: "README.md" },
  { value: "package.json", label: "package.json" },
]

const skills = [
  {
    value: "search",
    label: "search",
    arguments: [{ name: "query", hint: "<query>", required: true }],
  },
  { value: "review", label: "review" },
  { value: "summarize", label: "summarize" },
]

const loadFile = () => new Promise<void>((resolve) => setTimeout(resolve, 800))

function UiSdkTokenInputPrompt() {
  const [prompt] = React.useState(createTokenInputController)
  const [running, setRunning] = React.useState(false)
  const [sent, setSent] = React.useState<string | null>(null)
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(
    undefined
  )

  React.useEffect(() => () => clearTimeout(timer.current), [])

  function send(value: TokenInputValue) {
    if (running || !prompt.getLabel().trim()) return
    setRunning(true)
    const skillTokens = value.filter(
      (segment): segment is Token =>
        typeof segment !== "string" && segment.type === "skill"
    )
    for (const skill of skillTokens) {
      prompt.updateToken(skill.id!, { data: { ...skill.data, run: "running" } })
    }
    timer.current = setTimeout(() => {
      for (const skill of skillTokens) {
        prompt.updateToken(skill.id!, { data: { ...skill.data, run: "done" } })
      }
      setSent(toLabel(prompt.getValue()))
      prompt.clear()
      setRunning(false)
    }, 1200)
  }

  return (
    <div className="flex w-full max-w-md flex-col gap-2">
      <TokenInput
        controller={prompt}
        onSubmit={send}
        renderToken={(token) => (
          <TokenInputToken>
            {token.type === "skill" ? (
              <IconTerminal2 aria-hidden="true" />
            ) : (
              <IconFile aria-hidden="true" />
            )}
            {token.type === "skill" ? "/" + token.label : token.label}
            {token.data?.run === "running" ? (
              <IconLoader2
                aria-hidden="true"
                className="motion-safe:animate-spin"
              />
            ) : null}
            {token.data?.run === "done" ? (
              <IconCheck aria-hidden="true" className="text-success" />
            ) : null}
            <TokenInputTokenRemove />
          </TokenInputToken>
        )}
      >
        <div className="flex flex-col gap-2">
          <TokenInputEditor
            aria-label="Prompt"
            placeholder="Ask anything. Type @ for files or / for skills"
          />
          <Button
            type="button"
            size="icon-sm"
            className="self-end"
            aria-label={running ? "Sending" : "Send"}
            disabled={running}
            onClick={() => send(prompt.getValue())}
          >
            {running ? (
              <IconLoader2
                aria-hidden="true"
                className="motion-safe:animate-spin"
              />
            ) : (
              <IconArrowUp aria-hidden="true" />
            )}
          </Button>
        </div>
        <TriggerMenu
          triggers={[
            { char: "@", type: "file", items: files },
            { char: "/", boundary: "start", type: "skill", items: skills },
          ]}
          onSelect={(value, details) =>
            details.trigger === "@" ? loadFile() : undefined
          }
        >
          <TriggerMenuHint />
          <TriggerMenuContent>
            <TriggerMenuEmpty>No matches.</TriggerMenuEmpty>
            <TriggerMenuList>
              {(state) =>
                (state.items as TriggerMenuDataItem[]).map((item) => (
                  <TriggerMenuItem
                    key={item.value}
                    value={item.value}
                    label={item.label}
                  >
                    <TriggerMenuItemIcon>
                      {state.trigger === "/" ? (
                        <IconTerminal2 aria-hidden="true" />
                      ) : (
                        <IconFile aria-hidden="true" />
                      )}
                    </TriggerMenuItemIcon>
                    {item.label}
                    {item.value !== item.label ? (
                      <span className="ms-auto truncate font-mono text-xs text-muted-foreground">
                        {item.value}
                      </span>
                    ) : null}
                  </TriggerMenuItem>
                ))
              }
            </TriggerMenuList>
          </TriggerMenuContent>
        </TriggerMenu>
      </TokenInput>
      <p role="status" className="min-h-5 text-sm text-muted-foreground">
        {sent ? "Sent: " + sent : null}
      </p>
    </div>
  )
}

export { UiSdkTokenInputPrompt }

tokenDeletion define qué hace Retroceso en un token. whole lo quita, select primero lo selecciona y revert lo vuelve texto.

"use client"

import * as React from "react"
import { IconFile } from "@tabler/icons-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
  TokenInput,
  TokenInputEditor,
  TokenInputToken,
  type TokenDeletion,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Explain ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
  " in two sentences.",
]

const modes: { value: TokenDeletion; label: string }[] = [
  { value: "whole", label: "Whole" },
  { value: "select", label: "Select" },
  { value: "revert", label: "Revert" },
]

function UiSdkTokenInputDeletion() {
  const [deletion, setDeletion] = React.useState<TokenDeletion>("whole")

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ToggleGroup
        aria-label="Backspace removes"
        value={[deletion]}
        onValueChange={(next) => {
          if (next[0]) setDeletion(next[0] as TokenDeletion)
        }}
        variant="outline"
        size="sm"
      >
        {modes.map((mode) => (
          <ToggleGroupItem key={mode.value} value={mode.value}>
            {mode.label}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <TokenInput
        key={deletion}
        tokenDeletion={deletion}
        defaultValue={draft}
        renderToken={(token) => (
          <TokenInputToken>
            <IconFile aria-hidden="true" />
            {token.label}
          </TokenInputToken>
        )}
      >
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputDeletion }

Un controlador de createTokenInputController() inserta, actualiza y quita tokens desde el código. Pasa una promesa como resolve para dejar un token pendiente hasta que se resuelva.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  createTokenInputController,
  TokenInput,
  TokenInputEditor,
  type Token,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Compare ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
  " with ",
]

const loadFile = () => new Promise<void>((resolve) => setTimeout(resolve, 800))

function UiSdkTokenInputController() {
  const [prompt] = React.useState(createTokenInputController)
  const firstToken = () =>
    prompt
      .getValue()
      .find((segment): segment is Token => typeof segment !== "string")

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <div
        className="flex flex-wrap gap-2"
        onMouseDown={(event) => event.preventDefault()}
      >
        <Button
          variant="outline"
          size="sm"
          onClick={() =>
            prompt.insert(
              { type: "file", value: "package.json", label: "package.json" },
              { at: "end", resolve: loadFile() }
            )
          }
        >
          Attach file
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => {
            const token = firstToken()
            if (token) prompt.updateToken(token.id!, { label: token.value })
          }}
        >
          Show path
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => {
            const token = firstToken()
            if (token) prompt.removeToken(token.id!)
          }}
        >
          Remove first
        </Button>
        <Button variant="ghost" size="sm" onClick={() => prompt.undo()}>
          Undo
        </Button>
        <Button variant="ghost" size="sm" onClick={() => prompt.clear()}>
          Clear
        </Button>
      </div>
      <TokenInput controller={prompt} defaultValue={draft}>
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputController }

Con un name, el input envía su texto con cada token escrito como [@label](type:value). required bloquea un envío vacío, y reiniciar vuelve a defaultValue.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Explain ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
  " in two sentences.",
]

function UiSdkTokenInputForm() {
  const [submitted, setSubmitted] = React.useState<string | null>(null)

  return (
    <form
      className="flex w-full max-w-md flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault()
        setSubmitted(String(new FormData(event.currentTarget).get("prompt")))
      }}
      onReset={() => setSubmitted(null)}
    >
      <TokenInput name="prompt" required defaultValue={draft}>
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
      <div className="flex gap-2">
        <Button type="submit" size="sm">
          Submit
        </Button>
        <Button type="reset" variant="outline" size="sm">
          Reset
        </Button>
      </div>
      <p role="status" className="text-sm wrap-anywhere text-muted-foreground">
        {submitted ? (
          <>
            Submitted prompt=
            <span className="font-mono text-foreground">{submitted}</span>
          </>
        ) : (
          "Nothing submitted yet."
        )}
      </p>
    </form>
  )
}

export { UiSdkTokenInputForm }

Con submitShortcut="mod+enter", Enter añade una línea y Command o Control+Enter envía, algo que prefieren muchas personas que escriben en japonés y chino.

"use client"

import * as React from "react"

import { Kbd } from "@/components/ui/kbd"
import {
  TokenInput,
  TokenInputEditor,
  toLabel,
} from "@/components/ui/ui-sdk-token-input"

function UiSdkTokenInputSubmitShortcut() {
  const [sent, setSent] = React.useState<string | null>(null)

  return (
    <div className="flex w-full max-w-md flex-col gap-2">
      <TokenInput
        submitShortcut="mod+enter"
        onSubmit={(value) => setSent(toLabel(value))}
      >
        <TokenInputEditor
          aria-label="Message"
          placeholder="Write a message. Enter adds a line."
        />
      </TokenInput>
      <p
        role="status"
        className="flex flex-wrap items-center gap-1.5 text-sm wrap-anywhere text-muted-foreground"
      >
        {sent ? (
          "Sent: " + sent
        ) : (
          <>
            Send with <Kbd>⌘</Kbd>
            <Kbd>Enter</Kbd> or <Kbd>Ctrl</Kbd>
            <Kbd>Enter</Kbd>
          </>
        )}
      </p>
    </div>
  )
}

export { UiSdkTokenInputSubmitShortcut }

Un codec convierte los tokens en texto y viceversa, aquí como <@id>. Con copy="label", copiar escribe lo que se lee, y al pegar de vuelta se restauran los tokens.

"use client"

import * as React from "react"

import {
  fromText,
  TokenInput,
  TokenInputEditor,
  toText,
  type TokenCodec,
} from "@/components/ui/ui-sdk-token-input"

const chat: TokenCodec = {
  format: (token) => "<@" + token.value + ">",
  parse: (text) =>
    text.split(/(<@[\w.-]+>)/).map((part) => {
      const id = /^<@([\w.-]+)>$/.exec(part)?.[1]
      return id ? { type: "mention", value: id, label: "@" + id } : part
    }),
}

const source = "Ping <@hollis> and <@ines> about the launch"

function UiSdkTokenInputCodec() {
  const [text, setText] = React.useState(source)

  return (
    <div className="flex w-full max-w-md flex-col gap-2">
      <TokenInput
        codec={chat}
        copy="label"
        defaultValue={fromText(source, chat)}
        onValueChange={(value) => setText(toText(value, chat))}
      >
        <TokenInputEditor aria-label="Message" />
      </TokenInput>
      <p className="font-mono text-xs wrap-anywhere text-muted-foreground">
        {text}
      </p>
    </div>
  )
}

export { UiSdkTokenInputCodec }

Un input deshabilitado no recibe foco ni cambia, oculta los botones de quitar y no envía nada.

import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Summarize ",
  { type: "file", value: "README.md", label: "README.md" },
  " for the release notes.",
]

function UiSdkTokenInputDisabled() {
  return (
    <div className="w-full max-w-md">
      <TokenInput disabled defaultValue={draft}>
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputDisabled }

Con readOnly, el texto sigue recibiendo foco, se puede seleccionar y se envía, pero escribir, pegar y soltar no lo cambian.

import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "Summarize ",
  { type: "file", value: "README.md", label: "README.md" },
  " for the release notes.",
]

function UiSdkTokenInputReadOnly() {
  return (
    <div className="w-full max-w-md">
      <TokenInput readOnly defaultValue={draft}>
        <TokenInputEditor aria-label="Prompt" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputReadOnly }

El editor sigue la dirección de la página, y un token sigue siendo una pieza dentro de texto de derecha a izquierda.

import {
  TokenInput,
  TokenInputEditor,
  type TokenInputValue,
} from "@/components/ui/ui-sdk-token-input"

const draft: TokenInputValue = [
  "اشرح ",
  { type: "file", value: "src/routes.ts", label: "routes.ts" },
  " في جملتين.",
]

function UiSdkTokenInputRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md">
      <TokenInput defaultValue={draft}>
        <TokenInputEditor aria-label="الطلب" />
      </TokenInput>
    </div>
  )
}

export { UiSdkTokenInputRtl }
KeyAcción
BackspaceDeleteQuita el token antes o después del cursor, según tokenDeletion.
←→Mueve el cursor más allá de un token de una pulsación. Nunca se detiene dentro de uno.
↑↓Recorre las sugerencias mientras el menú está abierto.
EnterEnvía. Con el menú abierto, elige la sugerencia resaltada.
Shift + EnterInicia una nueva línea.
⌘ + EnterCtrl + EnterEnvía cuando submitShortcut es mod+enter. Enter empieza entonces una línea nueva.
⌘ + ZCtrl + ZDeshace el último paso. Lo escrito se agrupa, y pegar o cambiar tokens son pasos propios.
EscCierra las sugerencias.

El editor es un cuadro de texto multilínea. Un menú de disparadores dentro añade aria-autocomplete y aria-haspopup, y el foco sigue en el texto mientras las flechas recorren las sugerencias. Cada token se lee como su label y su tipo, como “routes.ts archivo”, y se anuncia al insertarlo o quitarlo. Nombra el editor con aria-label o con una etiqueta alrededor.

Los botones de quitar se ocultan a los lectores de pantalla, ya que Retroceso quita un token desde el teclado. Su área táctil crece en pantallas táctiles, un token pendiente solo pulsa si se permite el movimiento, y un token seleccionado recibe un borde de resaltado del sistema en el modo de colores forzados.

Cada parte envuelve la parte de UI SDK del mismo nombre y acepta todas sus props, incluidas render y un className que puede ser una función del estado de la parte.

PropTipoPredeterminado
value
TokenInputValue–
defaultValue
TokenInputValue–
onValueChangeSe ejecuta al escribir, pegar, usar menús, deshacer y con llamadas del controlador. details indica el motivo y los tokens quitados.
(value, details) => void–
onSubmitSe ejecuta con el atajo de envío, salvo que haya un menú abierto o un método de entrada componiendo.
(value, details) => void–
submitShortcut
"enter" | "mod+enter""enter"
tokenDeletion
"whole" | "select" | "revert""whole"
renderTokenRenderiza un token. Por defecto, un TokenInputToken con su label y un botón de quitar.
(token: Token) => ReactNode–
copy
"codec" | "label""codec"
codec
TokenCodec–
getTokenDescriptionLo que un token dice a los lectores de pantalla además de su label.
(token: Token) => string–
history
{ limit, groupDelay }–
name
string–
form
string–
required
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
controllerDe createTokenInputController().
TokenInputController–
PropTipoPredeterminado
placeholderSe muestra con aria-placeholder mientras el input está vacío.
string–
render
ReactElement | (props, state) => ReactElement–
AtributoDescripción
data-slot="token-input-editor"Para apuntar al editor en CSS.
data-emptyPresente mientras el input está vacío.
data-disabledPresente cuando está deshabilitado.
data-readonlyPresente cuando es de solo lectura.
AtributoDescripción
data-slot="token-input-token"Para apuntar a un token en CSS.
data-token-typeEl tipo del token, como file o mention.
data-selectedPresente mientras el token está en la selección o seleccionado para quitarlo.
data-pendingPresente mientras el token carga.
data-unresolvedPresente mientras el token necesita a la persona, como una mención ambigua.

Quita su token y mantiene el foco en el editor. Renderiza un icono X de Tabler salvo que pases children, y se oculta mientras el input está deshabilitado o es de solo lectura.

PropTipoPredeterminado
triggersLos caracteres que abren el menú, el tipo de token que inserta cada uno y sus elementos.
TriggerDefinition[]–
onSelectSe ejecuta al elegir un elemento. Devuelve una promesa para insertar el token como pendiente.
(value, details) => void | Promise–
autoHighlight
booleantrue
loop
booleantrue
PropTipoPredeterminado
align
"start" | "center" | "end""start"
sideOffset
number6
collisionPadding
number8
AtributoDescripción
data-slot="trigger-menu-content"Para apuntar al popup en CSS.
data-triggerEl disparador de la coincidencia actual, como @, para que cada lista pueda verse distinta.
data-starting-stylePresente mientras el popup se anima al entrar.
data-ending-stylePresente mientras el popup se anima al salir.
PropTipoPredeterminado
value
T–
labelEl texto que se inserta y compara, cuando el contenido no es texto plano.
string–
disabled
booleanfalse
AtributoDescripción
data-highlightedPresente en el elemento resaltado. El foco sigue en el editor.
data-disabledPresente cuando el elemento no se puede elegir.