Find items by typing, rank search results, highlight matches, bring back earlier messages and format text in the right language.

Use createTypeahead to find items as someone types, fuzzyScore to rank search results, matchRanges or matchSegments to highlight matches, and createInputHistory to bring back earlier messages with the arrow keys.

pageLocale and userLocale read the page's or user's language for matching and formatting.

Find an Item by Typing

Start with a few labels. createTypeahead() remembers the keys you type and returns the index of the matching item:

import { createTypeahead } from "@ui-sdk/core"

const fruits = ["Apple", "Pear", "Plum"]
const typeahead = createTypeahead()

typeahead("p", fruits, 0) // 1 (Pear).
typeahead("l", fruits, 1) // 2 (Plum).

The third argument is the current index. Pressing “p” searches after Apple; pressing “l” soon after continues the query as “pl”. A search with no match returns -1.

Try it below. Type “p” repeatedly to cycle through the matching fruit, or type “pl” quickly to find Plum.

import * as React from "react"
import {
  createTypeahead,
  definePrimitive,
  elements,
  idPart,
  isTypeaheadKey,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

type FruitPickerState = { fruit: string }

const fruitPicker = definePrimitive({
  name: "fruit-picker",
  state: (): FruitPickerState => ({ fruit: "Apple" }),
  api: (context) => {
    const typeahead = createTypeahead()
    return {
      keydown(event: KeyboardEvent) {
        const fruits = elements(context, "option").map((option) => option.key)
        const current = fruits.indexOf(context.store.get().fruit)
        let next = -1
        if (event.key === "ArrowDown") {
          next = Math.min(current + 1, fruits.length - 1)
        } else if (event.key === "ArrowUp") {
          next = Math.max(current - 1, 0)
        } else if (isTypeaheadKey(event)) {
          next = typeahead(event.key, fruits, current)
        }
        if (next === -1) {
          return
        }
        event.preventDefault()
        context.store.set({ fruit: fruits[next] })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}>({
      attrs: (state, part) => ({
        role: "listbox",
        tabindex: "0",
        "aria-activedescendant": `${part.id}-${idPart(state.fruit)}`,
      }),
      on: { keydown: (event, { api }) => api.keydown(event) },
    }),
    option: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        id: `${part.id}-${idPart(part.key)}`,
        "aria-selected": String(state.fruit === part.key),
      }),
    }),
  }),
})

const parts = createParts(fruitPicker, { root: "div", option: "div" })

const FruitPicker = parts.Root

const FruitPickerOption = parts.Option

const fruits = ["Apple", "Banana", "Cherry", "Peach", "Pear", "Plum", "Quince"]

function Example() {
  return (
    <FruitPicker aria-label="Fruit">
      {fruits.map((fruit) => (
        <FruitPickerOption key={fruit} value={fruit}>
          {fruit}
        </FruitPickerOption>
      ))}
    </FruitPicker>
  )
}

export {
  Example,
  fruitPicker,
  FruitPicker,
  FruitPickerOption,
  type FruitPickerState,
}

In a keydown handler, use isTypeaheadKey(event) from @ui-sdk/core to check whether a key types a character. It leaves arrows and shortcuts such as Ctrl+C out of the search. Focus and Keys shows how to connect the result to roving focus.

What an Item Is Called

An item's label may include a shortcut hint. labelOf(el) reads the name and skips text marked aria-hidden:

<button id="copy-command">Copy <kbd aria-hidden="true">⌘C</kbd></button>
import { labelOf } from "@ui-sdk/core"

const button = document.getElementById("copy-command")!
labelOf(button) // "Copy".

Use those labels when you call your typeahead search. If an item has no visible text, labelOf uses its aria-label instead. An icon button with aria-label="Delete" can be found by typing “d”.

Try “c” for Copy or Cut, and “d” for Delete:

