Actions
Picker
Single-choice picker for toolbars, with no visible label
Preview
size
maxVisible
Code
import css from "./Picker.module.css";
import { useEffect, useId, useRef, useState } from "react";
import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";
import { CheckIcon } from "@phosphor-icons/react/dist/csr/Check";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface Option {
name: string;
value: string | number;
}
interface PickerProps {
size: Size | ResponsiveSize;
// Required: the trigger only shows the chosen option, nothing says what it picks.
"aria-label": string;
maxVisible: number;
options: Option[];
value: string | number;
onChange: (value: string | number) => void;
}
// MUST match the CSS of .picker_options: row, gap, padding, border, half a row of peek, and the gap
// to the field.
const ROW = 40;
const GAP = 8;
const PADDING = 8;
const BORDER = 2;
const PEEK = ROW / 2;
const OFFSET = 12;
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Picker({
size,
"aria-label": ariaLabel,
maxVisible,
options,
value,
onChange,
}: PickerProps) {
const trigger = useRef<HTMLButtonElement>(null);
const picker = useRef<HTMLDivElement>(null);
const optionsBox = useRef<HTMLDivElement>(null);
const id = useId();
const listboxId = `${id}-listbox`;
const optionId = (i: number) => `${id}-option-${i}`;
const [open, setOpen] = useState(false);
const [openUp, setOpenUp] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const selectedIndex = options.findIndex((option) => option.value === value);
const selected = options[selectedIndex];
const openList = () => {
if (trigger.current) {
const spaceBelow =
window.innerHeight - trigger.current.getBoundingClientRect().bottom;
// The list as the CSS draws it: natural height up to the cap.
const optionsHeight = Math.min(
options.length * (ROW + GAP) - GAP + PADDING * 2 + BORDER * 2,
PADDING + maxVisible * (ROW + GAP) + PEEK + BORDER * 2,
);
setOpenUp(spaceBelow < OFFSET + optionsHeight);
}
setOpen(true);
};
const closeList = () => {
setOpen(false);
setFocusedIndex(-1);
};
const onClick = () => {
if (open) {
closeList();
return;
}
openList();
setFocusedIndex(selectedIndex);
};
const onKeyDown = (ev: React.KeyboardEvent) => {
if (!open) {
if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown") {
ev.preventDefault();
openList();
setFocusedIndex(selectedIndex >= 0 ? selectedIndex : 0);
}
return;
}
if (ev.key === "ArrowDown") {
ev.preventDefault();
setFocusedIndex((i) => (i + 1) % options.length);
} else if (ev.key === "ArrowUp") {
ev.preventDefault();
setFocusedIndex((i) => (i - 1 + options.length) % options.length);
} else if (ev.key === "Home") {
ev.preventDefault();
setFocusedIndex(0);
} else if (ev.key === "End") {
ev.preventDefault();
setFocusedIndex(options.length - 1);
} else if (ev.key === "PageDown") {
ev.preventDefault();
setFocusedIndex((i) => Math.min(i + maxVisible, options.length - 1));
} else if (ev.key === "PageUp") {
ev.preventDefault();
setFocusedIndex((i) => Math.max(i - maxVisible, 0));
} else if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
if (focusedIndex >= 0) onSelect(options[focusedIndex]);
} else if (ev.key === "Escape") {
ev.preventDefault();
closeList();
trigger.current?.focus();
}
};
const onSelect = (option: Option) => {
onChange(option.value);
closeList();
trigger.current?.focus();
};
useEffect(() => {
if (!open) return;
const onOutside = (ev: Event) => {
if (picker.current && !picker.current.contains(ev.target as Node)) closeList();
};
document.addEventListener("click", onOutside);
document.addEventListener("focusin", onOutside);
return () => {
document.removeEventListener("click", onOutside);
document.removeEventListener("focusin", onOutside);
};
}, [open]);
useEffect(() => {
if (!open || focusedIndex < 0 || !optionsBox.current) return;
const option = optionsBox.current.children[focusedIndex] as HTMLElement;
option?.scrollIntoView({ block: "nearest" });
}, [focusedIndex, open]);
return (
<div
ref={picker}
className={`
${css.picker}
${getSizeClasses(size)}
${open ? css.open : ""}
${openUp ? css.open_up : ""}
`}
onKeyDown={onKeyDown}
>
<button
ref={trigger}
className={css.picker_trigger}
type="button"
role="combobox"
aria-label={ariaLabel}
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={
focusedIndex >= 0 ? optionId(focusedIndex) : undefined
}
onClick={onClick}
>
<span className={css.trigger_value}>{selected?.name}</span>
<CaretDownIcon className={css.trigger_caret} aria-hidden="true" />
</button>
{/* A scroller without focusable children is a Tab stop in Chrome; with aria-activedescendant
the focus never enters the list. */}
<div
ref={optionsBox}
id={listboxId}
className={css.picker_options}
style={{ "--max-visible": maxVisible } as React.CSSProperties}
role="listbox"
tabIndex={-1}
aria-label={ariaLabel}
>
{options.map((option, index) => (
<button
key={option.value}
id={optionId(index)}
className={`
${option.value === value ? css.selected : ""}
${index === focusedIndex ? css.focused : ""}
`}
type="button"
tabIndex={-1}
role="option"
aria-selected={option.value === value}
onClick={() => onSelect(option)}
>
{option.name}
{option.value === value && (
<CheckIcon className={css.option_check} aria-hidden="true" />
)}
</button>
))}
</div>
</div>
);
}
.picker {
width: 100%;
position: relative;
/* On the root: the popover is a sibling of the trigger and inherits the font from here. */
font-size: var(--text-14);
-webkit-tap-highlight-color: transparent;
&.xs {
--picker-height: var(--size-36);
}
&.sm {
--picker-height: var(--size-40);
}
&.md {
--picker-height: var(--size-44);
}
&.lg {
--picker-height: var(--size-48);
}
&.xs_base {
--picker-height: var(--size-36);
}
&.sm_base {
--picker-height: var(--size-40);
}
&.md_base {
--picker-height: var(--size-44);
}
&.lg_base {
--picker-height: var(--size-48);
}
@media (min-width: 480px) {
&.xs_mobile {
--picker-height: var(--size-36);
}
&.sm_mobile {
--picker-height: var(--size-40);
}
&.md_mobile {
--picker-height: var(--size-44);
}
&.lg_mobile {
--picker-height: var(--size-48);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--picker-height: var(--size-36);
}
&.sm_tablet {
--picker-height: var(--size-40);
}
&.md_tablet {
--picker-height: var(--size-44);
}
&.lg_tablet {
--picker-height: var(--size-48);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--picker-height: var(--size-36);
}
&.sm_desktop {
--picker-height: var(--size-40);
}
&.md_desktop {
--picker-height: var(--size-44);
}
&.lg_desktop {
--picker-height: var(--size-48);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--picker-height: var(--size-36);
}
&.sm_wide {
--picker-height: var(--size-40);
}
&.md_wide {
--picker-height: var(--size-44);
}
&.lg_wide {
--picker-height: var(--size-48);
}
}
.picker_trigger {
width: 100%;
height: var(--picker-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
padding: 0 var(--space-12);
background: none;
border: var(--border-surface) solid var(--color-border-control);
border-radius: var(--radius-8);
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
.trigger_value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trigger_caret {
flex-shrink: 0;
font-size: 16px;
transition: transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.picker_options {
/* maxVisible rows and half of the next; a max-height, so the half only shows when there is
more. */
max-height: calc(
var(--space-8) + var(--max-visible) * (var(--size-40) + var(--space-8)) +
var(--size-40) / 2 + var(--border-interactive) * 2
);
display: none;
position: absolute;
top: calc(100% + var(--space-12));
width: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 25%, transparent)
transparent;
background: var(--color-surface-opaque);
border: var(--border-interactive) solid var(--color-surface-opaque);
border-radius: var(--radius-8);
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 30%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 30%, transparent)
);
z-index: 1;
padding: var(--space-8);
scroll-padding: var(--space-8);
button {
width: 100%;
/* Flex items in a capped column shrink to their content, and the 40px row
the max-height assumes is gone. */
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
padding: var(--space-8) var(--space-12);
background: none;
border: none;
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
height: var(--size-40);
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
border-radius: var(--radius-8);
@media (hover: hover) {
&:hover {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
}
&:active {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 15%
);
}
&.selected {
color: var(--color-accent);
}
&.focused {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
.option_check {
flex-shrink: 0;
font-size: 16px;
}
}
}
&.open {
.picker_trigger {
border-color: var(--color-contour-accent);
.trigger_caret {
transform: rotate(180deg);
}
}
.picker_options {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
}
/* After .open so it outranks it. An inset shadow, not border-width, which would move the
value. */
.picker_trigger:focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
&.open_up {
.picker_options {
top: auto;
bottom: calc(100% + var(--space-12));
}
}
}
<script setup lang="ts">
import {
computed,
onWatcherCleanup,
ref,
useId,
useTemplateRef,
watch,
} from "vue";
import { PhCaretDown, PhCheck } from "@phosphor-icons/vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface Option {
name: string;
value: string | number;
}
interface PickerProps {
size: Size | ResponsiveSize;
// Required: the trigger only shows the chosen option, nothing says what it picks.
ariaLabel: string;
maxVisible: number;
options: Option[];
}
// MUST match the CSS of .picker_options: row, gap, padding, border, half a row of peek, and the gap
// to the field.
const ROW = 40;
const GAP = 8;
const PADDING = 8;
const BORDER = 2;
const PEEK = ROW / 2;
const OFFSET = 12;
const { size, maxVisible, options } = defineProps<PickerProps>();
const model = defineModel<string | number>({ required: true });
const trigger = useTemplateRef<HTMLButtonElement>("trigger");
const picker = useTemplateRef<HTMLDivElement>("picker");
const optionsBox = useTemplateRef<HTMLDivElement>("optionsBox");
const id = useId();
const listboxId = `${id}-listbox`;
const optionId = (i: number) => `${id}-option-${i}`;
const open = ref(false);
const openUp = ref(false);
const focusedIndex = ref(-1);
const selectedIndex = computed(() =>
options.findIndex((option) => option.value === model.value),
);
const selected = computed(() => options[selectedIndex.value]);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const openList = () => {
if (trigger.value) {
const spaceBelow =
window.innerHeight - trigger.value.getBoundingClientRect().bottom;
// The list as the CSS draws it: natural height up to the cap.
const optionsHeight = Math.min(
options.length * (ROW + GAP) - GAP + PADDING * 2 + BORDER * 2,
PADDING + maxVisible * (ROW + GAP) + PEEK + BORDER * 2,
);
openUp.value = spaceBelow < OFFSET + optionsHeight;
}
open.value = true;
};
const closeList = () => {
open.value = false;
focusedIndex.value = -1;
};
const onClick = () => {
if (open.value) {
closeList();
return;
}
openList();
focusedIndex.value = selectedIndex.value;
};
const onSelect = (option: Option) => {
model.value = option.value;
closeList();
trigger.value?.focus();
};
const onKeyDown = (ev: KeyboardEvent) => {
if (!open.value) {
if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown") {
ev.preventDefault();
openList();
focusedIndex.value = selectedIndex.value >= 0 ? selectedIndex.value : 0;
}
return;
}
if (ev.key === "ArrowDown") {
ev.preventDefault();
focusedIndex.value = (focusedIndex.value + 1) % options.length;
} else if (ev.key === "ArrowUp") {
ev.preventDefault();
focusedIndex.value =
(focusedIndex.value - 1 + options.length) % options.length;
} else if (ev.key === "Home") {
ev.preventDefault();
focusedIndex.value = 0;
} else if (ev.key === "End") {
ev.preventDefault();
focusedIndex.value = options.length - 1;
} else if (ev.key === "PageDown") {
ev.preventDefault();
focusedIndex.value = Math.min(
focusedIndex.value + maxVisible,
options.length - 1,
);
} else if (ev.key === "PageUp") {
ev.preventDefault();
focusedIndex.value = Math.max(focusedIndex.value - maxVisible, 0);
} else if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
if (focusedIndex.value >= 0) onSelect(options[focusedIndex.value]);
} else if (ev.key === "Escape") {
ev.preventDefault();
closeList();
trigger.value?.focus();
}
};
watch(open, () => {
if (!open.value) return;
const onOutside = (ev: Event) => {
if (picker.value && !picker.value.contains(ev.target as Node)) closeList();
};
document.addEventListener("click", onOutside);
document.addEventListener("focusin", onOutside);
onWatcherCleanup(() => {
document.removeEventListener("click", onOutside);
document.removeEventListener("focusin", onOutside);
});
});
// flush "post" so the option list is already rendered when we scroll to it.
watch(
[focusedIndex, open],
() => {
if (!open.value || focusedIndex.value < 0 || !optionsBox.value) return;
const option = optionsBox.value.children[focusedIndex.value] as HTMLElement;
option?.scrollIntoView({ block: "nearest" });
},
{ flush: "post" },
);
</script>
<template>
<div
ref="picker"
class="picker"
:class="[sizeClasses, { open, open_up: openUp }]"
@keydown="onKeyDown"
>
<button
ref="trigger"
class="picker_trigger"
type="button"
role="combobox"
:aria-label="ariaLabel"
:aria-expanded="open"
aria-haspopup="listbox"
:aria-controls="listboxId"
:aria-activedescendant="
focusedIndex >= 0 ? optionId(focusedIndex) : undefined
"
@click="onClick"
>
<span class="trigger_value">{{ selected?.name }}</span>
<PhCaretDown class="trigger_caret" aria-hidden="true" />
</button>
<!-- A scroller without focusable children is a Tab stop in Chrome; with aria-activedescendant
the focus never enters the list. -->
<div
ref="optionsBox"
:id="listboxId"
class="picker_options"
:style="{ '--max-visible': maxVisible }"
role="listbox"
:tabindex="-1"
:aria-label="ariaLabel"
>
<button
v-for="(option, index) in options"
:key="option.value"
:id="optionId(index)"
:class="{
selected: option.value === model,
focused: index === focusedIndex,
}"
type="button"
:tabindex="-1"
role="option"
:aria-selected="option.value === model"
@click="onSelect(option)"
>
{{ option.name }}
<PhCheck v-if="option.value === model" class="option_check" aria-hidden="true" />
</button>
</div>
</div>
</template>
<style scoped>
.picker {
width: 100%;
position: relative;
/* On the root: the popover is a sibling of the trigger and inherits the font from here. */
font-size: var(--text-14);
-webkit-tap-highlight-color: transparent;
&.xs {
--picker-height: var(--size-36);
}
&.sm {
--picker-height: var(--size-40);
}
&.md {
--picker-height: var(--size-44);
}
&.lg {
--picker-height: var(--size-48);
}
&.xs_base {
--picker-height: var(--size-36);
}
&.sm_base {
--picker-height: var(--size-40);
}
&.md_base {
--picker-height: var(--size-44);
}
&.lg_base {
--picker-height: var(--size-48);
}
@media (min-width: 480px) {
&.xs_mobile {
--picker-height: var(--size-36);
}
&.sm_mobile {
--picker-height: var(--size-40);
}
&.md_mobile {
--picker-height: var(--size-44);
}
&.lg_mobile {
--picker-height: var(--size-48);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--picker-height: var(--size-36);
}
&.sm_tablet {
--picker-height: var(--size-40);
}
&.md_tablet {
--picker-height: var(--size-44);
}
&.lg_tablet {
--picker-height: var(--size-48);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--picker-height: var(--size-36);
}
&.sm_desktop {
--picker-height: var(--size-40);
}
&.md_desktop {
--picker-height: var(--size-44);
}
&.lg_desktop {
--picker-height: var(--size-48);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--picker-height: var(--size-36);
}
&.sm_wide {
--picker-height: var(--size-40);
}
&.md_wide {
--picker-height: var(--size-44);
}
&.lg_wide {
--picker-height: var(--size-48);
}
}
.picker_trigger {
width: 100%;
height: var(--picker-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
padding: 0 var(--space-12);
background: none;
border: var(--border-surface) solid var(--color-border-control);
border-radius: var(--radius-8);
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
.trigger_value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trigger_caret {
flex-shrink: 0;
font-size: 16px;
transition: transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.picker_options {
/* maxVisible rows and half of the next; a max-height, so the half only shows when there is
more. */
max-height: calc(
var(--space-8) + var(--max-visible) * (var(--size-40) + var(--space-8)) +
var(--size-40) / 2 + var(--border-interactive) * 2
);
display: none;
position: absolute;
top: calc(100% + var(--space-12));
width: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 25%, transparent)
transparent;
background: var(--color-surface-opaque);
border: var(--border-interactive) solid var(--color-surface-opaque);
border-radius: var(--radius-8);
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 30%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 30%, transparent)
);
z-index: 1;
padding: var(--space-8);
scroll-padding: var(--space-8);
button {
width: 100%;
/* Flex items in a capped column shrink to their content, and the 40px row
the max-height assumes is gone. */
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
padding: var(--space-8) var(--space-12);
background: none;
border: none;
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
height: var(--size-40);
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
border-radius: var(--radius-8);
@media (hover: hover) {
&:hover {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
}
&:active {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 15%
);
}
&.selected {
color: var(--color-accent);
}
&.focused {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
.option_check {
flex-shrink: 0;
font-size: 16px;
}
}
}
&.open {
.picker_trigger {
border-color: var(--color-contour-accent);
.trigger_caret {
transform: rotate(180deg);
}
}
.picker_options {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
}
/* After .open so it outranks it. An inset shadow, not border-width, which would move the
value. */
.picker_trigger:focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: inset 0 0 0 1px var(--color-accent);
}
&.open_up {
.picker_options {
top: auto;
bottom: calc(100% + var(--space-12));
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Trigger height (36/40/44/48, the TextField ladder). The text stays at 14. Fixed or responsive. |
| aria-label | string | required | Name of the control for screen readers. Not displayed, and the trigger only shows the chosen option and nothing says what it belongs to. |
| maxVisible | number | required | Number of whole options visible before scrolling. When there are more, part of the next one peeks in. |
| options | { name: string; value: string | number }[] | required | Dropdown options (visible name + value). |
| value | string | number | required | Selected value (controlled). There is always one, because it is a control, not a field that starts empty. |
| onChange | (value: string | number) => void | required | Callback with the value of the chosen option. |