HextaUI

BetaFeito sobre o UI SDK 0.1.0-beta.1 da Vercel. A API pode mudar.

Token input

Um campo de texto sobre o UI SDK da Vercel que mistura texto digitado com tokens de pessoas, arquivos e comandos, com sugestões de @ e /.

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

Adiciona o componente, os tokens de tema do HextaUI e quaisquer componentes do HextaUI dos quais ele depende.

O UI SDK adiciona um pouco de CSS próprio em uma camada de cascata chamada ui-sdk. Declare essa camada no topo da sua folha de estilos global, acima do import do Tailwind, para que suas classes vençam.

@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>

Um valor é um array de texto e tokens. Cada token tem um type, um value para seu app e um label que as pessoas leem. O editor cuida do conteúdo, da seleção e do desfazer, então deixe-o renderizar e passe renderToken para mudar a aparência de um token. Sem ele, cada token mostra o label e um botão de remover.

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

renderToken
└── TokenInputToken
    └── TokenInputTokenRemove

Um TriggerMenu dentro do input sugere tokens quando alguém digita um gatilho como @ ou /. Deixe-o de fora quando seu código fornecer os tokens.

Digite @ para arquivos ou / no início para skills. Um arquivo fica pendente enquanto carrega, e ao enviar cada skill é marcada como em execução e depois concluída com updateToken(). Enter envia e Shift+Enter adiciona uma linha.

"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 o que o Backspace faz em um token. whole o remove, select o seleciona primeiro e revert o transforma de volta em 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 }

Um controlador de createTokenInputController() insere, atualiza e remove tokens pelo código. Passe uma promise como resolve para manter um token pendente até ela terminar.

"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 }

Com um name, o input envia o texto com cada token escrito como [@label](type:value). required bloqueia envio vazio, e o reset volta para 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 }

Com submitShortcut="mod+enter", Enter adiciona uma linha e Command ou Control+Enter envia, o que muitas pessoas que escrevem em japonês e chinês preferem.

"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 }

Um codec transforma tokens em texto e de volta, aqui como <@id>. Com copy="label", copiar escreve o que as pessoas leem, e colar de volta ainda restaura os 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 }

Um input desativado não recebe foco nem muda, oculta os botões de remover e não envia 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 }

Com readOnly, o texto continua focável, selecionável e enviado, mas digitar, colar e soltar não o alteram.

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 }

O editor segue a direção da página, e um token continua sendo uma peça só dentro de texto da direita para a esquerda.

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 }
TeclaAção
BackspaceDeleteRemove o token antes ou depois do cursor, conforme tokenDeletion.
←→Move o cursor para além de um token com um toque. Ele nunca para dentro de um.
↑↓Percorre as sugestões enquanto o menu está aberto.
EnterEnvia. Com o menu aberto, escolhe a sugestão destacada.
Shift + EnterInicia uma nova linha.
⌘ + EnterCtrl + EnterEnvia quando submitShortcut é mod+enter. Enter então começa uma nova linha.
⌘ + ZCtrl + ZDesfaz o último passo. A digitação é agrupada, e colagens e mudanças de token são passos próprios.
EscFecha as sugestões.

O editor é uma caixa de texto multilinha. Um menu de gatilhos dentro dele adiciona aria-autocomplete e aria-haspopup, e o foco fica no texto enquanto as setas percorrem as sugestões. Cada token é lido como label e tipo, como “routes.ts arquivo”, e inserir ou remover um é anunciado. Nomeie o editor com aria-label ou com um rótulo ao redor.

Os botões de remover ficam ocultos para leitores de tela, já que o Backspace remove um token pelo teclado. A área de toque cresce em telas sensíveis ao toque, um token pendente só pulsa quando o movimento é permitido, e um token selecionado ganha uma borda de destaque do sistema no modo de cores forçadas.

Cada parte envolve a parte do UI SDK de mesmo nome e aceita todas as suas props, incluindo render e um className que pode ser uma função do estado da parte.

PropTipoPadrão
value
TokenInputValue–
defaultValue
TokenInputValue–
onValueChangeExecuta ao digitar, colar, usar menus, desfazer e em chamadas do controlador. details traz o motivo e os tokens removidos.
(value, details) => void–
onSubmitExecuta no atalho de envio, a menos que um menu esteja aberto ou um método de entrada esteja compondo.
(value, details) => void–
submitShortcut
"enter" | "mod+enter""enter"
tokenDeletion
"whole" | "select" | "revert""whole"
renderTokenRenderiza um token. Por padrão, um TokenInputToken com o label e um botão de remover.
(token: Token) => ReactNode–
copy
"codec" | "label""codec"
codec
TokenCodec–
getTokenDescriptionO que um token diz aos leitores de tela além do label.
(token: Token) => string–
history
{ limit, groupDelay }–
name
string–
form
string–
required
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
controllerDe createTokenInputController().
TokenInputController–
PropTipoPadrão
placeholderExibido por aria-placeholder enquanto o input está vazio.
string–
render
ReactElement | (props, state) => ReactElement–
AtributoDescrição
data-slot="token-input-editor"Para selecionar o editor no CSS.
data-emptyPresente enquanto o input está vazio.
data-disabledPresente quando desabilitado.
data-readonlyPresente quando somente leitura.
AtributoDescrição
data-slot="token-input-token"Para selecionar um token no CSS.
data-token-typeO tipo do token, como file ou mention.
data-selectedPresente enquanto o token está na seleção ou selecionado para remoção.
data-pendingPresente enquanto o token carrega.
data-unresolvedPresente enquanto o token precisa da pessoa, como uma menção ambígua.

Remove seu token e mantém o foco no editor. Renderiza um ícone X do Tabler, a menos que você passe children, e se oculta enquanto o input está desativado ou somente leitura.

PropTipoPadrão
triggersOs caracteres que abrem o menu, o tipo de token que cada um insere e seus itens.
TriggerDefinition[]–
onSelectExecuta quando um item é escolhido. Retorne uma promise para inserir o token como pendente.
(value, details) => void | Promise–
autoHighlight
booleantrue
loop
booleantrue
PropTipoPadrão
align
"start" | "center" | "end""start"
sideOffset
number6
collisionPadding
number8
AtributoDescrição
data-slot="trigger-menu-content"Para selecionar o popup no CSS.
data-triggerO gatilho da correspondência atual, como @, para que cada lista possa ter outra aparência.
data-starting-stylePresente enquanto o popup anima a entrada.
data-ending-stylePresente enquanto o popup anima a saída.
PropTipoPadrão
value
T–
labelO texto inserido e comparado, quando o conteúdo não é texto simples.
string–
disabled
booleanfalse
AtributoDescrição
data-highlightedPresente no item destacado. O foco fica no editor.
data-disabledPresente quando o item não pode ser escolhido.