# Listbox

> A list of Options to pick one or more from, right on the page instead of in a popup.

`<slab-listbox>` · Selection · built on `[role=listbox]`

## Install

```sh
pnpm add slab-elements lit
```

## Usage

```html
<script type="module">
  import 'slab-elements/listbox.js';
  import 'slab-elements/option.js';
</script>

<slab-listbox label="Size" name="size">
  <slab-option value="s">Small</slab-option>
  <slab-option value="m" selected>Medium</slab-option>
  <slab-option value="l">Large</slab-option>
</slab-listbox>
```

## Example

Here's a styled version, `<slab-styled-listbox>`. It's not in the package — it's an example of what you can build with the headless element. Copy it and make it yours at the bottom of this page.

```html
<slab-styled-listbox label="Crew" multiple>
  <slab-styled-option value="ana" selected>Ana, finisher</slab-styled-option>
  <slab-styled-option value="ben">Ben, pump operator</slab-styled-option>
  <slab-styled-option value="chen">Chen, form setter</slab-styled-option>
  <slab-styled-option value="dee">Dee, laborer</slab-styled-option>
  <slab-styled-option value="eli" disabled>Eli, on leave</slab-styled-option>
</slab-styled-listbox>
```

Swap `slab-styled-` for `slab-` in that markup and you've got the same example as it ships: the headless elements with no CSS at all. That's your starting point.

Option, documented here, is also what [Select](select.md) and
[Combobox](combobox.md) use.

## Keyboard

| Key          | What it does                                                              |
| ------------ | ------------------------------------------------------------------------- |
| Up, Down     | Moves the active Option, stopping at each end and skipping disabled ones. |
| Home, End    | Moves to the first or last Option.                                        |
| A character  | Moves to the next Option whose label starts with what's typed.            |
| Enter, Space | Selects the active Option, or toggles it when `multiple` is set.          |

With `multiple`, Shift with Up or Down moves and toggles, Shift with Space
selects from the last selected Option to the active one, Control or Command
with Shift and Home or End selects from the active one to the first or last,
and Control or Command with A selects every Option, or none.

## Behavior

### `<slab-listbox>`

A list of Options to select one or more from, with no visual styles.

There's no native listbox: `<select>` draws its own options, and they can't be anything but text. So this is a `<div role="listbox">` and ARIA does the rest. Not a `<ul>`: a list may only hold list items, which neither a slot nor an Option is, and the role would replace its semantics anyway.

Options are found by class rather than by tag name, so your subclass of Option can be named anything.

Focus stays on the listbox, and the active Option is pointed at with `aria-activedescendant` rather than focused, the way the APG's own listbox does it. It's what lets a Combobox keep focus in its input while the user moves through the Options, and a Select keep it on its button. Read or set the active Option with `activeOption`.

The arrow keys, Home and End move the active Option, stopping at each end and skipping disabled Options. Typing moves it to the next Option whose label starts with what was typed. Space, Enter, and a click select it, or toggle it when `multiple` is set. Then, as in the APG's multi-select listbox, Shift with an arrow key moves and toggles, Shift with Space selects every Option from the one last selected to the active one, Control or Command with Shift and Home or End selects every Option from the active one to the first or last and moves there, and Control or Command with A selects every Option, or none when all of them already are.

Style it through its parts and through `:state(disabled)`, `:state(error)`, and `:state(required)`, and each Option through `:state(active)`, `:state(disabled)`, and `:state(selected)`.

### `<slab-option>`

An Option with no visual styles. The host is the option, so style it directly.

It's never focused. Focus stays on its Listbox, which points at the active Option with `aria-activedescendant`, so a Select or a Combobox can keep focus where it is while the user moves through the Options.

Its Listbox sets `:state(active)`. It sets `:state(disabled)`, which is also set while its Listbox is disabled, and `:state(selected)` itself.

## API

### `<slab-listbox>`

#### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `string` |  |  |
| `disabled` | `boolean` | `false` |  |
| `hide-label` | `boolean` | `false` |  |
| `multiple` | `boolean` | `false` |  |
| `name` | `string` | `''` |  |
| `required` | `boolean` | `false` |  |
| `value` | `string` | `''` | The value of the first selected and enabled Option. Setting it selects the Option with that value, and only that one. Not reflected, to match a native control. |
| `version` | `string` |  |  |

#### Properties

