Inputs
Dropdown
Picks one value from a styled list drawn by the page, with descriptions, icons and groups in the options.
- Built on
Popover APIpopovertargetCSS anchor positioningrole=comboboxaria-activedescendant@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 7.5 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { Dropdown } from "@/components/equip/dropdown";
export default function Demo() {
return (
<div style={{ width: 200 }}>
<Dropdown
label="Data region"
defaultValue="eu-west-1"
options={[
{ value: "eu-west-1", label: "eu-west-1", description: "Ireland" },
{ value: "eu-central-1", label: "eu-central-1", description: "Frankfurt" },
{ value: "us-east-1", label: "us-east-1", description: "Virginia" },
{ value: "ap-south-1", label: "ap-south-1", description: "Mumbai" },
]}
/>
</div>
);
}Your versionchange its options in Look, This part
Examples
Groups, descriptions and disabled options
Arrow keys skip disabled options, and typing a few letters jumps to the option that starts with them.
They get an email when the ticket moves.
Code
import { Dropdown } from "@/components/equip/dropdown";
function Dot({ tone }: { tone: string }) {
return <span style={{ width: 8, height: 8, borderRadius: 4, background: tone }} aria-hidden="true" />;
}
export default function Example() {
return (
<div style={{ width: 260 }}>
<Dropdown
label="Assign to"
placeholder="Nobody yet"
hint="They get an email when the ticket moves."
options={[
{
group: "Platform team",
options: [
{ value: "ana", label: "Ana Ruiz", description: "On call this week", lead: <Dot tone="var(--eq-ok)" /> },
{ value: "ben", label: "Ben Okafor", description: "In review until 3pm", lead: <Dot tone="var(--eq-warn)" /> },
{ value: "chen", label: "Chen Wei", description: "On leave", lead: <Dot tone="var(--eq-ink-3)" />, disabled: true },
],
},
{
group: "Payments team",
options: [
{ value: "dara", label: "Dara Byrne", description: "Available", lead: <Dot tone="var(--eq-ok)" /> },
{ value: "eli", label: "Eli Navarro", description: "Available", lead: <Dot tone="var(--eq-ok)" /> },
],
},
]}
/>
</div>
);
}In a form, with sizes
With name, a hidden input carries the value, so the form submits it like a native select.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Dropdown } from "@/components/equip/dropdown";
const plans = [
{ value: "starter", label: "Starter", description: "Up to 3 projects" },
{ value: "team", label: "Team", description: "Unlimited projects, 10 seats" },
{ value: "enterprise", label: "Enterprise", description: "SSO and audit log" },
];
export default function Example() {
const [sent, setSent] = useState("");
return (
<form
style={{ display: "grid", gap: 12, width: 260 }}
onSubmit={(event) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setSent(`plan=${data.get("plan")}, rows=${data.get("rows")}`);
}}
>
<Dropdown label="Plan" name="plan" size="l" defaultValue="team" options={plans} />
<Dropdown
label="Rows per page"
name="rows"
size="s"
defaultValue="50"
options={["25", "50", "100"].map((n) => ({ value: n, label: n }))}
/>
<Button type="submit" variant="outline" size="s" style={{ justifySelf: "start" }}>
Save
</Button>
<p style={{ margin: 0, fontSize: 13 }} role="status">{sent}</p>
</form>
);
}Controlled, with an error
onValueChange is not called when the picked option is picked again.
Pick a time zone so reports land at the right hour.
No time zone yet
Code
"use client";
import { useState } from "react";
import { Dropdown } from "@/components/equip/dropdown";
const zones = [
{ value: "Europe/London", label: "London", description: "UTC+0, UTC+1 in summer" },
{ value: "Europe/Berlin", label: "Berlin", description: "UTC+1, UTC+2 in summer" },
{ value: "America/New_York", label: "New York", description: "UTC-5, UTC-4 in summer" },
{ value: "Asia/Kolkata", label: "Kolkata", description: "UTC+5:30" },
];
export default function Example() {
const [zone, setZone] = useState<string | null>(null);
return (
<div style={{ display: "grid", gap: 8, width: 260 }}>
<Dropdown
label="Time zone"
placeholder="Choose a time zone"
options={zones}
value={zone}
onValueChange={setZone}
error={zone ? undefined : "Pick a time zone so reports land at the right hour."}
/>
<p style={{ margin: 0, fontSize: 13 }}>{zone ? `Reports run at 09:00 ${zone}` : "No time zone yet"}</p>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label above the trigger. It names the trigger and the list. |
hint | ReactNode | none | Help text under the trigger, read as its description. |
error | ReactNode | none | Error text. Sets aria-invalid and adds the text to the description. |
options | ({ value: string; label: ReactNode; description?: ReactNode; lead?: ReactNode; disabled?: boolean } | { group: ReactNode; options: DropdownOption[] })[] | none | Options and groups of options, in order. description is a quieter second line in the list; lead is an icon or swatch shown in the list and the trigger. |
value | string | null | none | Controlled value; null shows the placeholder. Pair it with onValueChange. |
defaultValue | string | none | Uncontrolled starting value. |
onValueChange | (value: string) => void | none | Called with the picked value. Not called when the option already picked is picked again. |
placeholder | ReactNode | "Choose" | Shown in the trigger while nothing is picked. |
name | string | none | Submits the value with a form, in a hidden input under this name. |
size | "s" | "m" | "l" | "m" | Trigger height, matching Select, Field and Button. |
placement | "bottom-start" | "bottom-end" | "top-start" | "top-end" | "bottom-start" | Side of the trigger the list opens on and the edge it lines up with. Flips when it does not fit. |
className | string | none | Added to the root wrapper, as is style. |
...rest | ComponentProps<"button"> | none | Every native button attribute, applied to the trigger, including id, disabled and ref. |
Anatomy
| root | .eq-dropdown | Wrapper that stacks label, trigger and messages. |
|---|---|---|
| label | .eq-dropdown-label | |
| trigger | .eq-dropdown-trigger | The button with role combobox; focus stays here while the list is open. |
| lead | .eq-dropdown-lead | The picked option's lead, in the trigger. |
| value | .eq-dropdown-value | The picked option's label, or the placeholder. |
| chevron | .eq-dropdown-chevron | |
| hint | .eq-dropdown-hint | |
| error | .eq-dropdown-error | |
| list | .eq-dropdown-list | The popover with role listbox, in the top layer while open. |
| group | .eq-dropdown-group | |
| group label | .eq-dropdown-group-label | |
| option | .eq-dropdown-option | |
| option lead | .eq-dropdown-option-lead | |
| option text | .eq-dropdown-option-text | Stacks the label and the description. |
| option label | .eq-dropdown-option-label | Type-ahead matches against this text. |
| option description | .eq-dropdown-option-description | |
| tick | .eq-dropdown-tick | Draws in on the picked option. |
Keyboard
| EnterSpaceArrowDownArrowUp | On the closed trigger, opens the list on the picked option, or the first one. |
|---|---|
| HomeEnd | Opens the list on, or moves to, the first or last option. |
| ArrowDownArrowUp | In the open list, moves to the next or previous option, skipping disabled ones. It does not wrap. |
| PageDownPageUp | Moves ten options down or up. |
| A-Z | Opens the list if needed and moves to the next option starting with the typed letters. |
| EnterSpace | In the open list, picks the highlighted option and closes the list. |
| Alt+ArrowUp | Picks the highlighted option and closes the list. |
| Escape | Closes the list without changing the value; focus stays on the trigger. |
| Tab | Picks the highlighted option, closes the list and moves focus on. |
Notes
- Follows the APG select-only combobox: focus stays on the trigger and aria-activedescendant names the highlighted option.
- The open list is drawn by the page, so options can carry descriptions, icons and groups, and it wears the finish. Use Select when the platform's own picker is enough.
- The list is an auto popover in the top layer, so an overflow: hidden or scrolling ancestor cannot clip it, and a click outside closes it.
- The trigger shows the picked option's label in the prerendered page, before the client script runs.
- The label is not a label element, so clicking it focuses the trigger without opening the list.
- With name, a hidden input carries the value, so it submits with a native form; it carries an empty string while nothing is picked.
- Disabled options stay in the list and are announced, but the arrows skip them and they cannot be picked.
- CSS anchor positioning is used inside @supports when the engine has anchor-name, position-area and position-try-fallbacks (Chromium 125, Safari 26 and Firefox 147 on, each listed as partial support). Elsewhere the list is placed with fixed coordinates from the trigger's box, flipped once its size is known, and moved again as the page scrolls. The exit fade needs transition of overlay, which only Chromium has; elsewhere the list fades in but closes at once.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/dropdown.tsx474 lines
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, KeyboardEvent, ReactNode, ToggleEvent } from "react";
import "./dropdown.css";
export type DropdownOption = {
value: string;
/** What the row and, once picked, the trigger show. Type-ahead matches its text. */
label: ReactNode;
/** A second, quieter line under the label. Shown in the list only. */
description?: ReactNode;
/** An icon, swatch or other content before the label, in the list and the trigger. */
lead?: ReactNode;
disabled?: boolean;
};
export type DropdownGroup = {
/** Heading of the group. */
group: ReactNode;
options: DropdownOption[];
};
export type DropdownPlacement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
export type DropdownProps = Omit<ComponentProps<"button">, "value" | "defaultValue" | "children"> & {
/** Visible label above the trigger. */
label: ReactNode;
/** Help text under the trigger, announced as its description. */
hint?: ReactNode;
/** Error text. When set, the trigger is marked invalid and the text is announced with it. */
error?: ReactNode;
/** Options, and groups of options, in order. Each value must be unique. */
options: (DropdownOption | DropdownGroup)[];
/** Controlled value; null shows the placeholder. */
value?: string | null;
/** Uncontrolled starting value. */
defaultValue?: string;
/** Called with the picked option's value. Not called when the current option is picked again. */
onValueChange?: (value: string) => void;
/** Shown in the trigger while no option is picked. */
placeholder?: ReactNode;
/** Submits the value in a hidden input under this name. */
name?: string;
size?: "s" | "m" | "l";
/** Side of the trigger the list opens on, and the edge it lines up with. Flips when it does not fit. */
placement?: DropdownPlacement;
};
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
function isGroup(item: DropdownOption | DropdownGroup): item is DropdownGroup {
return "options" in item;
}
/* Must match the @supports condition in dropdown.css exactly. */
const ANCHOR_SUPPORT =
"(anchor-name: --a) and (position-area: block-end) and (position-try-fallbacks: flip-block)";
function anchorSupported() {
return typeof CSS !== "undefined" && CSS.supports(ANCHOR_SUPPORT);
}
/** Fallback for engines without anchor positioning: fixed coordinates from the trigger's box. */
function place(trigger: Element, surface: HTMLElement, placement: DropdownPlacement) {
const box = trigger.getBoundingClientRect();
const viewWidth = document.documentElement.clientWidth;
const viewHeight = document.documentElement.clientHeight;
const gap = parseFloat(getComputedStyle(surface).marginTop) || 0;
let top = placement.startsWith("top");
let end = placement.endsWith("end");
surface.style.minWidth = `${box.width}px`;
// Size is only known once shown; flip to the other side when this one does not fit.
const height = surface.offsetHeight + gap;
const width = surface.offsetWidth;
if (surface.offsetHeight > 0) {
if (!top && box.bottom + height > viewHeight && box.top - height >= 0) top = true;
else if (top && box.top - height < 0 && box.bottom + height <= viewHeight) top = false;
if (!end && box.left + width > viewWidth && box.right - width >= 0) end = true;
else if (end && box.right - width < 0 && box.left + width <= viewWidth) end = false;
}
const style = surface.style;
style.top = top ? "auto" : `${box.bottom}px`;
style.bottom = top ? `${viewHeight - box.top}px` : "auto";
style.left = end ? "auto" : `${box.left}px`;
style.right = end ? `${viewWidth - box.right}px` : "auto";
}
export function Dropdown({
label,
hint,
error,
options,
value,
defaultValue,
onValueChange,
placeholder = "Choose",
name,
size = "m",
placement = "bottom-start",
id,
className,
style,
disabled,
onKeyDown,
onKeyUp,
onBlur,
"aria-describedby": describedBy,
"aria-invalid": ariaInvalid,
...rest
}: DropdownProps) {
const auto = useId();
const triggerId = id ?? `${auto}trigger`;
const labelId = `${auto}label`;
const listId = `${auto}list`;
const hintId = `${auto}hint`;
const errorId = `${auto}error`;
const optionId = (index: number) => `${auto}option${index}`;
const anchorName = `--eq-dropdown-${auto.replace(/[^\w-]/g, "")}`;
const triggerRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const typed = useRef({ text: "", at: 0 });
/** Which option to light up when the list next opens; null means the picked one. */
const openAt = useRef<number | null>(null);
/** Space was handled in the open list; its key up must not click the trigger and reopen it. */
const swallowSpace = useRef(false);
const [inner, setInner] = useState<string | null>(defaultValue ?? null);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
/** Keys moved the highlight last, so it gets a ring; a pointer only tints it. */
const [byKeys, setByKeys] = useState(false);
const selected = value !== undefined ? value : inner;
const flat = options.flatMap((item) => (isGroup(item) ? item.options : [item]));
const selectedIndex = flat.findIndex((option) => option.value === selected);
const current = flat[selectedIndex];
function usable(index: number) {
return index >= 0 && index < flat.length && !flat[index].disabled;
}
/** The nearest usable option from index in the direction of step, or -1. */
function seek(index: number, step: 1 | -1) {
for (let i = index; i >= 0 && i < flat.length; i += step) if (usable(i)) return i;
return -1;
}
function first() {
return seek(0, 1);
}
function last() {
return seek(flat.length - 1, -1);
}
function isOpen() {
return listRef.current?.matches(":popover-open") ?? false;
}
function show(at: number | null) {
// Lit at once, not on the toggle event, so a key pressed straight after acts on it.
const target = at ?? (usable(selectedIndex) ? selectedIndex : first());
openAt.current = target;
setActive(target);
// A click, not showPopover(), so the trigger stays the popover's invoker
// and a later click on it closes the list instead of reopening it.
if (!isOpen()) triggerRef.current?.click();
}
function hide() {
if (isOpen()) listRef.current?.hidePopover();
}
function choose(index: number) {
if (!usable(index)) return;
const next = flat[index].value;
if (next !== selected) {
if (value === undefined) setInner(next);
onValueChange?.(next);
}
}
function move(index: number) {
if (index < 0) return;
setActive(index);
setByKeys(true);
}
/** Type-ahead: the next option whose label starts with what was typed in the last moment. */
function match(key: string, timeStamp: number, from: number) {
const memory = typed.current;
memory.text = timeStamp - memory.at > 600 ? key.toLowerCase() : memory.text + key.toLowerCase();
memory.at = timeStamp;
const repeated = memory.text.length > 1 && [...memory.text].every((c) => c === memory.text[0]);
const search = repeated ? memory.text[0] : memory.text;
// A fresh search starts after the highlighted option, so pressing one letter cycles through matches.
const start = search.length === 1 ? from + 1 : Math.max(from, 0);
const labels = listRef.current?.querySelectorAll<HTMLElement>(".eq-dropdown-option-label");
for (let step = 0; step < flat.length; step++) {
const index = (start + step) % flat.length;
const text = labels?.[index]?.textContent ?? "";
if (usable(index) && text.trim().toLowerCase().startsWith(search)) return index;
}
return -1;
}
function typing(timeStamp: number) {
return typed.current.text !== "" && timeStamp - typed.current.at <= 600;
}
function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || disabled) return;
const printable = event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
if (!isOpen()) {
let at: number | null | undefined;
if (event.key === "ArrowDown" || event.key === "ArrowUp") at = null;
else if (event.key === "Home") at = first();
else if (event.key === "End") at = last();
else if (printable && event.key !== " ") at = match(event.key, event.timeStamp, selectedIndex);
// Enter and Space fall through to the button's own click, which opens the list.
if (at === undefined) return;
event.preventDefault();
event.stopPropagation();
setByKeys(true);
show(at === -1 ? null : at);
return;
}
let next: number | undefined;
switch (event.key) {
case "ArrowDown":
if (event.altKey) return;
next = active < 0 ? first() : seek(active + 1, 1);
if (next < 0) next = active;
break;
case "ArrowUp":
if (event.altKey) {
choose(active);
hide();
event.preventDefault();
return;
}
next = active < 0 ? last() : seek(active - 1, -1);
if (next < 0) next = active;
break;
case "Home":
case "PageUp":
next = event.key === "Home" ? first() : seek(Math.max(0, active - 10), 1);
break;
case "End":
case "PageDown":
next = event.key === "End" ? last() : seek(Math.min(flat.length - 1, active + 10), -1);
break;
case "Enter":
event.preventDefault();
event.stopPropagation();
choose(active);
hide();
return;
case " ":
event.preventDefault();
event.stopPropagation();
swallowSpace.current = true;
if (typing(event.timeStamp)) {
next = match(" ", event.timeStamp, active);
break;
}
choose(active);
hide();
return;
case "Escape":
event.preventDefault();
event.stopPropagation();
hide();
return;
case "Tab":
// Picks the highlighted option and lets the browser move focus on.
choose(active);
hide();
return;
default:
if (!printable) return;
next = match(event.key, event.timeStamp, active);
}
event.preventDefault();
event.stopPropagation();
if (next !== undefined) move(next);
}
function handleToggle(event: ToggleEvent<HTMLDivElement>) {
const opening = event.newState === "open";
setOpen(opening);
if (!opening) {
setActive(-1);
setByKeys(false);
return;
}
const trigger = triggerRef.current;
// Some engines do not focus a button on click; the trigger must hold focus to take the keys.
if (trigger && document.activeElement !== trigger) trigger.focus({ preventScroll: true });
if (trigger && !anchorSupported()) place(trigger, event.currentTarget, placement);
const at = openAt.current;
openAt.current = null;
setActive(at ?? (usable(selectedIndex) ? selectedIndex : first()));
}
// Keep the highlighted row in view inside the list, without scrolling the page.
useEffect(() => {
const list = listRef.current;
if (!open || !list || active < 0) return;
const row = document.getElementById(optionId(active));
if (!row) return;
const top = row.getBoundingClientRect().top - list.getBoundingClientRect().top + list.scrollTop;
const pad = parseFloat(getComputedStyle(list).paddingTop) || 0;
if (top - pad < list.scrollTop) list.scrollTop = top - pad;
else if (top + row.offsetHeight + pad > list.scrollTop + list.clientHeight) {
list.scrollTop = top + row.offsetHeight + pad - list.clientHeight;
}
});
// Without anchor positioning, the list follows the trigger while the page scrolls.
useEffect(() => {
if (!open || anchorSupported()) return;
function update() {
if (triggerRef.current && listRef.current) place(triggerRef.current, listRef.current, placement);
}
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [open, placement]);
const described = [describedBy, present(hint) ? hintId : null, present(error) ? errorId : null]
.filter(Boolean)
.join(" ");
let index = -1;
function row(option: DropdownOption) {
index += 1;
const at = index;
return (
<div
key={option.value}
id={optionId(at)}
role="option"
className="eq-dropdown-option"
aria-selected={option.value === selected}
aria-disabled={option.disabled || undefined}
data-active={at === active || undefined}
onPointerMove={() => {
if (at !== active && usable(at)) {
setActive(at);
setByKeys(false);
}
}}
onClick={() => {
if (!usable(at)) return;
choose(at);
hide();
triggerRef.current?.focus();
}}
>
{present(option.lead) && <span className="eq-dropdown-option-lead">{option.lead}</span>}
<span className="eq-dropdown-option-text">
<span className="eq-dropdown-option-label">{option.label}</span>
{present(option.description) && (
<span className="eq-dropdown-option-description">{option.description}</span>
)}
</span>
<svg className="eq-dropdown-tick" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3.5 8.5l3 3 6-7" pathLength={1} />
</svg>
</div>
);
}
return (
<div className={["eq-dropdown", className].filter(Boolean).join(" ")} style={style} data-size={size}>
{/* Not a <label for>: that would click the trigger and open the list. */}
<span className="eq-dropdown-label" id={labelId} onClick={() => triggerRef.current?.focus()}>
{label}
</span>
<button
type="button"
{...rest}
ref={triggerRef}
id={triggerId}
className="eq-dropdown-trigger"
style={{ anchorName }}
disabled={disabled}
popoverTarget={listId}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-labelledby={labelId}
aria-activedescendant={open && active >= 0 ? optionId(active) : undefined}
aria-describedby={described || undefined}
aria-invalid={present(error) ? true : ariaInvalid}
onKeyDown={handleKeyDown}
onKeyUp={(event) => {
onKeyUp?.(event);
// Space activates a button on key up; once it has picked an option, that click must not reopen the list.
if (event.key === " " && swallowSpace.current) {
swallowSpace.current = false;
event.preventDefault();
}
}}
onBlur={(event) => {
onBlur?.(event);
hide();
}}
>
{current && present(current.lead) && <span className="eq-dropdown-lead">{current.lead}</span>}
<span className="eq-dropdown-value" data-placeholder={current ? undefined : true}>
{current ? current.label : placeholder}
</span>
<svg className="eq-dropdown-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
</svg>
</button>
{present(hint) && (
<p className="eq-dropdown-hint" id={hintId}>
{hint}
</p>
)}
{present(error) && (
<p className="eq-dropdown-error" id={errorId}>
{error}
</p>
)}
{name !== undefined && <input type="hidden" name={name} value={selected ?? ""} disabled={disabled} />}
<div
ref={listRef}
id={listId}
popover="auto"
role="listbox"
aria-labelledby={labelId}
className="eq-dropdown-list"
data-placement={placement}
data-keys={byKeys || undefined}
style={{ positionAnchor: anchorName }}
onToggle={handleToggle}
// A press inside the list must not take focus from the trigger, which owns the keyboard.
onMouseDown={(event) => event.preventDefault()}
>
{options.map((item, at) =>
isGroup(item) ? (
<div key={`group${at}`} role="group" aria-labelledby={`${auto}group${at}`} className="eq-dropdown-group">
<div id={`${auto}group${at}`} className="eq-dropdown-group-label">
{item.group}
</div>
{item.options.map(row)}
</div>
) : (
row(item)
),
)}
</div>
</div>
);
}components/equip/dropdown.css353 lines
@layer equip {
.eq-dropdown {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
min-width: 0;
font-family: var(--eq-font);
color: var(--eq-ink);
}
.eq-dropdown-label {
width: fit-content;
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
color: var(--eq-ink);
}
/* The closed box matches the select part: same height, border, padding and chevron. */
.eq-dropdown-trigger {
display: flex;
align-items: center;
gap: var(--eq-space-2);
box-sizing: border-box;
width: 100%;
min-width: 0;
height: var(--eq-h-m);
margin: 0;
padding-inline: var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.2 var(--eq-font);
text-align: start;
cursor: pointer;
user-select: none;
transition:
border-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-dropdown[data-size="s"] .eq-dropdown-trigger {
height: var(--eq-h-s);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s);
font-size: var(--eq-text-s);
}
.eq-dropdown[data-size="l"] .eq-dropdown-trigger {
height: var(--eq-h-l);
padding-inline: var(--eq-space-4);
font-size: var(--eq-text-l);
}
@media (hover: hover) {
/* :where keeps this below the invalid rule, so a red border stays red on hover. */
.eq-dropdown-trigger:hover:where(:not(:disabled)) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
.eq-dropdown-trigger:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-dropdown-trigger:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-dropdown-trigger[aria-invalid="true"] {
border-color: var(--eq-danger);
}
.eq-dropdown-trigger:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-dropdown-lead,
.eq-dropdown-option-lead {
display: inline-flex;
flex: none;
align-items: center;
}
.eq-dropdown-lead > svg,
.eq-dropdown-option-lead > svg {
width: 16px;
height: 16px;
}
.eq-dropdown-value {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-dropdown-value[data-placeholder] {
color: var(--eq-ink-3);
}
.eq-dropdown-chevron {
flex: none;
width: 16px;
height: 16px;
margin-inline-end: calc(-1 * var(--eq-space-1));
color: var(--eq-ink-2);
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
.eq-dropdown-trigger[aria-expanded="true"] .eq-dropdown-chevron {
rotate: 180deg;
}
.eq-dropdown-hint,
.eq-dropdown-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-dropdown-hint {
color: var(--eq-ink-2);
}
.eq-dropdown-error {
color: var(--eq-danger);
}
/* The open list: a floating surface in the top layer, so no scrolling or clipped ancestor hides it. */
.eq-dropdown-list {
--_shift: -4px;
box-sizing: border-box;
/* Undo the UA's centring: the list is placed next to its trigger. */
position: fixed;
inset: auto;
margin: var(--eq-space-1) 0;
width: max-content;
max-width: min(360px, calc(100vw - 2 * var(--eq-space-4)));
/* About nine rows, less when the viewport is short; the rest scrolls. */
max-height: min(calc(9 * var(--eq-h-s) + 2 * var(--eq-space-1)), calc(100dvh - 2 * var(--eq-space-4)));
overflow: auto;
overscroll-behavior: contain;
/* Rows carry their own padding, so the surface keeps only room for the focus ring. */
padding: var(--eq-space-1);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
font: var(--eq-weight) var(--eq-text-m) / 1.3 var(--eq-font);
cursor: default;
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
/* Grows out of the corner nearest its trigger. Leaving is quick, on the plain ease. */
transform-origin: top left;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease);
}
.eq-dropdown-list[data-placement^="top"] {
--_shift: 4px;
}
.eq-dropdown-list[data-placement="bottom-end"] {
transform-origin: top right;
}
.eq-dropdown-list[data-placement="top-start"] {
transform-origin: bottom left;
}
.eq-dropdown-list[data-placement="top-end"] {
transform-origin: bottom right;
}
.eq-dropdown-list:popover-open {
display: flex;
flex-direction: column;
gap: 2px;
opacity: 1;
scale: none;
translate: none;
/* Arriving: the spring carries it in and lets it settle. */
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-dropdown-list:popover-open {
opacity: 0;
scale: 0.96;
translate: 0 var(--_shift);
}
}
.eq-dropdown-option {
display: flex;
align-items: center;
gap: var(--eq-space-2);
flex: none;
box-sizing: border-box;
min-height: var(--eq-h-s);
padding: var(--eq-space-1) var(--eq-space-2);
border-radius: var(--eq-radius-s);
color: var(--eq-ink);
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* The highlighted row, moved to by keys or by the pointer, is the one Enter would pick. */
.eq-dropdown-option[data-active]:not([aria-selected="true"]) {
background: var(--eq-tint);
}
.eq-dropdown-list[data-keys] .eq-dropdown-option[data-active] {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
}
.eq-dropdown-option:active:not([aria-disabled="true"]) {
scale: var(--eq-press);
}
/* The picked row is the solid "on" state with a tick drawn in. */
.eq-dropdown-option[aria-selected="true"] {
background: var(--eq-solid);
color: var(--eq-solid-ink);
}
.eq-dropdown-option[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-dropdown-option-text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.eq-dropdown-option-description {
font-size: var(--eq-text-xs);
color: var(--eq-ink-2);
}
.eq-dropdown-option[aria-selected="true"] .eq-dropdown-option-description {
color: color-mix(in srgb, var(--eq-solid-ink) 72%, transparent);
}
.eq-dropdown-tick {
flex: none;
width: 16px;
height: 16px;
opacity: 0;
transition: opacity var(--eq-dur) var(--eq-ease);
}
/* The tick draws along its stroke (pathLength="1"); leaving, it fades and rewinds quickly. */
.eq-dropdown-tick > path {
stroke-dasharray: 2 3;
stroke-dashoffset: 2;
transition: stroke-dashoffset var(--eq-dur) var(--eq-ease);
}
.eq-dropdown-option[aria-selected="true"] .eq-dropdown-tick {
opacity: 1;
}
.eq-dropdown-option[aria-selected="true"] .eq-dropdown-tick > path {
stroke-dashoffset: 1;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-spring);
}
.eq-dropdown-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.eq-dropdown-group + .eq-dropdown-group,
.eq-dropdown-option + .eq-dropdown-group,
.eq-dropdown-group + .eq-dropdown-option {
margin-top: var(--eq-space-1);
padding-top: var(--eq-space-1);
border-top: 1px solid var(--eq-line);
}
.eq-dropdown-group-label {
padding: var(--eq-space-1) var(--eq-space-2) 2px;
color: var(--eq-ink-3);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
}
/* Keeping the surface in the top layer while it fades out needs overlay, which only some
engines can transition. Elsewhere it closes at once rather than fade outside the top layer. */
@supports (overlay: auto) {
.eq-dropdown-list {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
.eq-dropdown-list:popover-open {
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete;
}
}
/* Anchor positioning where the engine has it. dropdown.tsx uses the same condition.
The list is at least as wide as its trigger and flips to the other side when it does not fit. */
@supports (anchor-name: --a) and (position-area: block-end) and (position-try-fallbacks: flip-block) {
.eq-dropdown-list {
min-width: anchor-size(width);
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
}
.eq-dropdown-list[data-placement="bottom-end"] {
position-area: block-end span-inline-start;
}
.eq-dropdown-list[data-placement="top-start"] {
position-area: block-start span-inline-end;
}
.eq-dropdown-list[data-placement="top-end"] {
position-area: block-start span-inline-start;
}
}
}