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.
Design examples
Section titled “Design examples”Press More: the menu stands half a square below its button, fits its words on whole squares, and closes when an action is chosen.
<section class="specimen">
<ds-row class="line"><ds-text literal class="grow">Rain over the courtyard</ds-text><ds-button id="recording-more" secondary icon label="More actions"><ds-icon type="more" aria-hidden="true"></ds-icon></ds-button></ds-row>
<ds-select for="recording-more" aria-label="Recording actions"><ds-text data-ds-menu-label tone="muted" literal>Recording</ds-text><ds-button menu-item role="menuitem"><span class="mark"><ds-icon type="download" size="large" aria-hidden="true"></ds-icon></span>Download</ds-button><ds-button menu-item role="menuitem"><span class="mark"><ds-icon type="duplicate" size="large" aria-hidden="true"></ds-icon></span>Duplicate</ds-button><ds-button menu-item role="menuitem"><span class="mark"><ds-icon type="archive" size="large" aria-hidden="true"></ds-icon></span>Archive</ds-button><hr data-ds-menu-separator><ds-button menu-item role="menuitem" danger><span class="mark"><ds-icon type="delete" size="large" aria-hidden="true"></ds-icon></span>Delete</ds-button></ds-select>
</section>
<style>\n.specimen { width: 100%; min-width: 0; --cell: calc(20px * var(--sf, 1)); }
.specimen .line { width: 100%; }
.specimen .grow { flex: 1; min-width: 0; }
.specimen .mark { display: inline-flex; align-items: flex-end; width: var(--cell); height: var(--ds-control-height); flex: 0 0 var(--cell); }
.specimen label { display: flex; align-items: flex-end; gap: calc(var(--cell) / 2); min-height: var(--cell); }
.specimen .notes { width: calc(16 * var(--cell)); }
</style> Press the field: one square a choice, the chosen one checked, groups headed by their label, details and shortcuts on the row.
<section class="specimen">
<label>Paper <ds-select><select aria-label="Paper"><option data-details="Uncoated" data-shortcut="mod+1">Natural</option><option selected data-details="Smooth" data-shortcut="mod+2">Bright white</option><optgroup label="Recycled"><option data-icon="page" data-details="Grey fleck">Newsprint</option><option data-icon="page" disabled>Kraft</option></optgroup></select></ds-select></label>
</section>
<style>\n.specimen { width: 100%; min-width: 0; --cell: calc(20px * var(--sf, 1)); }
.specimen .line { width: 100%; }
.specimen .grow { flex: 1; min-width: 0; }
.specimen .mark { display: inline-flex; align-items: flex-end; width: var(--cell); height: var(--ds-control-height); flex: 0 0 var(--cell); }
.specimen label { display: flex; align-items: flex-end; gap: calc(var(--cell) / 2); min-height: var(--cell); }
.specimen .notes { width: calc(16 * var(--cell)); }
</style> Native choices keep their keyboard behaviour inside the shared control face.
<section class="specimen">
<div class="columns"><label>Paper<ds-select><select aria-label="Paper"><option>Natural</option><option>Bright white</option><option>Recycled</option></select></ds-select></label><label>Finish<ds-select><select aria-label="Finish"><option>Uncoated</option><option>Matt</option><option>Gloss</option></select></ds-select></label></div>
</section>
<style>
.specimen { width: 100%; min-width: 0; --cell: calc(20px * var(--sf, 1)); }
.specimen .line { width: 100%; }
.specimen .grow { flex: 1; min-width: 0; }
.specimen .muted { color: var(--ds-text-color-muted); }
.specimen .mono { --ds-typeface-regular: var(--ds-typeface-mono); font-variant-numeric: tabular-nums; }
.specimen .value { width: calc(4 * var(--cell)); text-align: right; }
.specimen .mark { display: inline-flex; align-items: flex-end; width: var(--cell); height: var(--ds-control-height); flex: 0 0 var(--cell); }
.specimen .columns { display: flex; flex-wrap: wrap; gap: var(--cell); }
.specimen .columns > * { min-width: 0; }
.specimen fieldset { border: 0; padding: 0; margin: 0; }
.specimen legend { padding: 0; }
.specimen label { display: flex; align-items: flex-end; gap: calc(var(--cell) / 2); min-height: var(--cell); }
.specimen ds-panel { height: calc(10 * var(--cell)); }
</style> A chip-sized select sits alongside the content count.
<section class="specimen">
<ds-row class="line" ><ds-text literal class="grow">24 photographs</ds-text><ds-select fit chip primary><select aria-label="Sort photographs"><option>Newest first</option><option>Title</option><option>Oldest first</option></select></ds-select></ds-row>
</section>
<style>
.specimen { width: 100%; min-width: 0; --cell: calc(20px * var(--sf, 1)); }
.specimen .line { width: 100%; }
.specimen .grow { flex: 1; min-width: 0; }
.specimen .muted { color: var(--ds-text-color-muted); }
.specimen .mono { --ds-typeface-regular: var(--ds-typeface-mono); font-variant-numeric: tabular-nums; }
.specimen .value { width: calc(4 * var(--cell)); text-align: right; }
.specimen .mark { display: inline-flex; align-items: flex-end; width: var(--cell); height: var(--ds-control-height); flex: 0 0 var(--cell); }
.specimen .columns { display: flex; flex-wrap: wrap; gap: var(--cell); }
.specimen .columns > * { min-width: 0; }
.specimen fieldset { border: 0; padding: 0; margin: 0; }
.specimen legend { padding: 0; }
.specimen label { display: flex; align-items: flex-end; gap: calc(var(--cell) / 2); min-height: var(--cell); }
.specimen ds-panel { height: calc(10 * var(--cell)); }
</style> A Select's actions pair real icons and short labels on one-square rows.
<section class="specimen">
<ds-select role="menu" aria-label="Recording actions"><ds-button secondary><span class="mark"><ds-icon type="download" size="large" aria-hidden="true"></ds-icon></span>Download</ds-button><ds-button secondary><span class="mark"><ds-icon type="duplicate" size="large" aria-hidden="true"></ds-icon></span>Duplicate</ds-button><ds-button secondary><span class="mark"><ds-icon type="archive" size="large" aria-hidden="true"></ds-icon></span>Archive</ds-button></ds-select>
</section>
<style>
.specimen { width: 100%; min-width: 0; --cell: calc(20px * var(--sf, 1)); }
.specimen .line { width: 100%; }
.specimen .grow { flex: 1; min-width: 0; }
.specimen .muted { color: var(--ds-text-color-muted); }
.specimen .mono { --ds-typeface-regular: var(--ds-typeface-mono); font-variant-numeric: tabular-nums; }
.specimen .value { width: calc(4 * var(--cell)); text-align: right; }
.specimen .mark { display: inline-flex; align-items: flex-end; width: var(--cell); height: var(--ds-control-height); flex: 0 0 var(--cell); }
.specimen .columns { display: flex; flex-wrap: wrap; gap: var(--cell); }
.specimen .columns > * { min-width: 0; }
.specimen fieldset { border: 0; padding: 0; margin: 0; }
.specimen legend { padding: 0; }
.specimen label { display: flex; align-items: flex-end; gap: calc(var(--cell) / 2); min-height: var(--cell); }
.specimen ds-panel { height: calc(10 * var(--cell)); }
</style> A field
Section titled “A field”<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).
Compact and colour choices
Section titled “Compact and colour choices”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>A menu from a button
Section titled “A menu from a button”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.