| Name | Type | Description |
| --- | --- | --- |
| `activeOption` | `Option \| null` | The Option the user is on, which Space, Enter and typeahead act from. It's pointed at rather than focused, so focus stays where it is. |
| `form` (read-only) | `HTMLFormElement \| null` |  |
| `options` (read-only) | `Option[]` |  |
| `selectedOptions` (read-only) | `Option[]` | Selected and enabled Options, in the order they appear. |
| `validity` (read-only) | `ValidityState` |  |
| `values` | `string[]` | The value of every selected and enabled Option, in the order they appear. Setting it selects the Options with those values, and only those. |

#### Methods

| Signature | Description |
| --- | --- |
| `checkValidity(): boolean` |  |
| `focus(options?: FocusOptions): void` |  |
| `reportValidity(): boolean` |  |
| `resetValidityFeedback(): void` | Clears the validity feedback without changing `validity`. |
| `setCustomValidity(message: string): void` |  |
| `setValidity(flags?: ValidityStateFlags, message?: string): void` |  |

#### Events

| Name | Type | Description |
| --- | --- | --- |
| `input` | `Event` |  |
| `change` | `Event` |  |

#### Slots

| Name | Type | Description |
| --- | --- | --- |
| `(default)` | `Option` |  |
| `description` | `Element \| string` |  |

#### CSS parts

| Name | Description |
| --- | --- |
| `description` | Text about the Listbox, and its validity message when it has one |
| `label` | The label |
| `listbox` | The Options |
| `validity-message` |  |

### `<slab-option>`

#### Attributes

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` |  | Names the Option in place of its text, and is what typeahead matches against. |
| `disabled` | `boolean` | `false` |  |
| `selected` | `boolean` | `false` | Like a native option, the attribute is the default state and the property is the current one. Style `:state(selected)` rather than `[selected]`. |
| `value` | `string` | `''` | Its label, or its text when it has no label, when not set. The same as a native option. |
| `version` | `string` |  |  |

#### Properties

| Name | Type | Description |
| --- | --- | --- |
| `text` (read-only) | `string` | What the user reads as the Option's name: its label, or its text when it has no label. |

#### Slots

| Name | Type | Description |
| --- | --- | --- |
| `(default)` | `Element \| string` | The label, plus anything else a design puts in an Option |

## Copy and own it

`<slab-styled-listbox>` isn't in the package. It's an example — copy it into your project and change whatever you want. Under the hood, it's just the headless element, subclassed, with `styles` set. That's it. Keyboard, focus, ARIA, forms, and events all come from the headless element, and the styles only reach in through `::part()` and `:state()`, same as yours would.

To use it, copy these files, swap the tag in `@customElement()` for one of your own, and import it. `tokens.styles.ts` holds the colors and fonts every styled example shares, so you only need it once.

### TypeScript

These files are TypeScript, and `@customElement()` is one of Lit's decorators (TypeScript's experimental flavor). You'll need two options in your tsconfig, per [Lit's docs](https://lit.dev/docs/components/decorators/#decorators-typescript): `experimentalDecorators` to compile them, and `useDefineForClassFields` turned off so class fields don't stomp on the accessors Lit sets up.

```json
{
  "compilerOptions": {
    "experimentalDecorators": true,
    "useDefineForClassFields": false
  }
}
```

Using plain JavaScript? Drop the types and register the class with `customElements.define()` instead of the decorator.

### listbox.ts

```ts
import { customElement } from 'lit/decorators.js';
import Listbox from 'slab-elements/listbox.js';
import styles from './listbox.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-listbox': StyledListbox;
  }
}

/**
 * Listbox, styled: its Options in an ink-bordered box that casts an ember
 * shadow while it's focused.
 */
@customElement('slab-styled-listbox')
export default class StyledListbox extends Listbox {
  static override styles = [tokens, styles];
}
```

### listbox.styles.ts

```ts
import { css } from 'lit';

export default css`
  :host {
    color: var(--ink);
    display: grid;
    font-family: var(--font-body);
  }

  [part~='label'] {
    font-family: var(--font-display);
    font-size: 1.0625rem;
    font-weight: 600;
    margin-block-end: 0.375rem;
  }

  :host(:state(required)) [part~='label']::after {
    color: var(--ember-deep);
    content: ' *' / '';
  }

  /*
    Focus stays on the listbox and the active Option shows where the keyboard
    is, so the listbox's own focus is the shadow, as Text Field's is.
  */
  [part~='listbox'] {
    background-color: var(--paper);
    border: var(--border);
    max-block-size: 16rem;
    overflow-y: auto;
    padding: 0.25rem 0;
    transition: box-shadow 100ms;

    &:focus-visible {
      box-shadow: 4px 4px 0 var(--ember);
      outline: 2px solid transparent;
    }
  }

  :host(:state(error)) [part~='listbox'] {
    border-color: var(--ember-deep);
  }

  :host(:state(disabled)) [part~='listbox'] {
    background-color: var(--chalk);
    border-style: dashed;
    color: var(--ash);
  }

  /*
    Spaced by what's in it rather than by a gap, so a description with
    nothing in it takes no room.
  */
  [part~='description'] {
    color: var(--ash);
    display: grid;
    font-size: 0.875rem;
  }

  ::slotted([slot='description']) {
    margin-block-start: 0.375rem;
  }

  [part~='validity-message'] {
    color: var(--ember-deep);
    font-weight: 600;
    margin-block-start: 0.375rem;

    &::before {
      content: '■ ' / '';
    }
  }

  @media (prefers-reduced-motion: reduce) {
    [part~='listbox'] {
      transition: none;
    }
  }
`;
```

### option.ts

```ts
import { customElement } from 'lit/decorators.js';
import Option from 'slab-elements/option.js';
import styles from './option.styles.js';
import tokens from './tokens.styles.js';