import * as React from "react"
import {
  createTypeahead,
  definePrimitive,
  elements,
  idPart,
  isTypeaheadKey,
  labelOf,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { Trash2Icon } from "lucide-react"

type EditListState = { command: string }

const editList = definePrimitive({
  name: "edit-list",
  state: (): EditListState => ({ command: "copy" }),
  api: (context) => {
    const typeahead = createTypeahead()
    return {
      keydown(event: KeyboardEvent) {
        if (!isTypeaheadKey(event)) {
          return
        }
        const commands = elements(context, "command")
        const current = commands.findIndex(
          (command) => command.key === context.store.get().command
        )
        const next = typeahead(
          event.key,
          commands.map((command) => labelOf(command.el)),
          current
        )
        if (next === -1) {
          return
        }
        event.preventDefault()
        context.store.set({ command: commands[next].key })
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}>({
      attrs: (state, part) => ({
        role: "listbox",
        tabindex: "0",
        "aria-activedescendant": `${part.id}-${idPart(state.command)}`,
      }),
      on: { keydown: (event, { api }) => api.keydown(event) },
    }),
    command: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        id: `${part.id}-${idPart(part.key)}`,
        "aria-selected": String(state.command === part.key),
      }),
    }),
  }),
})

const parts = createParts(editList, { root: "div", command: "div" })

const EditList = parts.Root

const EditListCommand = parts.Command

function Example() {
  return (
    <EditList aria-label="Edit">
      <EditListCommand value="copy">
        Copy{" "}
        <kbd aria-hidden="true" data-slot="kbd">
          ⌘C
        </kbd>
      </EditListCommand>
      <EditListCommand value="cut">
        Cut{" "}
        <kbd aria-hidden="true" data-slot="kbd">
          ⌘X
        </kbd>
      </EditListCommand>
      <EditListCommand value="paste">
        Paste{" "}
        <kbd aria-hidden="true" data-slot="kbd">
          ⌘V
        </kbd>
      </EditListCommand>
      <EditListCommand value="delete" aria-label="Delete">
        <Trash2Icon aria-hidden="true" />
      </EditListCommand>
    </EditList>
  )
}

export { EditList, EditListCommand, editList, type EditListState, Example }

Names with Spaces

By default, keys typed within 500 milliseconds of each other form one query. Pass a longer timeout if people need more time:

const typeahead = createTypeahead(1500)

While a query is active, Space continues it, so “new y” can find New York. typeahead.active() tells you whether a query is in progress. Use that to decide whether Space should continue searching or activate the current item.

typeahead.reset() clears the query immediately. For example, call it when your component handles Escape:

if (event.key === "Escape") {
  typeahead.reset()
}

Try “new y” below. Enter picks the city; Space picks it when no search is active. Escape clears the search, and the checkbox gives you more time between keys.

Console
import * as React from "react"
import {
  createTypeahead,
  definePrimitive,
  elements,
  idPart,
  isTypeaheadKey,
} from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type CityPickerOptions = {
  delay?: number
  onPick?: (city: string) => void
}

type CityPickerState = { city: string; picked: string | null }

