# SearchableList

> A search field paired with a filtered list, with result counts announced as you type.

Category: Advanced · Status: stable · Package: @bindshq/stance-react@0.1.1 · Docs: https://stanceui.com/components/searchable-list

Use SearchableList to let people find items in a list of tens to hundreds, such as organisation users or signature templates. In selectable mode, the search field drives a listbox for picking one item, like assigning a template owner. For choosing a value inside a form field, prefer Combobox.

## Import

```tsx
import { SearchableList } from '@bindshq/stance-react';
```

Styles are imported once per app: `@bindshq/stance-tokens/css` and `@bindshq/stance-react/styles.css`.

## Examples

### Filter a list

```tsx
import { Avatar, Badge, SearchableList } from '@bindshq/stance-react';

const users = [
  { id: 'u1', name: 'Priya Nair', email: 'priya.nair@binds.si', team: 'Sales' },
  { id: 'u2', name: 'Amara Okafor', email: 'amara.okafor@binds.si', team: 'Legal' },
  { id: 'u3', name: 'Lukas Brenner', email: 'lukas.brenner@binds.si', team: 'Sales' },
  { id: 'u4', name: 'Hana Sato', email: 'hana.sato@binds.si', team: 'Finance' },
  { id: 'u5', name: 'Tomás Ruiz', email: 'tomas.ruiz@binds.si', team: 'Operations' },
  { id: 'u6', name: 'Owen Price', email: 'owen.price@binds.si', team: 'Sales' },
  { id: 'u7', name: 'Zainab Haddad', email: 'zainab.haddad@binds.si', team: 'Legal' },
  { id: 'u8', name: 'Mateja Kovač', email: 'mateja.kovac@binds.si', team: 'Customer Success' },
];

export function Basic() {
  return (
    <div style={{ maxWidth: 420 }}>
      <SearchableList
        items={users}
        getItemLabel={(u) => u.name}
        getItemKey={(u) => u.id}
        getItemKeywords={(u) => [u.email, u.team]}
        searchLabel="Search organisation users"
        placeholder="Search by name, email or team"
        listLabel="Organisation users"
        renderItem={(u) => (
          <>
            <Avatar name={u.name} size="sm" />
            <span style={{ display: 'grid' }}>
              <span>{u.name}</span>
              <small style={{ color: 'var(--st-color-fg-muted)' }}>{u.email}</small>
            </span>
            <span style={{ marginLeft: 'auto' }}>
              <Badge size="sm">{u.team}</Badge>
            </span>
          </>
        )}
      />
    </div>
  );
}
```

### Pick one item

Arrow keys move through matches while focus stays in the search field.

```tsx
import * as React from 'react';
import { SearchableList } from '@bindshq/stance-react';

const users = [
  { id: 'u1', name: 'Priya Nair', email: 'priya.nair@binds.si', team: 'Sales' },
  { id: 'u2', name: 'Amara Okafor', email: 'amara.okafor@binds.si', team: 'Legal' },
  { id: 'u3', name: 'Lukas Brenner', email: 'lukas.brenner@binds.si', team: 'Sales' },
  { id: 'u4', name: 'Hana Sato', email: 'hana.sato@binds.si', team: 'Finance' },
  { id: 'u5', name: 'Tomás Ruiz', email: 'tomas.ruiz@binds.si', team: 'Operations' },
  { id: 'u6', name: 'Owen Price', email: 'owen.price@binds.si', team: 'Sales' },
  { id: 'u7', name: 'Zainab Haddad', email: 'zainab.haddad@binds.si', team: 'Legal' },
  { id: 'u8', name: 'Mateja Kovač', email: 'mateja.kovac@binds.si', team: 'Customer Success' },
];

export function Selectable() {
  const [owner, setOwner] = React.useState<React.Key | null>('u2');
  return (
    <div style={{ maxWidth: 380, display: 'grid', gap: 8 }}>
      <SearchableList
        items={users}
        getItemLabel={(u) => u.name}
        getItemKey={(u) => u.id}
        getItemKeywords={(u) => [u.team]}
        selectable
        selectedKey={owner}
        onSelectionChange={setOwner}
        searchLabel="Find a template owner"
        listLabel="People who can own templates"
        maxHeight={220}
      />
      <p style={{ margin: 0 }}>Owner: {users.find((u) => u.id === owner)?.name ?? 'none'}</p>
    </div>
  );
}
```