declare global {
  interface HTMLElementTagNameMap {
    'slab-styled-option': StyledOption;
  }
}

/**
 * Option, styled: a row that inverts to paper on ink while it's active, with
 * an ember bar on its edge, and a check while it's selected.
 */
@customElement('slab-styled-option')
export default class StyledOption extends Option {
  static override styles = [tokens, styles];
}
```

### option.styles.ts

```ts
import { css } from 'lit';

export default css`
  :host {
    align-items: center;
    border-inline-start: 4px solid transparent;
    cursor: pointer;
    display: flex;
    font-family: var(--font-body);
    gap: 0.75rem;
    justify-content: space-between;
    padding: 0.5rem 0.875rem 0.5rem 0.75rem;
    transition:
      background-color 100ms,
      border-color 100ms,
      color 100ms;
  }

  /*
    Before ":state(active)", which wins when both apply. Hovering doesn't make
    an Option active, as in the APG's select-only combobox, so the keyboard's
    place stays visible beside the pointer's.
  */
  :host(:hover) {
    background-color: var(--chalk);
  }

  :host(:state(active)) {
    background-color: var(--ink);
    border-inline-start-color: var(--ember);
    color: var(--paper);
  }

  :host(:state(selected)) {
    font-weight: 600;
  }

  /* The check: a corner of border, turned. */
  :host(:state(selected))::after {
    block-size: 0.625rem;
    border-block-end: 2px solid currentcolor;
    border-inline-end: 2px solid currentcolor;
    content: '';
    flex-shrink: 0;
    inline-size: 0.3125rem;
    rotate: 45deg;
    translate: 0 -0.125rem;
  }

  :host(:state(disabled)) {
    color: var(--ash);
    cursor: not-allowed;
    text-decoration: line-through;
  }

  :host(:state(disabled):state(active)) {
    background-color: var(--chalk);
    color: var(--ash);
  }

  @media (prefers-reduced-motion: reduce) {
    :host {
      transition: none;
    }
  }
`;
```

### tokens.styles.ts

```ts
import { css } from 'lit';

/*
  The styled components' palette and type, taken from the logo: ink for the
  courses, ember for the base, paper behind them.

  Each is a short alias for a "--slab-styled-*" property, with the default as
  its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
  which every shadow root inherits. Were the defaults declared under those
  names here instead, each host's own declaration would shadow the page's.

  Each default is "light-dark()", so the components follow the page's
  "color-scheme": "dark" for dark, "light" for light, and "light dark" to
  follow the operating system. Ink and paper trade places in the dark, so
  ink is always the text and paper always what's behind it. Contrast is given
  as light, then dark.
*/
export default css`
  :host {
    /* Secondary text. 6.8:1 and 6.9:1 on paper. */
    --ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
    --border: 2px solid var(--ink);

    /* Behind disabled and read-only controls, and hovered rows. */
    --chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));

    /*
      The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
      enough for borders, fills and large text, but not always for small text,
      which takes "--ember-deep".
    */
    --ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));

    /*
      Orange for small text on paper, and for paper text on it. 5.7:1 and
      7.4:1.
    */
    --ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
    --font-body: var(
      --slab-styled-font-body,
      'IBM Plex Sans',
      system-ui,
      sans-serif
    );
    --font-display: var(
      --slab-styled-font-display,
      'Zilla Slab',
      rockwell,
      'Roboto Slab',
      serif
    );
    --font-mono: var(
      --slab-styled-font-mono,
      'IBM Plex Mono',
      ui-monospace,
      monospace
    );
    --ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
    --paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));

    /* The hard, offset shadow a raised surface casts. */
    --shadow: 4px 4px 0 var(--ink);
  }
`;
```
