Skip to content

Lookup

Picks records from a search, one in the field or several as pills. Searches records you pass, or a search you run.

Installation

pnpm dlx shadcn@latest add @wisty/lookup

The CLI installs the Radix or Base UI file to match your project, with what it builds on: combobox, icon, input-group, pill, spinner.

If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.

When to use

Use it

  • People pick a Salesforce record for a relationship field, like the account on a contact or the record a task is related to.
  • People pick several records, like the accounts on a campaign. Pass multiple, and the records show as pills below the field.
  • The records come from a search you run as people type, like a GraphQL or REST query. Pass onSearch.
  • The records are already in the page, like a short list of recent records. Pass records alone, and the lookup filters them by name.
  • It replaces lightning-record-picker and Salesforce's own lookup fields: object icons, a meta line, the picked record in the field, and several records as pills.

Don't use it

  • The options are plain values, not records with an icon and a second line. Use Combobox.
  • The list is short and people don't need to type. Use Select.
  • The value is any text. Use Input.
  • You need to search several objects from one field, like accounts or contacts. Put a Select for the object next to the lookup.

Usage

The value is the record itself, not its id, so the field can show a record the current results don't hold. Hold it in state to control it:

"use client"

import { useState } from "react"

import { Lookup, type LookupRecord } from "@/components/wisty/lookup"
export function AccountLookup({ accounts }: { accounts: LookupRecord[] }) {
  const [account, setAccount] = useState<LookupRecord | null>(null)

  return (
    <Lookup
      aria-label="Account"
      records={accounts}
      icon="standard:account"
      value={account}
      onValueChange={setAccount}
    />
  )
}

onSearch gets the trimmed term and an AbortSignal, and returns a promise of records. The lookup calls it from two characters, 300ms after typing stops, and aborts the signal when a newer search starts. Pass the signal on, so an aborted request stops on the network too:

async function searchAccounts(
  term: string,
  { signal }: { signal: AbortSignal }
) {
  const response = await fetch(`/api/accounts?q=${encodeURIComponent(term)}`, {
    signal,
  })
  const rows: { Id: string; Name: string; BillingCity: string }[] =
    await response.json()
  return rows.map((row) => ({
    id: row.Id,
    name: row.Name,
    meta: `Account • ${row.BillingCity}`,
  }))
}

In a form, name submits the record's id, or one id per record with multiple.

In Radix projects, Lookup is built on Combobox, which uses @base-ui/react, so installing it adds that package beside radix-ui. The two work side by side.

Examples

Selected

An account set with `value`, shown in the field with its icon and a remove button.

Multiple

Several accounts as pills below the field, set with `multiple`, and the selected ones drop out of the results.

Press delete or backspace to remove

Recently viewed accounts from `records` before you type, then an `onSearch` search with a spinner and the term in bold.

States

A `required` lookup with an error set by `aria-invalid`, and a `disabled` one with a selected contact.

In a dialog

A Related to field in a Log a call dialog: accounts take the lookup's `icon`, and an opportunity and a case set their own on the record.

API reference

Lookup

PropTypeDefaultDescription
multiplefalse | truefalseOne record, shown in the field.
recordsreadonly LookupRecord[][]Records to show. Without onSearch they're filtered by name as people type.
onSearchLookupSearchFinds records for a term of two characters or more; its signal aborts when a newer search starts.
iconIconNameThe object icon for records that don't name their own.
valueLookupRecord | null | readonly LookupRecord[]
defaultValueLookupRecord | null | readonly LookupRecord[]
onValueChange(value: LookupRecord | null) => void | (value: LookupRecord[]) => void
disabledbooleanfalse
requiredbooleanfalse

Also takes every <input> (opens in a new tab) prop.

IconNametype

type IconName = React.ComponentProps<typeof Icon>["name"]

LookupRecordtype

