Select
A select lets the user pick one option, or several in multiple mode, from a
list that opens under a trigger button. Use it instead of a native <select>
when the options need markup, groups with headings, descriptions or a styled
popup. Select is a Listbox inside a Popover,
plus the trigger: the options, keys, typeahead and selection are Listbox’s,
and the popover holds the list in the top layer, places it and closes it. A
hidden native <select> carries the value into forms and takes the browser’s
autofill.
The trigger follows the WAI-ARIA select-only combobox and the popup the Listbox pattern, except that focus moves into the list while it is open (see Accessibility).
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The select's look is @uniaria/styles' default. */
:root {
font:
15px/1.5 system-ui,
sans-serif;
}
body {
display: grid;
gap: 1rem;
margin: 1.5rem;
background: var(--uni-surface-color);
color: var(--uni-text-color);
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
}
.row > uni-select {
inline-size: 13rem;
}
output {
padding-block: 0.5rem;
}
</style><form id="order" class="row">
<uni-select placeholder="Pick a fruit" required>
<div data-uni="select">
<span data-element="Label">Fruit</span>
<button data-element="Trigger">
<span data-element="Value"></span>
</button>
<div data-element="Popover">
<div data-element="List">
<div data-element="Option" data-key="apple">Apple</div>
<div data-element="Option" data-key="banana">Banana</div>
<div data-element="Option" data-key="blueberry">Blueberry</div>
<div data-element="Group" data-key="citrus">
<span data-element="GroupLabel" data-key="citrus">Citrus</span>
<div data-element="Option" data-key="lemon">Lemon</div>
<div data-element="Option" data-key="lime">Lime</div>
<div data-element="Option" data-key="orange">Orange</div>
</div>
<div data-element="Option" data-key="kiwi">Kiwi</div>
<div data-element="Option" data-key="mango">Mango</div>
<div data-element="Option" data-key="peach">Peach</div>
</div>
</div>
<select data-element="HiddenSelect" name="fruit"></select>
</div>
</uni-select>
<uni-button type="submit">
<button data-uni="button" data-variant="primary">Order</button>
</uni-button>
<output id="log">Pick a fruit, then order.</output>
</form>
<script src="/demo/select.js"></script><script>const log = document.getElementById('log');
const form = document.getElementById('order');
document.addEventListener('valuechange', (event) => {
log.textContent = `valuechange: ${event.detail.join(', ')}`;
});
// The hidden <select> submits the value, and `required` blocks an empty one.
form.addEventListener('submit', (event) => {
event.preventDefault();
log.textContent = `Submitted fruit=${new FormData(form).get('fruit')}`;
});
</script>React
import { select } from '@uniaria/core/select';
import { useBehavior } from '@uniaria/react';
const fruits = [
{ key: 'apple', textValue: 'Apple' },
{ key: 'banana', textValue: 'Banana' },
{ key: 'cherry', textValue: 'Cherry' },
];
export function FruitSelect() {
const fruit = useBehavior(select, {
items: fruits,
placeholder: 'Pick a fruit',
isRequired: true,
});
return (
<div {...fruit.parts.root}>
<span {...fruit.parts.label}>Fruit</span>
<button {...fruit.parts.trigger}>
<span {...fruit.parts.value} />
<ChevronIcon aria-hidden />
</button>
<div {...fruit.parts.popover}>
<div {...fruit.parts.list}>
{fruits.map((item) => (
<div key={item.key} {...fruit.getItemProps('option', item.key)}>
{item.textValue}
</div>
))}
</div>
<p {...fruit.parts.empty}>No fruit</p>
</div>
<select {...fruit.parts.hiddenSelect} name="fruit" />
</div>
);
}Vue
<script setup lang="ts">
import { select } from '@uniaria/core/select';
import { useBehavior } from '@uniaria/vue';
const fruits = [
{ key: 'apple', textValue: 'Apple' },
{ key: 'banana', textValue: 'Banana' },
{ key: 'cherry', textValue: 'Cherry' },
];
const fruit = useBehavior(select, {
items: fruits,
placeholder: 'Pick a fruit',
isRequired: true,
});
</script>
<template>
<div v-bind="fruit.parts.root">
<span v-bind="fruit.parts.label">Fruit</span>
<button v-bind="fruit.parts.trigger">
<span v-bind="fruit.parts.value" />
<ChevronIcon aria-hidden="true" />
</button>
<div v-bind="fruit.parts.popover">
<div v-bind="fruit.parts.list">
<div
v-for="item in fruits"
:key="item.key"
v-bind="fruit.getItemProps('option', item.key)"
>
{{ item.textValue }}
</div>
</div>
<p v-bind="fruit.parts.empty">No fruit</p>
</div>
<select v-bind="fruit.parts.hiddenSelect" name="fruit" />
</div>
</template>HTML
<uni-select placeholder="Pick a fruit" required>
<div data-uni="select">
<span data-element="Label">Fruit</span>
<button data-element="Trigger">
<span data-element="Value"></span>
<svg aria-hidden="true">…</svg>
</button>
<div data-element="Popover">
<div data-element="List">
<div data-element="Option" data-key="apple">Apple</div>
<div data-element="Option" data-key="banana">Banana</div>
<div data-element="Option" data-key="cherry">Cherry</div>
</div>
<p data-element="Empty">No fruit</p>
</div>
<select data-element="HiddenSelect" name="fruit"></select>
</div>
</uni-select>
<script type="module">
import '@uniaria/elements/select';
</script>The options are Listbox’s item parts, so keys, groups, option labels,
descriptions and extras, and items read from the markup work as on
Listbox. Render the options while the list is
closed, inside the hidden popover: the element reads its items from them, and
the trigger’s aria-controls points at a list that holds the selected option.
Render the root part around everything, the popover included. Select tracks
focus within it: without a root, focuschange never fires and the
data-focus-within and data-focus-visible attributes never appear.
UniAria writes the value part’s text: the selected option’s getTextValue,
the selected options’ texts joined with , in multiple mode, or the
placeholder while nothing is selected, when it also sets
data-placeholder. Leave the part empty. In React and Vue the text appears once
the component mounts.
For an icon or other markup in the trigger, leave the value part out and
render the value yourself:
React
const selected = useBehaviorState(fruit, (state) => state.value[0]);
return (
<button {...fruit.parts.trigger}>
{selected ? <FruitBadge fruit={selected} /> : 'Pick a fruit'}
</button>
);Vue
<script setup lang="ts">
const fruit = useBehavior(select, { items: fruits });
const selected = useBehaviorState(fruit, (state) => state.value[0]);
</script>
<template>
<button v-bind="fruit.parts.trigger">
<FruitBadge v-if="selected" :fruit="selected" />
<template v-else>Pick a fruit</template>
</button>
</template>HTML
const fruit = document.querySelector('uni-select');
const badge = fruit.querySelector('.badge');
fruit.addEventListener('valuechange', () => {
const { valueText, isPlaceholder } = fruit.instance.getState();
badge.textContent = isPlaceholder ? 'Pick a fruit' : valueText;
});For buttons, badges and counts that go with the trigger, such as a quiet
Button that clears the value, render a triggerFrame inside the root, around
the trigger, and a triggerExtras part in it, beside the trigger. The extras
are never inside the trigger, so each is a control of its own and a Tab stop
after the trigger. The frame and the extras carry the trigger’s state
attributes, data-open and data-invalid included, and the pointer anywhere
over the frame hovers the trigger.
React
import { useState } from 'react';
import { button } from '@uniaria/core/button';
import { select } from '@uniaria/core/select';
import { useBehavior } from '@uniaria/react';
export function FruitSelect() {
const [value, setValue] = useState<readonly string[]>([]);
const fruit = useBehavior(select, {
items: fruits,
placeholder: 'Pick a fruit',
value,
onValueChange: setValue,
});
const clear = useBehavior(button, { onPress: () => setValue([]) });
return (
<div {...fruit.parts.root}>
<span {...fruit.parts.label}>Fruit</span>
<div {...fruit.parts.triggerFrame}>
<button {...fruit.parts.trigger}>
<span {...fruit.parts.value} />
</button>
<span {...fruit.parts.triggerExtras}>
<button
{...clear.parts.root}
data-variant="quiet"
aria-label="Clear Fruit"
>
<ClearIcon aria-hidden />
</button>
</span>
</div>
<div {...fruit.parts.popover}>…</div>
</div>
);
}Vue
<script setup lang="ts">
import { ref } from 'vue';
import { button } from '@uniaria/core/button';
import { select } from '@uniaria/core/select';
import { useBehavior } from '@uniaria/vue';
const value = ref<readonly string[]>([]);
const fruit = useBehavior(select, () => ({
items: fruits,
placeholder: 'Pick a fruit',
value: value.value,
onValueChange: (keys) => (value.value = keys),
}));
const clear = useBehavior(button, { onPress: () => (value.value = []) });
</script>
<template>
<div v-bind="fruit.parts.root">
<span v-bind="fruit.parts.label">Fruit</span>
<div v-bind="fruit.parts.triggerFrame">
<button v-bind="fruit.parts.trigger">
<span v-bind="fruit.parts.value" />
</button>
<span v-bind="fruit.parts.triggerExtras">
<button
v-bind="clear.parts.root"
data-variant="quiet"
aria-label="Clear Fruit"
>
<ClearIcon aria-hidden="true" />
</button>
</span>
</div>
<div v-bind="fruit.parts.popover">…</div>
</div>
</template>HTML
<uni-select id="fruit" placeholder="Pick a fruit">
<div data-uni="select">
<span data-element="Label">Fruit</span>
<div data-element="TriggerFrame">
<button data-element="Trigger"><span data-element="Value"></span></button>
<span data-element="TriggerExtras">
<uni-button id="clear-fruit">
<button
data-uni="button"
data-variant="quiet"
aria-label="Clear Fruit"
>
<svg aria-hidden="true">…</svg>
</button>
</uni-button>
</span>
</div>
<div data-element="Popover">…</div>
</div>
</uni-select>
<script type="module">
import '@uniaria/elements/button';
import '@uniaria/elements/select';
const fruit = document.querySelector('#fruit');
fruit.addEventListener('valuechange', (event) => {
fruit.value = event.detail;
});
document.querySelector('#clear-fruit').addEventListener('press', () => {
fruit.value = [];
});
</script>Without an anchor part, the popover is placed against the frame, so
min-width: var(--anchor-width) makes it as wide as the trigger with its
extras. A press on an extra while the list is open closes the list without
selecting, and the extra acts, in one press; focus stays on the extra. Name
each extra in its context: “Clear Fruit”, not “Clear”.
Select takes two Tooltip links:
tooltipis the trigger’s, as on a Button. It describes the trigger after its description and error, unless it hasrelation: 'none'.optionTooltipis one tooltip for every option. It opens on the option under the pointer or with keyboard focus, moves with them, and closes on a press or while you type. It never describes an option: give itrelation: 'none', or development builds warn. Render it inside the popover, beside the list, never inside an option, whose name comes from its content.
With shouldOpenOnlyWhenTruncated, a tooltip opens only while the text is cut
off, and writes the whole text into its text part (see
Overflow tooltips). The trigger’s measures
the value part, so a cut-off value gets an overflow tooltip; the option
tooltip measures each option’s optionLabel and optionDescription, else
the option.
React
import { select } from '@uniaria/core/select';
import { tooltip } from '@uniaria/core/tooltip';
import { useBehavior } from '@uniaria/react';
export function FruitSelect() {
const valueTip = useBehavior(tooltip, {
shouldOpenOnlyWhenTruncated: true,
relation: 'none',
});
const optionTip = useBehavior(tooltip, {
shouldOpenOnlyWhenTruncated: true,
relation: 'none',
positionArea: 'right',
});
const fruit = useBehavior(select, {
items: fruits,
tooltip: valueTip.instance,
optionTooltip: optionTip.instance,
});
return (
<div {...fruit.parts.root}>
<span {...fruit.parts.label}>Fruit</span>
<button {...fruit.parts.trigger}>
<span {...fruit.parts.value} />
</button>
<div {...valueTip.parts.root}>
<span {...valueTip.parts.text} />
</div>
<div {...fruit.parts.popover}>
<div {...fruit.parts.list}>…</div>
<div {...optionTip.parts.root}>
<span {...optionTip.parts.text} />
</div>
</div>
</div>
);
}Vue
<script setup lang="ts">
import { select } from '@uniaria/core/select';
import { tooltip } from '@uniaria/core/tooltip';
import { useBehavior } from '@uniaria/vue';
const valueTip = useBehavior(tooltip, {
shouldOpenOnlyWhenTruncated: true,
relation: 'none',
});
const optionTip = useBehavior(tooltip, {
shouldOpenOnlyWhenTruncated: true,
relation: 'none',
positionArea: 'right',
});
const fruit = useBehavior(select, {
items: fruits,
tooltip: valueTip.instance,
optionTooltip: optionTip.instance,
});
</script>
<template>
<div v-bind="fruit.parts.root">
<span v-bind="fruit.parts.label">Fruit</span>
<button v-bind="fruit.parts.trigger">
<span v-bind="fruit.parts.value" />
</button>
<div v-bind="valueTip.parts.root">
<span v-bind="valueTip.parts.text" />
</div>
<div v-bind="fruit.parts.popover">
<div v-bind="fruit.parts.list">…</div>
<div v-bind="optionTip.parts.root">
<span v-bind="optionTip.parts.text" />
</div>
</div>
</div>
</template>HTML
<uni-tooltip open-only-when-truncated relation="none">
<uni-select option-tooltip="fruit-option-tip">
<div data-uni="select">
<span data-element="Label">Fruit</span>
<button data-element="Trigger"><span data-element="Value"></span></button>
<div data-element="Popover">
<div data-element="List">…</div>
<uni-tooltip
id="fruit-option-tip"
open-only-when-truncated
relation="none"
position-area="right"
>
<div data-uni="tooltip"><span data-element="Text"></span></div>
</uni-tooltip>
</div>
</div>
</uni-select>
<div data-uni="tooltip"><span data-element="Text"></span></div>
</uni-tooltip>
<script type="module">
import '@uniaria/elements/select';
import '@uniaria/elements/tooltip';
</script>The <uni-tooltip> around the select is the trigger’s tooltip. The option
tooltip is linked only by its id, with option-tooltip, never through a
<uni-tooltip> around the select. Import the select’s entry before the
tooltip’s.
Put an empty <select> in the hiddenSelect part, with your name, and
form or autocomplete when you need them. UniAria fills it with an empty
first <option> and one <option> per item, keeps its selection, multiple,
required and disabled in step, and hides it visually, but not with
display: none, since Safari won’t autofill that. Then:
- The form submits the selected key under the
<select>’s name, once per key in multiple mode. - With
isRequired, the browser blocks submitting nothing. When the select is the form’s first invalid control, focus moves to its trigger and shows.form.checkValidity()moves no focus. - Resetting the form goes back to
defaultValue, even one set after the select rendered, andvalueChangereports it. A controlled select gets avalueChangeasking fordefaultValue, or without one the value it mounted with. - Browser autofill sets the value.
With selectionMode: 'multiple' (selection-mode="multiple"), a press, Enter
or Space toggles an option and the list stays open. Listbox’s ranges and Ctrl+A
(Cmd+A on Apple platforms) work in the list, and Escape, Tab or a press outside
closes it. The value text joins the selected options with , . Typing and
ArrowLeft and ArrowRight on the closed trigger change nothing in multiple mode.
React
const [value, setValue] = useState<readonly string[]>([]);
const [isOpen, setOpen] = useState(false);
const fruit = useBehavior(select, {
items: fruits,
value,
onValueChange: setValue,
isOpen,
onOpenChange: setOpen,
});Vue
<script setup lang="ts">
const value = ref<readonly string[]>([]);
const isOpen = ref(false);
const fruit = useBehavior(select, () => ({
items: fruits,
value: value.value,
onValueChange: (keys) => (value.value = keys),
isOpen: isOpen.value,
onOpenChange: (open) => (isOpen.value = open),
}));
</script>HTML
const fruit = document.querySelector('uni-select');
fruit.value = ['banana'];
fruit.addEventListener('valuechange', (event) => {
fruit.value = event.detail;
});Setting the value or isOpen property makes it controlled: the element
reports the change and waits for you to set the new value. Set defaultValue
or defaultOpen to let it keep its own. Reading the property gives the
current value either way.
A controlled select that keeps isOpen true stays open after Escape, Tab or
a choice.
Select takes Listbox’s virtualization props and passes them to its list. Put
the scroller part inside the popover, around the list, and render the
renderedKeys state as on Listbox. Rows are
positioned absolutely, so give the popover a width, such as
width: var(--anchor-width). Opening scrolls the selected option into view.
| Part | Element | Notes |
|---|---|---|
root |
any, usually div |
Around every other part. Select tracks focus within it. |
label |
any, span by default |
Names the trigger and the list. A press on it focuses the trigger without opening the list. UniAria writes no for, even on a <label>. |
triggerFrame |
any, usually div |
Optional, inside the root, around the trigger and its extras. It carries the trigger’s state attributes, and the pointer over it hovers the trigger. Without an anchor part, the popover is placed against it. |
trigger |
button |
Opens and closes the list. Gets role="combobox"; its text is the value. Add an icon hidden with aria-hidden. |
value |
any, usually span |
Optional, inside the trigger, left empty: UniAria writes the value text or the placeholder. |
triggerExtras |
any, usually span |
Optional, inside the frame, beside the trigger and never inside it: your buttons, badges and counts. It carries the trigger’s state attributes. |
anchor |
any | Optional. The popover is placed against it instead of the frame or the trigger. |
popover |
any, usually div |
Always rendered, after the trigger. Hidden while closed; shown in the top layer while open. |
arrow |
any, inside the popover | Optional. Points at the trigger, or the anchor. |
scroller |
any, usually div |
Only when virtualized: the box that scrolls, around the list. |
list |
any, usually div or ul |
Inside the popover. Holds the options and groups. |
empty |
any | Optional, inside the popover and outside the list: your “No items” text, hidden while there are options. |
close |
button |
Optional, inside the popover. A visually hidden one lets touch screen-reader users close the list. |
description |
any | Optional. Describes the trigger. |
error |
any | Optional. Shown and announced while isInvalid. |
errorText |
any, inside error |
Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself. |
hiddenSelect |
select, left empty |
Optional. Submits the value and takes autofill; UniAria writes its options. |
A part keeps an id you give it, and everything that refers to the part
follows it; UniAria writes an id only on a part that has none (see
Ids).
The item parts are Listbox’s: option, optionLabel, optionDescription,
optionExtras, group and groupLabel, inside the list and keyed by the
item’s key (see Listbox). optionExtras holds
badges, counts or icons inside an option, nothing focusable, and describes the
option after its description; extras with aria-hidden="true" stay out of
the description (see Extras in options).
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
items, getKey, getTextValue, isItemDisabled, getChildren |
— (properties, or markup) | Listbox’s: the options and how to read them. getTextValue is also the displayed value and the hidden <option>s’ text. |
||
disabledKeys |
disabled-keys (JSON) |
readonly string[] |
[] |
Disabled options, by key. |
value / defaultValue |
— (properties) | readonly string[] |
[] |
The selected keys; at most one in single mode. Keys with no item stay in it and show nothing. |
selectionMode |
selection-mode |
'single' | 'multiple' |
'single' |
Multiple mode keeps the list open while options toggle. |
isOpen / defaultOpen |
— / default-open |
boolean |
false |
Whether the list is open. Changes are reported by openchange. |
placeholder |
placeholder |
string |
— | The value part’s text while nothing is selected. There is no default text. |
isDisabled |
disabled |
boolean |
false |
The trigger is natively disabled and nothing opens. |
isReadOnly |
readonly |
boolean |
false |
aria-readonly on the trigger. The list opens and focus moves, but nothing is selected, from the list or the closed trigger. |
isRequired |
required |
boolean |
false |
aria-required on the trigger and required on the hidden <select>. |
isInvalid |
invalid |
boolean |
— | aria-invalid on the trigger, and shows the error part. Unset in a Form, the form’s errors decide it. |
shouldFocusWrap |
focus-wrap |
boolean |
false |
The arrows in the list wrap from the last option to the first. |
positionArea |
position-area |
PositionArea |
'block-end span-inline-end' |
Where the popover goes: below the trigger, aligned to its start edge. See Popover. |
positionTryFallbacks |
position-try-fallbacks |
string |
'flip-block, flip-inline' |
Where it goes when that doesn’t fit. |
triggerId |
— | string |
generated | The trigger’s id while it has none of its own. |
listId |
— | string |
generated | The list’s id while it has none of its own. Option ids are made from the list’s id. |
labelElementType |
— (the label’s tag) | string |
'span' |
The tag you render as the label. |
labelledBy |
labelled-by |
string |
— | Ids of more elements that name the trigger and the list. |
describedBy |
described-by |
string |
— | Ids of more elements that describe the trigger. |
tooltip |
tooltip (the <uni-tooltip>’s id) |
a tooltip’s instance |
— | A Tooltip for the trigger. On the element, a <uni-tooltip> that <uni-select> sits in directly links too. With shouldOpenOnlyWhenTruncated, it measures the value part, else the trigger. |
optionTooltip |
option-tooltip (the <uni-tooltip>’s id) |
a tooltip’s instance |
— | One Tooltip for every option (see Tooltips). On the element, only the attribute links it. |
virtualizer, itemSize, shouldMeasureItems, overscan, initialItemCount |
as on Listbox | Listbox’s virtualization, passed to the list. |
In a Form, Select also takes name, form, validate,
validationTrigger, validationDelay, validationMessages, dependsOn,
isEqual and shouldPreserve: see Field props for the
form.
The list always focuses options on hover and is vertical; the popover is
always modal and dismissable. Development builds warn when the select has no
name: no label part, no labelledBy and no aria-label on the trigger.
| Event | Callback prop | Detail | When |
|---|---|---|---|
valuechange |
onValueChange |
readonly string[] |
The user changed the selection, in the list or by typing or ArrowLeft and ArrowRight on the closed trigger. Never for a value you set. |
openchange |
onOpenChange |
boolean |
The user opened or closed the list. Once per change, never for an isOpen you set. |
focuschange |
onFocusChange |
boolean |
Focus entered or left the select. The trigger and the popup count as one, so choosing an option reports nothing. |
renderedkeyschange |
onRenderedKeysChange |
{ keys: readonly string[]; sections: readonly string[] } |
The set of options to render changed. Only virtualized lists need it. |
| Action | What it does |
|---|---|
open() |
Opens the list, as Enter on the trigger does: focus goes to the selected option, else the first one. Does nothing while disabled. |
close() |
Closes the list and returns focus to the trigger if it was in the list. |
In React and Vue they are on the handle (fruit.actions.open()); on the
custom element they are methods
(document.querySelector('uni-select').open()).
While isOpen is controlled, each only reports openChange and leaves the
change to you.
| Field | Type | Meaning |
|---|---|---|
value |
readonly string[] |
The selected keys. |
valueText |
string |
The selected options’ text, joined with , ; '' when none has an item. |
isPlaceholder |
boolean |
No selected key has an item: the value part shows the placeholder. |
selectionMode |
'single' | 'multiple' |
The selectionMode prop. |
isOpen |
boolean |
The list is open. |
focusedKey |
string | null |
The option with focus, or the one the list focuses when it opens. |
isEmpty |
boolean |
The list has no options. |
isDisabled, isReadOnly, isRequired, isInvalid |
boolean |
The props of the same names. |
isErrorShown |
boolean |
The error part is shown: invalid and not disabled. |
isFocusWithin |
boolean |
Focus is on the trigger or in the popup. |
isFocusVisible |
boolean |
That focus came from the keyboard. |
isVirtualized, renderedKeys, renderedSections |
Listbox’s virtualization state. |
Select what you render, and the component renders again only when it changes:
const isOpen = useBehaviorState(fruit, (state) => state.isOpen);On the custom element, read element.instance.getState(). See
State.
| Attribute | On | When |
|---|---|---|
data-uni="select" |
root | Always. |
data-element |
every other part | Always. |
data-open |
named parts | The list is open. |
data-entering, data-exiting |
named parts | The first frames after opening; closing, until your exit animations on the popover end. |
data-placeholder |
named parts | Nothing is selected. |
data-multiple |
named parts | Multiple mode. |
data-disabled, data-readonly, data-required, data-invalid |
named parts | The select is disabled, read-only, required or invalid. |
data-focus-within, data-focus-visible |
named parts | Focus is on the trigger or in the popup, and from the keyboard. |
data-pressed, data-hovered, data-focused |
trigger, triggerFrame, triggerExtras | As on Button. The frame and the extras carry the trigger’s, and data-hovered is set while the pointer is anywhere over the frame. |
data-placement |
popover, arrow | The side the popover opened on: top, bottom, left or right. |
data-empty, data-orientation |
list, empty | Listbox’s: no options, and vertical. |
data-selected, data-focused, data-focus-visible, data-hovered, data-pressed, data-disabled |
option | Listbox’s. Hovering focuses an option, so data-focused follows the mouse too. |
“Named parts” are every part except anchor, scroller and hiddenSelect.
State attributes are present or absent, never "false".
| Custom property | On | Value |
|---|---|---|
--anchor-width |
popover | The width of the anchor part, else the trigger frame, else the trigger, for min-width. |
--available-width |
popover | The most room the popover has across its positions, in pixels. |
--available-height |
popover | The same, vertically, for a max-height that leaves room to flip. |
They resolve on the popover, so use them there:
[data-uni='select'] [data-element='Popover'] {
min-width: var(--anchor-width);
max-height: min(16rem, var(--available-height));
margin: 4px 0; /* the gap from the trigger */
overflow: auto;
}By default UniAria places the open popover with inline position: fixed,
top and left, and the gap from the trigger is the popover’s margin (see
Popover for CSS anchor positioning instead). It also hides
the hidden <select> with inline styles. Everything else is your CSS.
The optional @uniaria/styles package styles
every part, as in the demo above, whose CSS tab holds only the page’s layout.
The trigger is a field’s box holding the value, on one line that ends in an
ellipsis, with a soft placeholder and a chevron the styles draw, so leave your
icon out. In a triggerFrame, the extras sit over the trigger’s end, the value
ends before them, and the chevron comes after them (see
Labels, frames and extras).
The popover is a surface a gap away from the trigger, at least as wide as it
and no larger than the room UniAria measures, and the list scrolls inside it;
options are Listbox’s. They cut their text to
one line with an ellipsis, so pair them with an option tooltip (see
Tooltips), and their extras come before the check. Opening, the
popover fades in and grows from 90% as it moves 8 pixels away from the
trigger, in 80 milliseconds, and closing reverses it; under reduced motion it
shows and hides at once. The close button stays visually hidden until it has
focus.
On the closed trigger:
| Key | Behavior |
|---|---|
| Enter, Space | Opens the list and focuses the selected option, else the first. |
| ArrowDown, Alt+ArrowDown | The same. |
| ArrowUp, Alt+ArrowUp | Opens the list and focuses the selected option, else the last. |
| ArrowLeft, ArrowRight | Single mode: selects the previous or next enabled option without opening; with nothing selected, the first. Doesn’t wrap. |
| Printable characters | Single mode: typeahead selects the next matching option without opening. Space while a query is in progress joins it. |
| Home, End, PageUp, PageDown | Not handled. |
| Escape | Not handled, so it reaches a dialog around the select. |
In the open list, focus is on an option and Listbox’s keys apply: the arrows, Home, End, the page keys, typeahead and, in multiple mode, the range keys. Besides them:
| Key | Behavior |
|---|---|
| Enter, Space | Single mode: selects the option and closes the list. Multiple mode: toggles it. |
| Escape | Closes the list without changing the value. Focus returns to the trigger. |
| Tab, Shift+Tab | Closes the list without selecting, and moves focus on from the trigger: to what follows it, or with Shift to what comes before it. |
Each of the trigger’s extras is a Tab stop of its own after the trigger, in DOM order, so Tab in the open list closes it and moves focus to the first extra.
- The trigger is a
<button>withrole="combobox",aria-haspopup="listbox",aria-expandedandaria-controls, named by the label alone; its text is the value. It reads “combobox, Fruit, has popup listbox, not expanded”, then “Apple”. Because it is a combobox, it can carryaria-required,aria-invalidandaria-readonly, which a plain button can’t.aria-controlsstays while the list is closed: Chromium computes the value from the selected option of the list it points at. - Focus moves into the list when it opens, to the selected option, else
the first one (the last for ArrowUp), and the list reads “listbox, Fruit”.
The APG’s select-only combobox keeps focus on the trigger and moves
aria-activedescendant, which VoiceOver on iOS and TalkBack ignore. - While open, the rest of the page is hidden from assistive technology
with
aria-hidden, but it isn’t inert: one press outside both closes the list and acts on what it hit. Render a visually hiddenclosebutton for touch screen-reader users, who can’t reach the page to dismiss it. - On close, focus returns to the trigger as the closing starts, so a key pressed during an exit animation doesn’t reach an option. After a press outside, focus stays where the press put it.
- The label isn’t a
<label for>: activating it would open the list. A press on it focuses the trigger and shows its focus ring. - The hidden
<select>hasaria-hidden="true"andtabindex="-1", so screen readers and Tab skip it. - The root part is needed for
focuschangeand the focus attributes. - Extras beside the trigger aren’t part of its name or value: each reads on its own, such as “button, Clear Fruit”.
- Option extras describe their option after its description: “option,
Apple, New, not selected”. Extras with
aria-hidden="true"stay out of it. - Tooltips. An overflow tooltip with
relation: 'none'adds nothing: a cut-off value still reads “combobox, Fruit, has popup listbox, not expanded”, then the whole value, which is in the trigger already. The option tooltip never describes an option.
- The trigger is a combobox, named by the label, as in Radix and the APG. React Aria’s is a plain button named “Apple Fruit”, which can’t say required, invalid or read-only.
- Focus moves into the list, as in React Aria, Radix and the browser’s
customizable select. The APG keeps focus on the trigger and moves
aria-activedescendant. - Tab closes the list without selecting and moves on. The APG selects the focused option; React Aria and Radix keep Tab in the popup.
- Typing on the closed trigger selects without opening, as native
<select>, React Aria and Radix do. The APG opens the list. - A mouse opens the list on press, and releasing over an option after
moving 10 px selects it, as native
<select>, React Aria and Radix do. The APG opens on click. Touch opens on release. - The value is an array of keys in both modes, as Listbox’s.
- Clearing and other actions are your own controls in
triggerExtras, instead of Cube UI Kit’sisClearableandactions. A press on one while the list is open closes it and acts; Cube UI Kit keeps it open. - Compared with a native
<select>, options can hold markup and groups can have headings, multiple mode opens a popup, and the hidden<select>keeps form submission, validation and autofill.