Select
DO YOU NEED A CUSTOM STYLED SELECT?
Custom select elements are infamously inaccessible. If you can use a native select instead, please do so! There's a great article on this by Sarah Higley - <select> your poison
.
<template>
<PeachyInput.Input class="select">
<PeachySelect.Input v-model:selected-ids="selectedIds">
<PeachyInput.Label>Select an option</PeachyInput.Label>
<PeachyInput.Description>
Clear with
<kbd>Backspace</kbd>
or
<kbd>Delete</kbd>
.
</PeachyInput.Description>
<PeachyInput.Error v-for="(e, i) in errs" :key="i">
{{ e }}
</PeachyInput.Error>
<PeachySelect.Trigger>
{{ mapIdsToLabel(selectedIds) }}
<ChevronSvg aria-hidden="true" />
</PeachySelect.Trigger>
<PeachySelect.Target>
<PeachySelect.List>
<PeachySelect.Item id="none">
None
<PeachySelect.Indicator>
<CheckboxSvg />
</PeachySelect.Indicator>
</PeachySelect.Item>
<PeachySelect.Label>Label</PeachySelect.Label>
<PeachySelect.Item id="one">
Item 1
<PeachySelect.Indicator>
<CheckboxSvg />
</PeachySelect.Indicator>
</PeachySelect.Item>
<PeachySelect.Item id="two" disabled>
Item 2
<PeachySelect.Indicator>
<CheckboxSvg />
</PeachySelect.Indicator>
</PeachySelect.Item>
<PeachySelect.Separator />
<PeachySelect.Label>Label</PeachySelect.Label>
<PeachySelect.Item id="three">
Item 3
<PeachySelect.Indicator>
<CheckboxSvg />
</PeachySelect.Indicator>
</PeachySelect.Item>
<PeachySelect.Item id="four">
Item 4
<PeachySelect.Indicator>
<CheckboxSvg />
</PeachySelect.Indicator>
</PeachySelect.Item>
</PeachySelect.List>
</PeachySelect.Target>
</PeachySelect.Input>
</PeachyInput.Input>
</template>
.peachy-input.select {
display: grid;
gap: var(--gap);
}
.peachy-input__description {
margin-top: 0;
opacity: 0.9;
font-size: 85%;
}
.peachy-select__trigger {
font: inherit;
color: inherit;
cursor: pointer;
border-radius: 0.15rem;
padding: 0.5rem;
width: 15rem;
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--bg);
border: 1px solid var(--border);
caret-color: var(--brand-color);
transition: border-color 0.25s;
}
.peachy-select__trigger svg {
transition: transform 0.25s;
transform-origin: center;
transform: rotate(90deg);
}
.peachy-select__trigger:hover {
border-color: var(--brand-color-dimmed);
background-color: var(--bg) !important;
}
.peachy-select__trigger[data-active="true"] {
border-color: var(--brand-color-dimmed);
}
.peachy-select__trigger[data-active="true"] svg {
transform: rotate(-90deg);
}
.peachy-select__trigger[data-empty="true"] {
color: var(--dimmed-text);
}
.peachy-select__trigger:focus-visible {
outline: none !important;
border-color: var(--brand-color);
}
.peachy-select__target {
background: none;
border: none;
}
.peachy-select__list {
list-style: none;
width: 15rem;
padding: 0.75rem 0.5rem;
margin: 0.5rem;
display: grid;
gap: 0.25rem;
border-radius: var(--border-radius);
background: var(--bg);
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.peachy-select__indicator[data-selected="true"] svg {
width: 1rem;
height: 1rem;
}
.peachy-select__indicator[data-selected="false"] svg {
display: none;
}
.peachy-select__item {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
}
.peachy-select__item,
.peachy-select__label {
border: none;
background: transparent;
border-radius: calc(var(--border-radius) / 2);
height: var(--button-height);
padding: 0.75em;
font-size: 90%;
color: var(--text);
line-height: 1;
transition: background 0.25s;
}
.peachy-select__item[aria-selected="true"] {
background-color: var(--brand-button-bg);
color: var(--brand-button-text);
}
.peachy-select__item:hover,
.peachy-select__item:focus-visible,
.peachy-select__item[data-active="true"] {
background: var(--button-hover-bg);
color: var(--text);
outline: none !important;
}
.peachy-select__item[aria-disabled="true"] {
opacity: 0.75;
pointer-events: none;
}
.peachy-select__label {
opacity: 0.7;
font-size: 85%;
text-transform: uppercase;
padding: 0 0.75em;
height: 1.25rem;
}
.peachy-select__label {
margin-top: 12px;
}
.peachy-select__separator {
width: 100%;
margin: 8px 0 8px 0;
border: 0;
border-top: 2px solid var(--divider);
}
Anatomy
Select does use <PeachyInput.Label />
, not be be confused with it's own Label
component, which is used as a label inside the list of options.
<template>
<PeachyInput.Input>
<PeachySelect.Input>
<PeachyInput.Label />
<PeachySelect.Trigger />
<PeachySelect.Target>
<PeachySelect.List>
<PeachySelect.Label />
<PeachySelect.Separator />
<PeachySelect.Item>
<PeachySelect.Indicator />
</PeachySelect.Item>
</PeachySelect.List>
</PeachySelect.Target>
</PeachySelect.Input>
</PeachyInput.Input>
</template>
<script lang="ts" setup>
import { PeachyInput, PeachySelect } from "typeach";
</script>
ACCESSIBILITY FOR AN EMPTY SELECT
The component clears the values when pressing Backspace or Delete, however this is not a recognized pattern for clearing a listbox, and is not available on devices not connected to a keyboard (e.g. mobile) - so it is important to communicate this shortcut to the user if you want them to be aware of it, and offer an alternative.
The recommended approach is to have one of the options represent no value, such as a "None" option. In the example, if I detect that the selected item is "none", I clear the value.
Props & Emits
Input
DISABLED
There is no disabled state for the select. You're expected to disable the Trigger
directly.
Props
Name | Default | Type |
---|---|---|
selectedIds | [] | string[]? |
multiSelect | false | boolean? |
readonly | false | boolean? |
Emits
@ | Payload |
---|---|
update:selected-ids | string[] |
validate | string[] |
clear-validation |
Trigger
Props
Name | Default | Type |
---|---|---|
disabled | false | boolean? |
Item
Props
Name | Default | Type |
---|---|---|
id | string | |
disabled | false | boolean? |
Separator
Props
Name | Default | Type | Description |
---|---|---|---|
is | hr | string? | The is attribute for the dynamic root component. |
Styling
CSS Selectors
Follows our CSS classes convention.
State selectors
Selector | Description | For |
---|---|---|
[aria-selected="<boolean>"] | For a selected item. |
|
[data-active="<boolean>"] | For if an item is focused or not. |
|
[data-readonly="<boolean>"] | For if an item that is readonly. |
|
[aria-disabled="<boolean>"] | For an item's disabled and/or readonly state. |
|
[data-selected="<boolean>"] | For a selected item's indicator. |
|
[data-active="<boolean>"] | For if the select is active or not. |
|
[data-empty="<boolean>"] | For when there is no selection. |
|
Accessibility
Resources: APG Select-Only Combobox, APG Listbox Pattern, Webkit Bugzilla - activedescendant
on combobox not honored and aria-selected
options in listboxes not announced - see below for a more comprehensive list.
Due to a known issue in Safari with the aria-activedescendant
attribute, the component actually does move focus, rather than rely solely on the attribute.
ACCESSIBILITY FOR AN EMPTY SELECT
The component clears the values when pressing Backspace or Delete, however this is not a recognized pattern for clearing a listbox, and is not available on devices not connected to a keyboard (e.g. mobile) - so it is important to communicate this shortcut to the user if you want them to be aware of it, and offer an alternative.
The recommended approach is to have one of the options represent no value, such as a "None" option. In the example, if I detect that the selected item is "none", I clear the value.
Keyboard interactions
Single select
Key | Action |
---|---|
Space or ↵ or ↓ or ↑ on Trigger when not disabled | First time it opens, it focuses the first item - every other times, it focuses on the most recently focused item. |
Alt + ↓ on Trigger when not disabled | First time it opens, it focuses the first item - every other times, it focuses on the most recently focused item. |
Home on Trigger | Opens and moves focus to the first item. |
End on Trigger | Opens and moves focus to the last item. |
A-Z or a-z on Trigger | Opens and goes through items starting with the letter typed, or searches for the first item matching the search. |
↓ on Item | Moves focus to the next item. |
↑ on Item | Moves focus to the previous item. |
Space or Enter or Alt + ↑ on Item | Selects the item, closes the select and returns focus back to it's trigger. |
Home on Item | Moves focus to the first item. |
End on Item | Moves focus to the last item. |
Tab on Item | Selects the item and closes the select. |
A-Z or a-z on Item | Focuses through items starting with the letter typed, or searches for the first item matching the search. |
esc on Item | Closes the select and returns focus back to it's trigger. |
Multi select
Extends the single select navigation, except - keys to select now toggles the item, and selecting an item does not close the select, and Alt + ↑ is ignored.
Key | Action |
---|---|
Shift + ↓ on Item | Moves focus to and toggles the next item. |
Shift + ↑ on Item | Moves focus to and toggles the previous item. |
Shift + Space on Item | Selects continuously from the most recently selected item to the focused item. |
Control + Shift + Home on Item | Selects the focused item and all items upwards. |
Control + Shift + End on Item | Selects the focused item and all items downwards. |
Control + A Item | Toggles select all on and off. |
Hover and keyboard interactions are synced.