HextaUI

ベータVercel の UI SDK 0.1.0-beta.1 上に構築。API は変わる可能性があります。

Token input

Vercel の UI SDK 上のテキストフィールド。入力した文字に人・ファイル・コマンドのトークンを混ぜられ、@ と / で候補を出します。

依存関係
  • @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

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

UI SDK は ui-sdk というカスケードレイヤーに少しだけ独自の CSS を追加します。自分のクラスが優先されるよう、グローバルスタイルシートの先頭、Tailwind の import より上でこのレイヤーを宣言してください。

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

値はテキストとトークンの配列です。各トークンには type、アプリ用の value、人が読む label があります。エディターが内容・選択・元に戻すを管理するので描画は任せ、見た目を変えるときは renderToken を渡します。渡さない場合は、ラベルと削除ボタンを表示します。

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

renderToken
└── TokenInputToken
    └── TokenInputTokenRemove

input 内の TriggerMenu は、@ や / などのトリガーが入力されるとトークンを提案します。トークンをコードから渡す場合は不要です。

ファイルは @、スキルは先頭で / を入力します。ファイルは読み込み中は保留になり、送信すると updateToken() で各スキルが実行中、次に完了になります。Enter で送信、Shift+Enter で改行します。

"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 はトークン上での Backspace の動作を決めます。whole は削除、select はまず選択、revert はテキストに戻します。

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

createTokenInputController() のコントローラーで、コードからトークンを挿入・更新・削除できます。resolve に Promise を渡すと、完了までトークンを保留中にします。

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

name を付けると、各トークンを [@label](type:value) と書いたテキストを送信します。required は空の送信を防ぎ、リセットで 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 }

submitShortcut="mod+enter" では Enter で改行し、Command か Control+Enter で送信します。日本語や中国語で書く多くの人が好む設定です。

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

codec はトークンとテキストを相互に変換します。ここでは <@id> の形式です。copy="label" ではコピー時に読める文字列を書き出し、貼り戻すとトークンが復元されます。

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

無効な input はフォーカスも変更もできず、削除ボタンを隠し、何も送信しません。

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 }

readOnly ではテキストにフォーカスでき、選択でき、送信もされますが、入力・貼り付け・ドロップでは変わりません。

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 }

エディターはページの方向に従い、右から左のテキスト内でもトークンはひとかたまりのままです。

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 }
キーアクション
BackspaceDeletetokenDeletion に従って、キャレットの前後のトークンを削除します。
←→1 回の操作でキャレットがトークンを飛び越えます。トークンの内側では止まりません。
↑↓メニューが開いている間、候補を移動します。
Enter送信します。メニューが開いているときはハイライト中の候補を選びます。
Shift + Enter改行します。
⌘ + EnterCtrl + EntersubmitShortcut が mod+enter のとき送信します。その場合 Enter は改行です。
⌘ + ZCtrl + Z直前の操作を元に戻します。入力はまとめられ、貼り付けとトークンの変更はそれぞれ 1 ステップです。
Esc候補を閉じます。

エディターは複数行のテキストボックスです。中のトリガーメニューが aria-autocomplete と aria-haspopup を追加し、矢印キーで候補を移動する間もフォーカスはテキストに残ります。各トークンは「routes.ts ファイル」のようにラベルと種類で読み上げられ、挿入や削除も通知されます。エディターには aria-label か周囲のラベルで名前を付けます。

Backspace でキーボードからトークンを削除できるため、削除ボタンはスクリーンリーダーから隠れています。タッチ画面ではヒット領域が広がり、保留中のトークンはモーションが許可されているときだけ点滅し、選択中のトークンは強制カラーモードでシステムのハイライト枠が付きます。

各パーツは同名の UI SDK パーツをラップし、render やパーツの状態を受け取る関数にもできる className を含むすべての props を受け付けます。

プロパティ型デフォルト
value
TokenInputValue–
defaultValue
TokenInputValue–
onValueChange入力、貼り付け、メニュー、元に戻す、コントローラー呼び出しで実行されます。details には理由と削除されたトークンが入ります。
(value, details) => void–
onSubmit送信ショートカットで実行されます。メニューが開いているときや IME で変換中のときは実行されません。
(value, details) => void–
submitShortcut
"enter" | "mod+enter""enter"
tokenDeletion
"whole" | "select" | "revert""whole"
renderTokenトークンを描画します。既定はラベルと削除ボタン付きの TokenInputToken です。
(token: Token) => ReactNode–
copy
"codec" | "label""codec"
codec
TokenCodec–
getTokenDescriptionトークンがラベル以外にスクリーンリーダーへ伝える内容。
(token: Token) => string–
history
{ limit, groupDelay }–
name
string–
form
string–
required
booleanfalse
disabled
booleanfalse
readOnly
booleanfalse
controllercreateTokenInputController() で作成します。
TokenInputController–
プロパティ型デフォルト
placeholderinput が空の間、aria-placeholder で表示されます。
string–
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="token-input-editor"CSS でエディターを指定するときに使います。
data-emptyinput が空の間に付きます。
data-disabled無効のときに存在します。
data-readonly読み取り専用のときに付与されます。
属性説明
data-slot="token-input-token"CSS でトークンを指定するときに使います。
data-token-typeトークンの種類。file や mention など。
data-selectedトークンが選択範囲にある間、または削除のために選択されている間に付きます。
data-pendingトークンの読み込み中に付きます。
data-unresolved曖昧なメンションなど、トークンにユーザーの対応が必要な間に付きます。

自分のトークンを削除し、フォーカスはエディターに残します。children を渡さなければ Tabler の X アイコンを描画し、input が無効または読み取り専用の間は隠れます。

プロパティ型デフォルト
triggersメニューを開く文字、それぞれが挿入するトークンの種類、その項目。
TriggerDefinition[]–
onSelect項目を選んだときに実行されます。Promise を返すとトークンを保留中として挿入します。
(value, details) => void | Promise–
autoHighlight
booleantrue
loop
booleantrue
プロパティ型デフォルト
align
"start" | "center" | "end""start"
sideOffset
number6
collisionPadding
number8
属性説明
data-slot="trigger-menu-content"CSS でポップアップを指定するときに使います。
data-trigger現在のマッチのトリガー(@ など)。リストごとに見た目を変えられます。
data-starting-styleポップアップが表示アニメーション中に付きます。
data-ending-styleポップアップが非表示アニメーション中に付きます。
プロパティ型デフォルト
value
T–
label内容がプレーンテキストでないときに、挿入・照合に使うテキスト。
string–
disabled
booleanfalse
属性説明
data-highlightedハイライト中の項目に付きます。フォーカスはエディターに残ります。
data-disabled項目を選べないときに付きます。