const cityPicker = definePrimitive({
  name: "city-picker",
  options: { delay: "number", onPick: "callback" },
  state: (_options: CityPickerOptions): CityPickerState => ({
    city: "Nairobi",
    picked: null,
  }),
  api: (context) => {
    let delay = context.options().delay
    let typeahead = createTypeahead(delay)
    context.onOptions((options) => {
      if (options.delay === delay) {
        return
      }
      delay = options.delay
      typeahead.reset()
      typeahead = createTypeahead(delay)
    })
    return {
      keydown(event: KeyboardEvent) {
        const cities = elements(context, "city").map((city) => city.key)
        const { city } = context.store.get()
        if (event.key === "Escape") {
          return typeahead.reset()
        }
        if (
          event.key === "Enter" ||
          (event.key === " " && !typeahead.active())
        ) {
          event.preventDefault()
          context.store.set({ picked: city })
          return context.options().onPick?.(city)
        }
        if (!isTypeaheadKey(event)) {
          return
        }
        const next = typeahead(event.key, cities, cities.indexOf(city))
        event.preventDefault()
        if (next !== -1) {
          context.store.set({ city: cities[next] })
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot<{}>({
      attrs: (state, part) => ({
        role: "listbox",
        tabindex: "0",
        "aria-activedescendant": `${part.id}-${idPart(state.city)}`,
      }),
      on: { keydown: (event, { api }) => api.keydown(event) },
    }),
    city: slot<{ value: string }>({
      props: { value: "string" },
      attrs: (state, part) => ({
        role: "option",
        id: `${part.id}-${idPart(part.key)}`,
        "aria-selected": String(state.city === part.key),
        "data-picked": state.picked === part.key ? "" : undefined,
      }),
    }),
  }),
})

const parts = createParts(cityPicker, { root: "div", city: "div" })

const CityPicker = parts.Root

const CityPickerCity = parts.City

const cities = [
  "Nairobi",
  "Naples",
  "New Delhi",
  "New Orleans",
  "New York",
  "Newark",
  "Nice",
]

function Example() {
  const [slow, setSlow] = React.useState(false)
  const log = useLog()
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <label data-slot="label">
          <input
            type="checkbox"
            checked={slow}
            onChange={(event) => setSlow(event.target.checked)}
          />
          Wait 1.5 seconds between keys
        </label>
      </div>
      <CityPicker
        aria-label="City"
        delay={slow ? 1500 : 500}
        onPick={(city) => log(`Picked ${city}`)}
      >
        {cities.map((city) => (
          <CityPickerCity key={city} value={city}>
            {city}
            <span aria-hidden="true" data-show="picked">
              ✓
            </span>
          </CityPickerCity>
        ))}
      </CityPicker>
    </div>
  )
}

export {
  CityPicker,
  CityPickerCity,
  cityPicker,
  type CityPickerOptions,
  type CityPickerState,
  Example,
}

Rank What Someone Searches For

A search field often needs to filter and sort a whole list. fuzzyScore(text, query) returns a score from 0 to 1. Keep results above zero, then put the best matches first:

import { fuzzyScore } from "@ui-sdk/core/text-match"

const commands = ["Open settings", "Settings", "Git push"]
const query = "set"

const ranked = commands
  .map((command) => ({ command, score: fuzzyScore(command, query) }))
  .filter(({ score }) => score > 0)
  .sort((a, b) => b.score - a.score)

ranked.map(({ command }) => command) // ["Settings", "Open settings"].

An exact match scores highest, followed by a match at the start of the label. Letters can also match in order, so “gp” finds “Git push”. An empty query gives every item a score of 1.

Try “gp”, “set” or “resu” below:

import * as React from "react"
import { definePrimitive } from "@ui-sdk/core"
import { fuzzyScore } from "@ui-sdk/core/text-match"
import { createParts } from "@ui-sdk/react/primitive"

type CommandSearchState = { query: string }

const commandSearch = definePrimitive({
  name: "command-search",
  state: (): CommandSearchState => ({ query: "" }),
  api: (context) => ({
    search: (query: string) => context.store.set({ query }),
  }),
  slots: (slot) => ({
    root: slot<{}, CommandSearchState>({
      state: (state) => ({ query: state.query }),
    }),
    input: slot({
      attrs: (state) => ({ type: "search", value: state.query }),
      on: {
        input: (event, { api }) =>
          api.search((event.target as HTMLInputElement).value),
      },
    }),
    list: slot({ attrs: () => ({ role: "list" }) }),
    command: slot({ attrs: () => ({ role: "listitem" }) }),
  }),
})

const parts = createParts(commandSearch, {
  root: "div",
  input: "input",
  list: "ul",
  command: "li",
})

const CommandSearch = parts.Root

const CommandSearchInput = parts.Input

const CommandSearchList = parts.List

const CommandSearchCommand = parts.Command

const commands = [
  "Git push",
  "Git pull",
  "Go to file",
  "Open settings",
  "Settings",
  "Résumé template",
  "Toggle sidebar",
]

function rank(query: string) {
  return commands
    .map((command) => ({ command, score: fuzzyScore(command, query) }))
    .filter(({ score }) => score > 0)
    .sort((a, b) => b.score - a.score)
}

function Example() {
  return (
    <CommandSearch>
      {({ query }) => (
        <>
          <CommandSearchInput
            aria-label="Search commands"
            placeholder="Search commands…"
          />
          <CommandSearchList>
            {rank(query).map(({ command, score }) => (
              <CommandSearchCommand key={command}>
                {command}
                <span data-slot="hint">{score.toFixed(2)}</span>
              </CommandSearchCommand>
            ))}
          </CommandSearchList>
        </>
      )}
    </CommandSearch>
  )
}

export {
  CommandSearch,
  commandSearch,
  CommandSearchCommand,
  CommandSearchInput,
  CommandSearchList,
  type CommandSearchState,
  Example,
}

Matching follows the user's language. Case and accents are ignored when that language treats them as equivalent, so someone typing “resume” can find “Résumé”.

Highlight What Matched

Once you have a result, show which part matched. matchRanges finds the ranges; matchSegments splits the original text into pieces you can render:

import { matchRanges, matchSegments } from "@ui-sdk/core/text-match"

const ranges = matchRanges("Settings", "set", "startsWith")
// [{ start: 0, end: 3 }].

matchSegments("Settings", ranges)
// [{ text: "Set", match: true }, { text: "tings", match: false }].

Render each matching segment inside a <mark> element. The original text stays intact, including its case and accents.

Choose the matching mode your search uses:

ModeWhat it highlights
"contains"The first occurrence of the query.
"startsWith"The query at the start of the label, ignoring leading whitespace.
"fuzzy"The whole query when it appears, preferring a word start; otherwise, its letters in order.

No match gives you an empty range array. The exported types are MatchRange, with start and end like slice takes, and MatchSegment, with text and match.

Change the mode below to see the difference:

import * as React from "react"
import { fuzzyScore, matchRanges, matchSegments } from "@ui-sdk/core/text-match"
import {
  CommandSearch,
  CommandSearchCommand,
  CommandSearchInput,
  CommandSearchList,
} from "./command-search"

type Mode = "contains" | "startsWith" | "fuzzy"

const commands = [
  "Git push",
  "Git pull",
  "Go to file",
  "Open settings",
  "Settings",
  "Résumé template",
  "Toggle sidebar",
]

function Highlight({
  text,
  query,
  mode,
}: {
  text: string
  query: string
  mode: Mode
}) {
  return matchSegments(text, matchRanges(text, query, mode)).map(
    (segment, index) =>
      segment.match ? <mark key={index}>{segment.text}</mark> : segment.text
  )
}

function matches(text: string, query: string, mode: Mode) {
  if (!query.trim()) {
    return true
  }
  return mode === "fuzzy"
    ? fuzzyScore(text, query) > 0
    : matchRanges(text, query, mode).length > 0
}

function Example() {
  const [mode, setMode] = React.useState<Mode>("fuzzy")
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <select
          aria-label="Match"
          value={mode}
          onChange={(event) => setMode(event.target.value as Mode)}
        >
          <option value="fuzzy">Fuzzy</option>
          <option value="contains">Contains</option>
          <option value="startsWith">Starts with</option>
        </select>
      </div>
      <CommandSearch>
        {({ query }) => (
          <>
            <CommandSearchInput
              aria-label="Search commands"
              placeholder="Search commands…"
            />
            <CommandSearchList>
              {commands
                .filter((command) => matches(command, query, mode))
                .map((command) => (
                  <CommandSearchCommand key={command}>
                    <span>
                      <Highlight text={command} query={query} mode={mode} />
                    </span>
                  </CommandSearchCommand>
                ))}
            </CommandSearchList>
          </>
        )}
      </CommandSearch>
    </div>
  )
}