MemberTypeDefaultDescription
id*stringUnique id, like a Salesforce record id. Selection compares ids.
name*stringThe record's name: in results, in the field and in pills.
metastringA second line under the name in results, like "Account • Austin, TX".
iconIconNameThe object icon, like "standard:account". Defaults to the lookup's icon.
disabledbooleanShown in results, but can't be picked.

LookupSearchtype

type LookupSearch = (
  term: string,
  options: { signal: AbortSignal }
) => Promise<readonly LookupRecord[]>

SLDS mapping

The lookup is SLDS's entity autocomplete combobox. The list is slds-dropdown slds-dropdown_fluid, and each result shows an object icon, the name and a meta line. A picked record sits in the field with its icon and a remove button. Several records are Pills below the field, in a group without its border. A disabled record takes slds-icon_disabled on its icon.

Known gaps:

  • No "Search for 'term'" row, no object switcher (slds-combobox-group) and no collapsed selection group with "+N more".
  • While a search runs, SLDS shows a remove button for the typed text beside the spinner. The lookup keeps its search icon.
  • The search icon and the remove X are lucide's, where SLDS draws utility:search and utility:close.
  • The text the lookup adds ("Remove selected option", "Selected records", "No results for…", "Loading results", "… results", "Something went wrong. Try again.") is English.
PropValueSLDSNotes
(no value)default (added)slds-combobox_container, slds-combobox__input, slds-input__icon_rightA search field with the search icon on the right.
records(results) (added)slds-dropdown_length-with-icon-7, slds-listbox__option_entity, slds-listbox__option-metaEach result is an object icon, the name and a meta line. The list shows seven before it scrolls.
onSearch(set) (added)mark (in slds-dropdown), slds-input__spinner, slds-spinner_brandSearches from two characters, 300ms after typing stops, with the brand spinner in the field. The typed term is bold in each name.
value(a record) (added)slds-has-selection, slds-combobox__input-entity-icon, slds-combobox__input-value, slds-input__icon_right (remove)The record in a read-only field with its object icon and a remove button.
multipletrue (added)slds-listbox_selection-group slds-is-expanded, slds-listbox_horizontal, slds-pillSelected records as pills below the field, always expanded.
disabledtrue (added)slds-input[disabled], slds-button_icon[disabled]
aria-invalidtrue (added)slds-has-error, slds-combobox_container[invalid]

Keyboard interactions

Keys Action
Typing Filters the records, or searches from two characters, and opens the list when there's something to show.
Down Arrow, Up Arrow Opens the list, or moves the highlight.
Enter Picks the highlighted record and closes the list. Without multiple, the field shows it. With multiple, it adds a pill and clears the text.
Escape Closes the list. On a closed field, clears the typed text. Inside a modal dialog, the first Escape closes only the list.
Backspace, Delete On a field showing a record, removes the record. Focus stays in the field, ready to search.

With multiple, the pills are the next tab stop. Left and Right Arrow move between them, and Backspace or Delete removes one. When the last pill goes, focus returns to the field.

Accessibility

  • Name the input with a FieldLabel whose htmlFor is the lookup's id, or pass aria-label.
  • For an error, set aria-invalid and point aria-describedby at the message. In Cosmos dark the error text reaches about 4.3:1, below WCAG AA's 4.5:1. SLDS's own tokens do the same.
  • In Lightning Blue, a highlighted record's meta line is 4.15:1, below WCAG AA's 4.5:1. SLDS's own stylesheet does the same.
  • While the list is open, the rest of the page is hidden from screen readers, the field's label included. The input keeps its name.

Motion

  • The spinner's dots bounce while a search runs, and hold still with reduced motion.

Responsive behavior

  • The lookup fills its parent's width, and the list is as wide as the field. Set the width on the parent or the Field.
  • Long names and meta lines are cut off with an ellipsis. Each name has a title with the full text.

Performance

  • The list renders every record it's given. Keep records and search results to tens, and let people narrow a search by typing.

Rules

  • Use multiple for several records. Never build the pills yourself.
  • Never pass asChild or render.