### Fuzzy ranking

matchMode="fuzzy" ranks prefix and word matches first and tolerates gaps.

```tsx
import { SearchableList } from '@bindshq/stance-react';

export function Fuzzy() {
  const templates = [
    'Standard signature',
    'Sales team signature',
    'Legal disclaimer footer',
    'Holiday out-of-office',
    'Event banner, Q4 summit',
    'Recruiting signature',
  ];
  return (
    <div style={{ maxWidth: 380 }}>
      <SearchableList
        items={templates}
        getItemLabel={(t) => t}
        matchMode="fuzzy"
        defaultQuery="sgn"
        searchLabel="Search templates"
        listLabel="Signature templates"
        emptyState={(q) => <p>No templates match “{q}”. Try a shorter search.</p>}
      />
    </div>
  );
}
```

## API reference

| Component | Prop | Type | Default | Description |
| --- | --- | --- | --- | --- |
| SearchableList | `items` (required) | `readonly T[]` | None | Items to filter. |
| SearchableList | `getItemLabel` (required) | `(item: T) => string` | None | Text used for matching and as the option name. |
| SearchableList | `listLabel` (required) | `string` | None | Accessible name of the list. |
| SearchableList | `searchLabel` | `string` | `'Search'` | Accessible name of the search field. |
| SearchableList | `getItemKey` | `(item: T) => Key` | `label` | Stable key; also the selection key. |
| SearchableList | `getItemKeywords` | `(item: T) => string[]` | None | Extra searchable text, such as email or team. |
| SearchableList | `renderItem` | `(item: T, state) => ReactNode` | None | Custom item content. state has active, selected and query. |
| SearchableList | `matchMode` | `'contains' \| 'fuzzy'` | `'contains'` | Contains keeps original order; fuzzy ranks matches. |
| SearchableList | `filter` | `(item: T, query: string) => boolean` | None | Custom matching; overrides matchMode. |
| SearchableList | `query / defaultQuery / onQueryChange` | `string / string / (q) => void` | None | Controlled or uncontrolled search text. |
| SearchableList | `selectable` | `boolean` | `false` | Turns the list into a single-select listbox. |
| SearchableList | `selectedKey / defaultSelectedKey` | `Key \| null` | None | Selected item, controlled or uncontrolled. |
| SearchableList | `onSelectionChange` | `(key, item) => void` | None | Called when an item is selected. |
| SearchableList | `isItemDisabled` | `(item: T) => boolean` | None | Disabled items are skipped and cannot be selected. |
| SearchableList | `emptyState` | `ReactNode \| (query) => ReactNode` | None | Shown when nothing matches. |
| SearchableList | `formatCount` | `(count, total) => string` | None | Text for the results status. |
| SearchableList | `maxHeight` | `number \| string` | `320` | Max list height before it scrolls. |
| SearchableList | `size` | `'sm' \| 'md'` | `'md'` | Field and row density. |

## Accessibility

- The result count is a status live region, so screen reader users hear “3 of 48 results” as they type.
- In selectable mode the search field is a combobox that controls the listbox; aria-activedescendant tracks the active option while focus stays in the field.
- Escape clears the search, as in SearchInput.
- Disabled items stay visible and are announced as unavailable.

## Keyboard

| Keys | Action |
| --- | --- |
| `↓` `↑` | Selectable: move the active option. |
| `Ctrl` `Home` | Selectable: first option. |
| `Ctrl` `End` | Selectable: last option. |
| `Enter` | Selectable: select the active option. |
| `Escape` | Clear the search. |

## Guidelines

**Do**

- Include secondary text, such as email or team, in getItemKeywords.
- Write an empty state that suggests what to try next.

**Don’t**

- Use for thousands of items without virtualisation; combine with VirtualizedList.
- Use as a form field picker; use Combobox.

## Related

- https://stanceui.com/components/search-input
- https://stanceui.com/components/combobox
- https://stanceui.com/components/virtualized-list
- https://stanceui.com/components/list