export { Example }

The Page's Language

Use the page's language when you format something for the reader. pageLocale() reads the document's lang, with the browser's preferred language as a fallback:

import { pageLocale } from "@ui-sdk/core"

const price = new Intl.NumberFormat(pageLocale(), {
  style: "currency",
  currency: "EUR",
})

price.format(12.5)

For a section with its own lang, pass an element from that section to pageLocale(el). It reads the nearest lang attribute. Inside a primitive, bind is a good place to read it because the element is in the page.

userLocale() reads the browser's preferred language directly. The search helpers use this language when comparing what someone typed. If no locale is available, the helpers return undefined, and Intl uses its runtime's default.

Pick a language below to see prices and sorting change. In Swedish, Å and Ö sort after Z.

import * as React from "react"
import { definePrimitive, pageLocale, userLocale } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"

const priceList = definePrimitive({
  name: "price-list",
  state: () => ({}),
  api: () => ({}),
  slots: (slot) => ({
    root: slot({}),
    language: slot({
      bind: (el) => {
        el.textContent = `Page: ${pageLocale(el)}, you: ${userLocale()}`
      },
    }),
    price: slot<{ amount: number }>({
      props: { amount: "number" },
      bind: (el, part) => {
        const format = new Intl.NumberFormat(pageLocale(el), {
          style: "currency",
          currency: "EUR",
        })
        el.textContent = format.format(part.props.amount)
      },
    }),
    names: slot<{ names: string[] }>({
      props: { names: "list" },
      bind: (el, part) => {
        const locale = pageLocale(el)
        const sorted = [...part.props.names].sort(
          new Intl.Collator(locale).compare
        )
        el.textContent = new Intl.ListFormat(locale).format(sorted)
      },
    }),
  }),
})

