Skip to content

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.


vue
<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>
css
.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.

vue
<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

NameDefaultType
selectedIds[]string[]?
multiSelectfalseboolean?
readonlyfalseboolean?

Emits

@Payload
update:selected-idsstring[]
validatestring[]
clear-validation

Trigger


Props

NameDefaultType
disabledfalseboolean?

Item


Props

NameDefaultType
idstring
disabledfalseboolean?

Separator


Props

NameDefaultTypeDescription
ishrstring?The is attribute for the dynamic root component.

Styling

CSS Selectors

Follows our CSS classes convention.


State selectors

SelectorDescriptionFor
[aria-selected="<boolean>"]For a selected item.
  • Item
[data-active="<boolean>"]For if an item is focused or not.
  • Item
[data-readonly="<boolean>"]For if an item that is readonly.
  • Item
[aria-disabled="<boolean>"]For an item's disabled and/or readonly state.
  • Item
[data-selected="<boolean>"]For a selected item's indicator.
  • Indicator
[data-active="<boolean>"]For if the select is active or not.
  • Trigger
[data-empty="<boolean>"]For when there is no selection.
  • Trigger

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

KeyAction
Space or or or on Trigger when not disabledFirst time it opens, it focuses the first item - every other times, it focuses on the most recently focused item.
Alt + on Trigger when not disabledFirst time it opens, it focuses the first item - every other times, it focuses on the most recently focused item.
Home on TriggerOpens and moves focus to the first item.
End on TriggerOpens and moves focus to the last item.
A-Z or a-z on TriggerOpens and goes through items starting with the letter typed, or searches for the first item matching the search.
on ItemMoves focus to the next item.
on ItemMoves focus to the previous item.
Space or Enter or Alt + on ItemSelects the item, closes the select and returns focus back to it's trigger.
Home on ItemMoves focus to the first item.
End on ItemMoves focus to the last item.
Tab on ItemSelects the item and closes the select.
A-Z or a-z on ItemFocuses through items starting with the letter typed, or searches for the first item matching the search.
esc on ItemCloses 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.

KeyAction
Shift + on ItemMoves focus to and toggles the next item.
Shift + on ItemMoves focus to and toggles the previous item.
Shift + Space on ItemSelects continuously from the most recently selected item to the focused item.
Control + Shift + Home on ItemSelects the focused item and all items upwards.
Control + Shift + End on ItemSelects the focused item and all items downwards.
Control + A ItemToggles select all on and off.

Hover and keyboard interactions are synced.

Resources