Select

ds-select is every menu that opens from a button. Around a native <select> it is a field and its list of choices; with entries of its own it is an action menu opened from a button; with neither an opener nor a select it is a list in place. One menu engine draws all three: whole squares, the keyboard, typeahead, placement, nested menus and focus. A menu that opens where the pointer is, is a Context.

Actions from a button

Press More: the menu stands half a square below its button, fits its words on whole squares, and closes when an action is chosen.

A field's choices, in place of the picker

Press the field: one square a choice, the chosen one checked, groups headed by their label, details and shortcuts on the row.

Paper and finish

Native choices keep their keyboard behaviour inside the shared control face.

A compact sorting row

A chip-sized select sits alongside the content count.

Actions for a recording

A Select's actions pair real icons and short labels on one-square rows.

<label>Project
<ds-select><select name="project" required>
<option value="">Choose a project</option>
<option value="alpha">Alpha</option>
<option value="beta" data-details="Shared">Beta</option>
<optgroup label="Archived">
<option value="gamma" data-icon="archive">Gamma</option>
</optgroup>
</select></ds-select>
</label>

The native select keeps the value, labels, option groups, validation, FormData, disabled fieldsets and reset. DS1 draws the field, one square high and eight wide by default (layout can set the host width), and in place of the platform’s picker its own list: one square a choice, the chosen one checked and focused on opening, groups headed by their <optgroup> label, half a square below the field and as wide as it in whole squares (--ds-select-list-width widens it). A press anywhere on the field opens it; so do Arrow Down and Up, Enter, Space, F4 and Alt+Arrow on the focused select. Typing a name finds it; Escape or a press outside closes it, and focus returns to the select. Choosing sets the select’s value and sends input and change, so forms and frameworks see an ordinary change.

An option may carry more on its row, all on the sixteen:

  • data-details: muted words after the name, as Link’s lists show a workspace’s members;
  • data-shortcut: a shortcut written once, mod+k, shown as the platform writes it (⌘K, Ctrl+K);
  • data-icon: a library icon on the row’s first square.

native keeps the platform’s own picker. A multiple select, or one with size above 1, stays a native list, one square a visible row (four for multiple).

fit sizes a field to its options. chip uses the shared 16px control height and quiet button face; primary gives a chip the accent. For a colour category, pass swatch a CSS colour: DS1 draws its ds-swatch in a one-square control.

<ds-select fit chip primary><select aria-label="Sort direction">
<option value="asc">Ascending</option><option value="desc">Descending</option>
</select></ds-select>

Without a select, ds-select is the menu a button opens. Name the button with for, as <label for> names its field (or set trigger to the element):

<ds-button id="more" icon aria-label="More"><ds-icon type="more"></ds-icon></ds-button>
<ds-select for="more" aria-label="Recording actions">
<ds-button menu-item role="menuitem"><ds-icon type="download"></ds-icon>Download</ds-button>
<ds-button menu-item role="menuitem">Duplicate</ds-button>
<hr data-ds-menu-separator>
<ds-text data-ds-menu-label tone="muted" literal>Danger</ds-text>
<ds-button menu-item role="menuitem" danger>Delete</ds-button>
</ds-select>

It is a menu (role="menu") unless told otherwise, floats in the browser’s popover layer half a square from its button, flips above when there is more room, and stays inside the visible screen, all on whole squares. The button gets aria-haspopup, aria-expanded and aria-controls. A click opens and closes it; Arrow Down opens at the first entry (or the checked one) and Arrow Up at the last. Arrows move between entries and skip disabled and hidden ones, Home and End jump, typing finds an entry (repeated letters cycle), Escape closes only the top menu, Tab leaves it, and focus returns to the button.

Choosing an entry closes the menu after the entry’s own handlers ran, unless the entry says data-keep-open (a colour that cycles in place) or a handler prevented its click. The cancelable events are the engine’s: ds-menu-toggle (detail.open), ds-menu-close (detail.reason: escape, outside, select, back, parent) and ds-menu-close-focus.

The menu stands on whole squares: its line and the padding inside it make half a square on each side, so n entries are n + 1 squares high, and every entry is one square high whatever button styles surround it. A separator takes one square with its line. compact entries are filled rows on a square’s pitch, 19px with the 1px between them. hover opens it while the pointer rests on its button (a breadcrumb), with a grace across the half-square gap. columns makes a small grid of one-square cells with two-dimensional arrows (a palette; data-ds-menu-row for a full-width action, data-ds-menu-start to start a section).

A nested menu is another ds-select inside it with trigger-mode="submenu" and its row as trigger: it opens beside the row, with hover, the forward and back arrows, and its own Escape.

For suggestions under a field (a mention, a token), set controller to the field and activeId to the chosen option: arrows request the next option with ds-menu-active while focus stays in the field.

Select from DS1/react/select is both. With <option> children it is a field, the native select’s props, children and ref passing through:

<Select name="project" value={project} onChange={event => setProject(event.currentTarget.value)}>
<option value="alpha">Alpha</option>
<option value="beta" data-details="Shared">Beta</option>
</Select>

With trigger (or open, or items) it is a menu. Its entries are children, or items, entries as data after Link’s dropdowns:

<Select trigger={button} open={open} onOpenChange={setOpen} items={[
{ heading: "Workspace" },
{ label: "Rename", icon: "pen", shortcut: "mod+r", onSelect: rename },
{ label: "Pinned", checked: pinned, keepOpen: true, onSelect: togglePin },
{ label: "Move to", items: lists.map(list => ({ label: list.name, onSelect: () => move(list) })) },
{ separator: true },
{ label: "Delete", danger: true, onSelect: remove },
]} />

An entry has a label, and may have details, an icon, a shortcut, an href, danger, disabled, checked (a checkbox row), keepOpen, onSelect(event) (prevent the event to keep the menu open) and items (a nested menu beside it). { heading } and { separator: true } stand between entries. Register ds-select in the browser before hydrating.

Remove product select styles, popup positioning, keyboard listeners and duplicate arrows when moving to Select. Keep data and callbacks in the product, and check required validation, controlled changes, labels, reset, keyboard choices and disabled state on light and dark paper, at the product’s scale and at phone width.