const parts = createParts(priceList, {
  root: "div",
  language: "p",
  price: "p",
  names: "p",
})

const PriceList = parts.Root

const PriceListLanguage = parts.Language

const PriceListPrice = parts.Price

const PriceListNames = parts.Names

const languages = {
  "en-US": "English",
  "de-DE": "Deutsch",
  "sv-SE": "Svenska",
  "ar-EG": "العربية",
}

function Example() {
  const [lang, setLang] = React.useState("en-US")
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <select
          aria-label="Language"
          value={lang}
          onChange={(event) => setLang(event.target.value)}
        >
          {Object.entries(languages).map(([tag, name]) => (
            <option key={tag} value={tag}>
              {name}
            </option>
          ))}
        </select>
      </div>
      <div lang={lang}>
        <PriceList key={lang}>
          <PriceListPrice amount={1234.5} data-price />
          <PriceListNames names={["Örjan", "Zoë", "Åsa", "Anna"]} data-names />
          <PriceListLanguage data-language />
        </PriceList>
      </div>
    </div>
  )
}

export {
  Example,
  priceList,
  PriceList,
  PriceListLanguage,
  PriceListNames,
  PriceListPrice,
}

The same locale works with Intl.DateTimeFormat, Intl.ListFormat and Intl.Collator. Dates covers calendar dates.

Tags from Elsewhere

A server or an older system might give you en_US, while Intl expects en-US. toLocale normalizes these tags:

import { toLocale } from "@ui-sdk/core"

toLocale("en_US") // "en-US".
toLocale("de_DE.UTF-8") // "de-DE".
toLocale("??") // undefined.

It returns undefined for an invalid tag, so you can pass the result to Intl and let it use the default locale.

Try a few tags below:

import * as React from "react"
import { definePrimitive, toLocale } from "@ui-sdk/core"
import { Field, FieldControl, FieldLabel } from "@ui-sdk/react/field"
import { createParts } from "@ui-sdk/react/primitive"

type LanguageTagState = { tag: string }

const languageTag = definePrimitive({
  name: "language-tag",
  state: (): LanguageTagState => ({ tag: "en_US" }),
  api: (context) => ({
    change: (tag: string) => context.store.set({ tag }),
  }),
  slots: (slot) => ({
    root: slot<{}, { locale: string | undefined }>({
      state: (state) => ({ locale: toLocale(state.tag) }),
    }),
    input: slot({
      attrs: (state) => ({ value: state.tag, spellcheck: "false" }),
      on: {
        input: (event, { api }) =>
          api.change((event.target as HTMLInputElement).value),
      },
    }),
  }),
})

const parts = createParts(languageTag, { root: "div", input: "input" })

const LanguageTag = parts.Root

const LanguageTagInput = parts.Input

const date = new Date(Date.UTC(2026, 9, 21))

function Example() {
  return (
    <LanguageTag>
      {({ locale }) => (
        <>
          <Field>
            <FieldLabel>Language tag</FieldLabel>
            <FieldControl render={<LanguageTagInput />} />
          </Field>
          <p data-locale>
            {locale ? `Intl reads ${locale}` : "Not a language tag"}
          </p>
          <p data-date data-slot="hint">
            {new Intl.DateTimeFormat(locale, {
              dateStyle: "long",
              timeZone: "UTC",
            }).format(date)}
          </p>
        </>
      )}
    </LanguageTag>
  )
}

export {
  Example,
  languageTag,
  LanguageTag,
  LanguageTagInput,
  type LanguageTagState,
}

Properly Handle Input Method Editors

Chinese, Japanese, Korean, Vietnamese and many other languages are typed through an input method editor, or IME. It turns keystrokes into text in steps: typing "nihon" in Japanese shows にほん, then Space offers 日本, and Enter confirms it. Until then, the text is still being composed, and a field that acts on every keystroke acts on fragments.

Search as Someone Types

Every step of a composition fires an input event, so a search on each one runs for にh and にほn before the word is done. Skip the events isComposing(event) marks, and search once the input method commits, on compositionend:

import { isComposing } from "@ui-sdk/core"

field.addEventListener("input", (event) => {
  if (!isComposing(event)) search(field.value)
})
field.addEventListener("compositionend", () => search(field.value))

Chrome and Firefox end the composition after its last input event, so compositionend is what brings the committed text. On Android, Gboard composes English words too, so results there change a word at a time.

Console
import * as React from "react"
import { isComposing } from "@ui-sdk/core"
import { Field, FieldControl, FieldLabel } from "@ui-sdk/react/field"
import { useLog } from "./log"

function Example() {
  const log = useLog()
  const [handled, setHandled] = React.useState(true)
  return (
    <div data-slot="stack">
      <div data-slot="controls">
        <label data-slot="label">
          <input
            type="checkbox"
            checked={handled}
            onChange={(event) => setHandled(event.target.checked)}
          />
          Wait for the input method
        </label>
      </div>
      <Field>
        <FieldLabel>Search</FieldLabel>
        <FieldControl
          type="search"
          placeholder="Type a city…"
          onChange={(event) => {
            if (!handled || !isComposing(event)) {
              log(`Search “${event.target.value}”`)
            }
          }}
          onCompositionEnd={(event) =>
            handled && log(`Search “${event.currentTarget.value}”`)
          }
        />
      </Field>
    </div>
  )
}

export { Example }
Type with a Japanese or Chinese input method: the search waits for the text you confirm. Turn off the waiting to see a search for every step of the composition.

Enter to Send

Enter often confirms what an input method suggests, so a field that sends on Enter would send half-written text. Check isComposing(event) before acting on the key:

textarea.addEventListener("keydown", (event) => {
  if (event.key !== "Enter" || event.shiftKey || isComposing(event)) return
  event.preventDefault()
  send()
})

isComposing takes React's events too, so the same lines go in onChange and onKeyDown. It reads the event's isComposing, and counts key code 229 as composing, because Safari on macOS ends the composition before that last Enter arrives and reports it as not composing. No state to keep: every event says for itself whether the input method has it. Core's own components already handle both.

Bring Back Earlier Messages

Use createInputHistory to step through what someone sent before, the way a terminal or a chat box does. ArrowUp brings back the previous entry, ArrowDown the next one, and ArrowDown past the newest brings back what they were typing.

Create it in api with three functions: one returns the earlier entries, oldest first, and the other two read and write the field's value:

import { createInputHistory } from "@ui-sdk/core"

const history = createInputHistory({
  entries: () => options().history,
  read: () => store.get().value,
  write: (value) => store.set({ value }),
})

Then hand it the field's key presses. keydown returns true when it brought back an entry, so your own keys, like Enter to send, come after it:

root: slot({
  on: {
    keydown: (event, { api }) => {
      if (api.history.keydown(event)) return
      if (event.key === "Enter" && !event.shiftKey && !isComposing(event)) {
        event.preventDefault()
        api.send()
      }
    },
  },
}),
Console
import * as React from "react"
import { createInputHistory, definePrimitive, isComposing } from "@ui-sdk/core"
import { createParts } from "@ui-sdk/react/primitive"
import { useLog } from "./log"

type ComposerOptions = {
  history?: string[]
  onSend?: (message: string) => void
}

const composer = definePrimitive({
  name: "composer",
  options: { history: "value", onSend: "callback" },
  state: (_options: ComposerOptions) => ({ value: "" }),
  api: ({ store, options }) => {
    const write = (value: string) => store.set({ value })
    return {
      write,
      history: createInputHistory({
        entries: () => options().history,
        read: () => store.get().value,
        write,
      }),
      send() {
        const message = store.get().value.trim()
        if (message) {
          options().onSend?.(message)
          write("")
        }
      },
    }
  },
  slots: (slot) => ({
    root: slot({
      attrs: (state) => ({ value: state.value, rows: "3" }),
      on: {
        input: (event, { api }) =>
          api.write((event.target as HTMLTextAreaElement).value),
        keydown: (event, { api }) => {
          if (api.history.keydown(event)) {
            return
          }
          if (event.key !== "Enter" || event.shiftKey || isComposing(event)) {
            return
          }
          event.preventDefault()
          api.send()
        },
      },
    }),
  }),
})

const Composer = createParts(composer, { root: "textarea" }).Root

const earlier = [
  "Can you review the launch post?",
  "Agenda for Thursday:\n1. Docs\n2. Launch video\n3. Changelog",
  "Ship it.",
]

function Example() {
  const log = useLog()
  const [sent, setSent] = React.useState(earlier)
  return (
    <Composer
      aria-label="Message"
      placeholder="Write a message…"
      history={sent}
      onSend={(message) => {
        setSent([...sent, message])
        log(`Sent “${message}”`)
      }}
    />
  )
}

export { composer, Composer, type ComposerOptions, Example }
Press ArrowUp to bring back an earlier message, and ArrowDown to return to what you were typing. Send a message with Enter, and it's the first one ArrowUp brings back.

In a textarea, the arrow keys move between lines first. An entry comes back only when the caret is on the first line for ArrowUp, or the last line for ArrowDown, counting the lines a long message wraps onto. The caret goes to the end of the entry it brings back.

Edits to an entry you brought back stay while you move through the others. When entries changes, like after a send, it starts again from the newest. Call history.reset() to do the same yourself.

It leaves the arrow keys alone while a modifier is held, while an input method is composing, while text is selected in a textarea, and on a read-only field. It also leaves them to a popup, like a suggestion menu, while the field's aria-expanded is "true". On an input, it works for the text, search, url and tel types, so a number field keeps its steps.

Report It as a Change

When your component reports value changes with createChange, write through it with the reason "history-key". onValueChange then hears each entry that comes back, and can cancel it. A canceled change keeps the value as it was and leaves the key to the browser:

write: (value, event) =>
  change("value", value, { reason: "history-key", event }),

Values That Aren't Text

Entries can be any value, like a list of text and tokens. For an editor that isn't an input or a textarea, pass atEdge(direction, event) too: return true when the caret is on the first line for "up", or the last line for "down".

API Reference

ForHelpers
Finding an item as someone typescreateTypeahead, isTypeaheadKey; the search has active() and reset().
Reading an item's labellabelOf
Ranking search resultsfuzzyScore
Highlighting matchesmatchRanges, matchSegments, MatchRange, MatchSegment
Choosing a languagepageLocale, userLocale, toLocale
Handling input method editorsisComposing
Bringing back earlier messagescreateInputHistory; the history has keydown() and reset().

The ranking and highlighting helpers come from @ui-sdk/core/text-match. The other helpers come from @ui-sdk/core.