Inputs
Multi-select
Pick several options from a searchable list in a popover, shown as removable chips or a count.
- Built on
Popover API<select multiple>aria-multiselectablearia-activedescendant@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 7.8 kB
- Dependencies
- React
- Bug
- Security
Select all
Bug
Billing
Design
Docs
Infrastructure
Mobile
Performance
Security
Try its options
Loading its options.
Code
import { MultiSelect } from "@/components/equip/multi-select";
const labels = ["Bug", "Billing", "Design", "Docs", "Infrastructure", "Mobile", "Performance", "Security"];
export default function Demo() {
return (
<div style={{ width: 216 }}>
<MultiSelect label="Labels" options={labels} defaultValue={["Bug", "Security"]} name="labels" placeholder="Add labels" />
</div>
);
}Your versionchange its options in Look, This part
Examples
Count summary
With display="count" the box keeps one line however many are chosen; disabled options cannot be picked and Select all skips them.
Backups are copied to every region you pick.
Select all
eu-west-1, Ireland
eu-central-1, Frankfurt
us-east-1, Virginia
us-west-2, Oregon
ap-south-1, Mumbai
ap-northeast-1, Tokyo
sa-east-1, São Paulo
Select all
Ana Okafor
Wei Chen
Code
import { MultiSelect } from "@/components/equip/multi-select";
const regions = [
{ value: "eu-west-1", label: "eu-west-1, Ireland" },
{ value: "eu-central-1", label: "eu-central-1, Frankfurt" },
{ value: "us-east-1", label: "us-east-1, Virginia" },
{ value: "us-west-2", label: "us-west-2, Oregon" },
{ value: "ap-south-1", label: "ap-south-1, Mumbai" },
{ value: "ap-northeast-1", label: "ap-northeast-1, Tokyo" },
{ value: "sa-east-1", label: "sa-east-1, São Paulo", disabled: true },
];
export default function Example() {
return (
<div style={{ display: "grid", gap: 16, width: 260 }}>
<MultiSelect
label="Replicate to"
hint="Backups are copied to every region you pick."
options={regions}
display="count"
defaultValue={["eu-west-1", "us-east-1"]}
placeholder="Choose regions"
/>
<MultiSelect label="Owners" options={["Ana Okafor", "Wei Chen"]} display="count" placeholder="No owners" disabled />
</div>
);
}In a form
A hidden native select carries the values, so FormData.getAll returns them and required is checked by the browser.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { MultiSelect } from "@/components/equip/multi-select";
const topics = ["Release notes", "Incidents", "Security advisories", "Billing changes", "Product research", "Events"];
export default function Example() {
const [sent, setSent] = useState<string | null>(null);
return (
<form
style={{ display: "grid", gap: 12, width: "100%", maxWidth: 280 }}
onSubmit={(event) => {
event.preventDefault();
setSent(new FormData(event.currentTarget).getAll("topics").join(", "));
}}
>
<MultiSelect label="Email me about" name="topics" options={topics} required placeholder="Choose topics" />
<Button type="submit" variant="outline" style={{ justifySelf: "start" }}>
Save preferences
</Button>
{sent !== null && <p style={{ margin: 0, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>topics = {sent}</p>}
</form>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label. Names the button, the search box and the list. |
options | (MultiSelectOption | string)[] | none | The choices, as { value, label, disabled } or plain strings. |
value | string[] | none | Controlled selection. |
defaultValue | string[] | [] | Uncontrolled starting selection. |
onValueChange | (value: string[]) => void | none | Called with the new selection, in option order. |
name | string | none | Each selected value submits under this name, as from a native select multiple. |
required | boolean | false | At least one value must be chosen; checked by native validation. |
display | "chips" | "count" | "chips" | Removable chips in the box, or one line such as 3 selected. |
searchable | boolean | true | Adds a search box above the options. |
selectAll | boolean | true | Adds a Select all row that toggles every enabled option matching the search. |
placeholder | string | "Choose" | Shown while nothing is selected. |
emptyText | string | "No matches" | Shown, and announced, when the search matches nothing. |
hint | ReactNode | none | Help text under the control. |
error | ReactNode | none | Error text. Marks the button aria-invalid and turns the border red. |
disabled | boolean | false | Disables the button, the chips and the form value. |
...rest | ComponentProps<"div"> | none | Every native div attribute on the wrapper. |
Anatomy
| root | .eq-multi-select | data-display is chips or count. |
|---|---|---|
| label | .eq-multi-select-label | |
| control | .eq-multi-select-control | The bordered box; clicking its empty space opens the list. |
| chips | .eq-multi-select-chips | |
| chip | .eq-multi-select-chip | |
| chip label | .eq-multi-select-chip-label | |
| chip remove | .eq-multi-select-chip-remove | Out of the tab order; Backspace on the button does the same. |
| trigger | .eq-multi-select-trigger | The button that opens the list; the part's one tab stop. |
| value | .eq-multi-select-value | Placeholder or summary text. |
| chevron | .eq-multi-select-chevron | |
| native | .eq-multi-select-native | Hidden select multiple that submits the values and carries required. |
| hint | .eq-multi-select-hint | |
| error | .eq-multi-select-error | |
| popover | .eq-multi-select-popover | Manual popover in the top layer, flips above when there is no room. |
| search | .eq-multi-select-search | |
| search input | .eq-multi-select-search-input | role=combobox; owns the keyboard while the list is open. |
| list | .eq-multi-select-list | role=listbox with aria-multiselectable. |
| option | .eq-multi-select-option | data-active marks the row under visual focus. |
| select all | .eq-multi-select-all | |
| option label | .eq-multi-select-option-label | |
| box | .eq-multi-select-box | The 16px check box. |
| check | .eq-multi-select-check | |
| empty | .eq-multi-select-empty | |
| footer | .eq-multi-select-footer | |
| count | .eq-multi-select-count | |
| clear | .eq-multi-select-clear | |
| visually hidden | .eq-multi-select-sr | The spoken selection and the polite live region. |
Keyboard
| EnterSpaceArrowDown | On the button, opens the list on the first option. |
|---|---|
| ArrowUp | On the button, opens the list on the last option. |
| A-Z | On the button, opens the list and starts a search with that letter. |
| BackspaceDelete | On the button, removes the last chosen option. |
| ArrowDownArrowUp | Moves through the rows, wrapping. |
| Shift+ArrowDownShift+ArrowUp | Moves and selects the row it lands on. |
| HomeEnd | Goes to the first or last row while the search is empty. |
| Enter | Toggles the highlighted row. Never submits the form. |
| Space | Toggles the highlighted row while the search is empty. |
| Ctrl+ACmd+A | Selects or clears every enabled option while the search is empty. |
| Escape | Closes the list and returns focus to the button. |
| Tab | Moves to Clear, then out of the part, closing the list. |
Notes
- Follows the APG listbox pattern for multiple selection: focus stays in the search box and aria-activedescendant names the highlighted row.
- The button's name reads the label then the selection, such as Labels, 2 selected: Bug, Security.
- A hidden native select multiple holds the values, so FormData.getAll(name) returns them and a required, empty list fails native validation; focusing it hands focus to the button.
- The selection keeps option order, however it was picked.
- Chip remove buttons stay out of the tab order so the part is one tab stop; the list and Backspace cover the keyboard.
- Each change announces the new count, and searching announces the number of matches.
- A form reset puts the selection back to defaultValue.
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/multi-select.tsx517 lines
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { ComponentProps, KeyboardEvent, ReactNode } from "react";
import "./multi-select.css";
export type MultiSelectOption = { value: string; label: string; disabled?: boolean };
export type MultiSelectProps = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & {
/** Visible label. Names the button, the search box and the list. */
label: ReactNode;
/** Help text under the control, linked with aria-describedby. */
hint?: ReactNode;
/** Error text. Marks the control invalid and is announced with it. */
error?: ReactNode;
/** The choices. A string is both value and label. */
options: (MultiSelectOption | string)[];
/** Controlled list of selected values. */
value?: string[];
/** Uncontrolled starting selection. */
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
/** Submits each selected value under this name, like a native select multiple. */
name?: string;
/** At least one value must be chosen. Checked by native constraint validation. */
required?: boolean;
disabled?: boolean;
/** Shown in the box while nothing is selected. */
placeholder?: string;
/** "chips" lists each choice with a remove button; "count" sums them up as "3 selected". */
display?: "chips" | "count";
/** Adds a search box at the top of the list. */
searchable?: boolean;
/** Adds a Select all row that toggles every option that matches the search. */
selectAll?: boolean;
/** Row shown when the search matches nothing. */
emptyText?: string;
};
/** 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 supportsPopover(el: HTMLElement) {
return typeof el.showPopover === "function";
}
const GAP = 4;
const MIN_WIDTH = 220;
export function MultiSelect({
label,
hint,
error,
options,
value,
defaultValue = [],
onValueChange,
name,
required = false,
disabled = false,
placeholder = "Choose",
display = "chips",
searchable = true,
selectAll = true,
emptyText = "No matches",
className,
...rest
}: MultiSelectProps) {
const id = useId();
const labelId = `${id}-label`;
const valueId = `${id}-value`;
const listId = `${id}-list`;
const hintId = present(hint) ? `${id}-hint` : undefined;
const errorId = present(error) ? `${id}-error` : undefined;
const optionId = (index: number) => (index === -1 ? `${id}-all` : `${id}-option-${index}`);
const rootRef = useRef<HTMLDivElement>(null);
const controlRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const [inner, setInner] = useState(defaultValue);
const [open, setOpen] = useState(false);
/** Read by key handlers, which can run again before a render has caught up with a fast typist. */
const openRef = useRef(false);
const setShown = (next: boolean) => {
openRef.current = next;
setOpen(next);
};
const [query, setQuery] = useState("");
/** Index into the visible rows; -1 is the Select all row, null is nothing. */
const [active, setActive] = useState<number | null>(null);
const [status, setStatus] = useState("");
const all: MultiSelectOption[] = options.map((o) => (typeof o === "string" ? { value: o, label: o } : o));
const selected = value ?? inner;
const needle = query.trim().toLowerCase();
const matches = needle ? all.filter((o) => o.label.toLowerCase().includes(needle)) : all;
const pickable = matches.filter((o) => !o.disabled);
const showAll = selectAll && pickable.length > 1;
const allOn = pickable.length > 0 && pickable.every((o) => selected.includes(o.value));
const first = showAll ? -1 : 0;
const activeIndex = active !== null && active < matches.length && (active >= 0 || showAll) ? active : null;
const chosen = all.filter((o) => selected.includes(o.value));
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
function commit(next: string[]) {
// Keep the order of the options, whatever order they were picked in.
const ordered = all.map((o) => o.value).filter((v) => next.includes(v));
if (value === undefined) setInner(ordered);
onValueChange?.(ordered);
setStatus(`${ordered.length} selected`);
}
function toggle(option: MultiSelectOption) {
if (option.disabled || disabled) return;
commit(selected.includes(option.value) ? selected.filter((v) => v !== option.value) : [...selected, option.value]);
}
function toggleAll() {
const values = pickable.map((o) => o.value);
commit(allOn ? selected.filter((v) => !values.includes(v)) : [...selected, ...values]);
}
function activate(index: number | null) {
if (index === -1) toggleAll();
else if (index !== null && matches[index]) toggle(matches[index]);
}
function show(at: "first" | "last" = "first", seed = "") {
if (disabled) return;
setQuery(seed);
// Starts on the first option, not Select all, so Enter never picks everything by surprise.
setActive(at === "last" ? Math.max(all.length - 1, first) : all.length > 0 ? 0 : first);
setShown(true);
}
function hide(refocus: boolean) {
setShown(false);
setActive(null);
setStatus("");
if (refocus) triggerRef.current?.focus();
}
// The list floats in the top layer, pinned under the control while the page scrolls.
useLayoutEffect(() => {
const popover = popoverRef.current;
const control = controlRef.current;
if (!popover || !control) return;
const native = supportsPopover(popover);
if (!open) {
if (native && popover.matches(":popover-open")) popover.hidePopover();
return;
}
if (native && !popover.matches(":popover-open")) popover.showPopover();
function place() {
if (!popover || !control) return;
const rect = control.getBoundingClientRect();
const width = Math.max(rect.width, MIN_WIDTH);
const left = Math.max(GAP, Math.min(rect.left, window.innerWidth - width - GAP));
popover.style.left = `${left}px`;
popover.style.width = `${width}px`;
const height = popover.offsetHeight;
const below = rect.bottom + GAP;
const above = below + height > window.innerHeight && rect.top - GAP - height >= 0;
popover.style.top = `${above ? rect.top - GAP - height : below}px`;
popover.dataset.side = above ? "top" : "bottom";
}
place();
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [open, matches.length, chosen.length]);
// Opening moves focus into the list surface; a press anywhere outside the part closes it.
useEffect(() => {
if (!open) return;
(searchRef.current ?? listRef.current)?.focus();
function outside(event: PointerEvent) {
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) hide(false);
}
document.addEventListener("pointerdown", outside);
return () => document.removeEventListener("pointerdown", outside);
}, [open]);
useEffect(() => {
if (open && activeIndex !== null) document.getElementById(optionId(activeIndex))?.scrollIntoView({ block: "nearest" });
});
// A form reset puts the native select back to its default; follow it.
useEffect(() => {
const form = rootRef.current?.closest("form");
if (!form) return;
function reset() {
if (value === undefined) setInner(defaultValue);
onValueChange?.(defaultValue);
}
form.addEventListener("reset", reset);
return () => form.removeEventListener("reset", reset);
});
function move(delta: number) {
const count = matches.length;
if (count === 0) return setActive(showAll ? -1 : null);
const start = activeIndex ?? (delta > 0 ? first - 1 : count);
let next = start + delta;
if (next > count - 1) next = first;
if (next < first) next = count - 1;
setActive(next);
return next;
}
function handleListKey(event: KeyboardEvent<HTMLElement>) {
if (event.nativeEvent.isComposing) return;
const typing = event.currentTarget === searchRef.current && query !== "";
switch (event.key) {
case "ArrowDown":
case "ArrowUp": {
event.preventDefault();
const next = move(event.key === "ArrowDown" ? 1 : -1);
// Shift with an arrow selects as it goes, as in a native multiple list.
if (event.shiftKey && next !== undefined && next >= 0 && matches[next] && !selected.includes(matches[next].value)) {
toggle(matches[next]);
}
return;
}
case "Home":
case "End":
if (typing) return;
event.preventDefault();
setActive(event.key === "Home" ? first : matches.length - 1);
return;
case "Enter":
// Always taken, so Enter never submits the surrounding form from inside the list.
event.preventDefault();
if (!event.repeat) activate(activeIndex);
return;
case " ":
if (typing) return;
event.preventDefault();
activate(activeIndex);
return;
case "Escape":
event.preventDefault();
event.stopPropagation();
hide(true);
return;
case "a":
if ((event.metaKey || event.ctrlKey) && !typing) {
event.preventDefault();
if (pickable.length > 0) toggleAll();
}
return;
}
}
function handleTriggerKey(event: KeyboardEvent<HTMLButtonElement>) {
if (disabled) return;
if (openRef.current && (event.key === "Enter" || event.key.startsWith("Arrow") || event.key === "Escape")) {
// Keys that land before focus has moved into the list still act on it.
handleListKey(event);
} else if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
show(event.key === "ArrowUp" ? "last" : "first");
} else if ((event.key === "Backspace" || event.key === "Delete") && chosen.length > 0) {
event.preventDefault();
const last = chosen[chosen.length - 1];
commit(selected.filter((v) => v !== last.value));
setStatus(`${last.label} removed. ${selected.length - 1} selected`);
} else if (searchable && event.key.length === 1 && event.key !== " " && !event.metaKey && !event.ctrlKey && !event.altKey) {
// Typing on the button starts a search with that letter.
event.preventDefault();
event.stopPropagation();
if (openRef.current) {
setQuery((q) => q + event.key);
setActive(0);
} else show("first", event.key);
}
}
const summary =
chosen.length === 0 ? "" : chosen.length === 1 ? chosen[0].label : `${chosen.length} selected`;
const spoken = chosen.length === 0 ? placeholder : `${chosen.length} selected: ${chosen.map((o) => o.label).join(", ")}`;
const announce = status || (open && needle ? (matches.length === 0 ? emptyText : `${matches.length} ${matches.length === 1 ? "option" : "options"}`) : "");
return (
<div
{...rest}
ref={rootRef}
className={["eq-multi-select", className].filter(Boolean).join(" ")}
data-display={display}
onBlur={(event) => {
// Focus leaving the part, list included, closes it.
if (open && !(event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))) hide(false);
}}
>
<label className="eq-multi-select-label" id={labelId} htmlFor={`${id}-trigger`}>
{label}
</label>
<div
ref={controlRef}
className="eq-multi-select-control"
data-disabled={disabled || undefined}
data-invalid={present(error) || undefined}
data-open={open || undefined}
onClick={(event) => {
// The bare box opens the list too; the chips' own buttons do their own thing.
if (event.target === event.currentTarget && !disabled) {
triggerRef.current?.focus();
if (!open) show();
}
}}
>
{display === "chips" && chosen.length > 0 && (
<ul className="eq-multi-select-chips">
{chosen.map((option) => (
<li key={option.value} className="eq-multi-select-chip">
<span className="eq-multi-select-chip-label">{option.label}</span>
<button
type="button"
className="eq-multi-select-chip-remove"
// One tab stop for the part: Backspace on the button removes from the keyboard.
tabIndex={-1}
aria-label={`Remove ${option.label}`}
disabled={disabled || option.disabled}
onClick={() => {
toggle(option);
triggerRef.current?.focus();
}}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="m4.5 4.5 7 7m0-7-7 7" />
</svg>
</button>
</li>
))}
</ul>
)}
<button
ref={triggerRef}
id={`${id}-trigger`}
type="button"
className="eq-multi-select-trigger"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-labelledby={`${labelId} ${valueId}`}
aria-describedby={describedBy}
aria-invalid={present(error) || undefined}
disabled={disabled}
onClick={() => (open ? hide(true) : show())}
onKeyDown={handleTriggerKey}
>
<span className="eq-multi-select-value" data-empty={chosen.length === 0 || undefined}>
{display === "count" || chosen.length === 0 ? summary || placeholder : null}
</span>
<span className="eq-multi-select-sr" id={valueId}>
{spoken}
</span>
<svg className="eq-multi-select-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>
</div>
{present(hint) && (
<p className="eq-multi-select-hint" id={hintId}>
{hint}
</p>
)}
{present(error) && (
<p className="eq-multi-select-error" id={errorId}>
{error}
</p>
)}
{/* The real form control: submits every value and carries required. Focus is handed to the button. */}
<select
className="eq-multi-select-native"
multiple
name={name}
required={required}
disabled={disabled}
tabIndex={-1}
aria-hidden="true"
value={selected}
onChange={() => {}}
onFocus={() => triggerRef.current?.focus()}
>
{all.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div
ref={popoverRef}
className="eq-multi-select-popover"
popover="manual"
hidden={!open}
// A press on a row must not move focus off the search box.
onMouseDown={(event) => {
if (!(event.target instanceof HTMLInputElement) && !(event.target instanceof HTMLButtonElement)) event.preventDefault();
}}
>
{searchable && (
<div className="eq-multi-select-search">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" />
<path d="m10.5 10.5 3 3" />
</svg>
<input
ref={searchRef}
className="eq-multi-select-search-input"
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded={open}
aria-controls={listId}
aria-labelledby={labelId}
aria-activedescendant={activeIndex !== null ? optionId(activeIndex) : undefined}
autoComplete="off"
spellCheck={false}
placeholder="Search"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
setStatus("");
}}
onKeyDown={handleListKey}
/>
</div>
)}
<div
ref={listRef}
id={listId}
className="eq-multi-select-list"
role="listbox"
aria-multiselectable="true"
aria-labelledby={labelId}
tabIndex={searchable ? undefined : -1}
aria-activedescendant={!searchable && activeIndex !== null ? optionId(activeIndex) : undefined}
onKeyDown={searchable ? undefined : handleListKey}
>
{showAll && (
<div
id={optionId(-1)}
className="eq-multi-select-option eq-multi-select-all"
role="option"
aria-selected={allOn}
data-active={activeIndex === -1 || undefined}
onClick={() => {
setActive(-1);
toggleAll();
}}
>
<span className="eq-multi-select-box" aria-hidden="true">
<svg className="eq-multi-select-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m4.5 8.25 2.25 2.25 4.75-5" pathLength="1" />
</svg>
</span>
<span className="eq-multi-select-option-label">{needle ? `Select all ${pickable.length} matches` : "Select all"}</span>
</div>
)}
{matches.map((option, index) => (
<div
key={option.value}
id={optionId(index)}
className="eq-multi-select-option"
role="option"
aria-selected={selected.includes(option.value)}
aria-disabled={option.disabled || undefined}
data-active={activeIndex === index || undefined}
onClick={() => {
setActive(index);
toggle(option);
}}
>
<span className="eq-multi-select-box" aria-hidden="true">
<svg className="eq-multi-select-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="m4.5 8.25 2.25 2.25 4.75-5" pathLength="1" />
</svg>
</span>
<span className="eq-multi-select-option-label">{option.label}</span>
</div>
))}
</div>
{matches.length === 0 && <div className="eq-multi-select-empty">{emptyText}</div>}
<div className="eq-multi-select-footer">
<span className="eq-multi-select-count">{selected.length} selected</span>
<button
type="button"
className="eq-multi-select-clear"
disabled={selected.length === 0}
onClick={() => {
commit([]);
(searchRef.current ?? listRef.current)?.focus();
}}
>
Clear
</button>
</div>
</div>
<div className="eq-multi-select-sr" role="status" aria-live="polite">
{announce}
</div>
</div>
);
}components/equip/multi-select.css497 lines
@layer equip {
.eq-multi-select {
--_chip: 20px;
position: relative;
display: grid;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-multi-select-label {
justify-self: start;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-multi-select-hint,
.eq-multi-select-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-multi-select-hint {
color: var(--eq-ink-2);
}
.eq-multi-select-error {
color: var(--eq-danger);
}
/* The bordered box. Chips wrap a row at a time; the button fills what is left. */
.eq-multi-select-control {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-1);
min-height: var(--eq-h-m);
padding-block: calc((var(--eq-h-m) - 2px - var(--_chip)) / 2);
padding-inline: 0;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
cursor: pointer;
transition: border-color var(--eq-dur) var(--eq-ease);
}
.eq-multi-select-control:has(.eq-multi-select-chips) {
padding-inline-start: calc((var(--eq-h-m) - 2px - var(--_chip)) / 2);
}
@media (hover: hover) {
/* :where keeps this below the invalid rule, so a red border stays red on hover. */
.eq-multi-select-control:hover:where(:not([data-disabled])) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
.eq-multi-select-control:has(.eq-multi-select-trigger:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-multi-select-control[data-invalid],
.eq-multi-select:has(.eq-multi-select-native:user-invalid) .eq-multi-select-control {
border-color: var(--eq-danger);
}
.eq-multi-select-control[data-disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-multi-select-control[data-disabled] * {
cursor: not-allowed;
}
/* The list keeps its semantics but lends its items to the control's wrapping row. */
.eq-multi-select-chips {
display: contents;
margin: 0;
padding: 0;
list-style: none;
}
.eq-multi-select-chip {
box-sizing: border-box;
display: inline-flex;
flex: none;
align-items: center;
gap: 2px;
max-width: 100%;
height: var(--_chip);
padding-inline: var(--eq-space-2) 2px;
border-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
cursor: default;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
/* A chip picked from the open list pops in; chips already there on load simply show. */
@starting-style {
.eq-multi-select-control[data-open] .eq-multi-select-chip {
opacity: 0;
scale: 0.8;
}
}
.eq-multi-select-chip-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-multi-select-chip-remove {
position: relative;
display: grid;
flex: none;
place-items: center;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink-2);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* The chip is 20px tall; this invisible margin gives the button a 24px target. */
.eq-multi-select-chip-remove::before {
content: "";
position: absolute;
inset: -4px;
}
.eq-multi-select-chip-remove > svg {
width: 12px;
height: 12px;
}
.eq-multi-select-chip-remove:active:not(:disabled) {
scale: calc(var(--eq-press) - 0.04);
}
@media (hover: hover) {
.eq-multi-select-chip-remove:hover:not(:disabled) {
background: var(--eq-tint-strong);
color: var(--eq-ink);
}
}
.eq-multi-select-trigger {
box-sizing: border-box;
display: flex;
flex: 1 1 48px;
align-items: center;
gap: var(--eq-space-2);
min-width: 48px;
height: var(--_chip);
margin: 0;
padding: 0 var(--eq-space-2) 0 var(--eq-space-3);
border: 0;
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
text-align: start;
cursor: pointer;
}
.eq-multi-select-chips + .eq-multi-select-trigger {
padding-inline-start: 0;
}
.eq-multi-select-trigger:focus-visible {
outline: none;
}
.eq-multi-select-value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-multi-select-value[data-empty] {
color: var(--eq-ink-3);
}
.eq-multi-select-chevron {
flex: none;
width: 16px;
height: 16px;
color: var(--eq-ink-2);
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
.eq-multi-select-trigger[aria-expanded="true"] .eq-multi-select-chevron {
rotate: 180deg;
}
/* The real select: present for the form and validation, invisible and out of reach. */
.eq-multi-select-native {
position: absolute;
bottom: 0;
left: 0;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
border: 0;
opacity: 0;
pointer-events: none;
}
/* ---- the floating list ---- */
.eq-multi-select-popover {
box-sizing: border-box;
position: fixed;
inset: auto;
display: flex;
flex-direction: column;
max-height: min(340px, calc(100dvh - 2 * var(--eq-space-4)));
margin: 0;
padding: 0;
overflow: hidden;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
color: var(--eq-ink);
opacity: 1;
scale: 1;
translate: 0 0;
transform-origin: top center;
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;
}
.eq-multi-select-popover[data-side="top"] {
transform-origin: bottom center;
}
/* Leaving is quicker than arriving, and does not spring. */
.eq-multi-select-popover[hidden] {
display: none;
opacity: 0;
scale: 0.96;
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;
}
@starting-style {
.eq-multi-select-popover:not([hidden]) {
opacity: 0;
scale: 0.96;
translate: 0 -4px;
}
.eq-multi-select-popover[data-side="top"]:not([hidden]) {
translate: 0 4px;
}
}
.eq-multi-select-search {
display: flex;
flex: none;
align-items: center;
gap: var(--eq-space-2);
height: var(--eq-h-m);
padding-inline: var(--eq-space-3);
border-bottom: 1px solid var(--eq-line);
color: var(--eq-ink-3);
}
.eq-multi-select-search > svg {
flex: none;
width: 16px;
height: 16px;
}
.eq-multi-select-search-input {
flex: 1;
min-width: 0;
height: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
outline: none;
}
.eq-multi-select-search-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-multi-select-list {
display: grid;
align-content: start;
flex: 1 1 auto;
min-height: 0;
padding: var(--eq-space-1);
overflow-y: auto;
overscroll-behavior: contain;
}
.eq-multi-select-list:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -2px;
border-radius: var(--eq-radius-l);
}
.eq-multi-select-option {
display: flex;
align-items: center;
gap: var(--eq-space-2);
min-height: var(--eq-h-s);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.2 var(--eq-font);
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-multi-select-option:active:not([aria-disabled]) {
scale: var(--eq-press);
}
@media (hover: hover) {
.eq-multi-select-option:hover:not([aria-disabled]) {
background: var(--eq-tint);
}
}
/* The row under visual focus, the one aria-activedescendant names. */
.eq-multi-select-option[data-active] {
background: var(--eq-tint-strong);
}
.eq-multi-select-option[aria-disabled] {
opacity: 0.45;
cursor: not-allowed;
}
/* Select all is set apart from the options it acts on. */
.eq-multi-select-all {
margin-bottom: var(--eq-space-1);
font-weight: var(--eq-weight-strong);
box-shadow: 0 calc(var(--eq-space-1) / 2 + 1px) 0 calc(var(--eq-space-1) / -2) var(--eq-line);
}
.eq-multi-select-option-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The check control: the same 16px box as Checkbox. */
.eq-multi-select-box {
box-sizing: border-box;
display: grid;
flex: none;
place-items: center;
width: 16px;
height: 16px;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-s);
background: var(--eq-raised);
color: var(--eq-solid-ink);
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease);
}
.eq-multi-select-option[aria-selected="true"] .eq-multi-select-box {
border-color: var(--eq-solid);
background: var(--eq-solid);
}
.eq-multi-select-check {
width: 16px;
height: 16px;
}
/* The tick draws its stroke when the option is chosen. */
.eq-multi-select-check > path {
stroke-dasharray: 1;
stroke-dashoffset: 1;
transition: stroke-dashoffset var(--eq-dur-move) var(--eq-ease);
}
.eq-multi-select-option[aria-selected="true"] .eq-multi-select-check > path {
stroke-dashoffset: 0;
}
.eq-multi-select-empty {
display: flex;
align-items: center;
min-height: var(--eq-h-s);
padding: var(--eq-space-1) var(--eq-space-3) var(--eq-space-2);
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1.2 var(--eq-font);
}
.eq-multi-select-footer {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: var(--eq-space-2);
padding: var(--eq-space-1) var(--eq-space-1) var(--eq-space-1) var(--eq-space-3);
border-top: 1px solid var(--eq-line);
}
.eq-multi-select-count {
color: var(--eq-ink-2);
font: var(--eq-weight) var(--eq-text-s) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
}
.eq-multi-select-clear {
height: var(--eq-h-s);
margin: 0;
padding: 0 var(--eq-space-2);
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-multi-select-clear:active:not(:disabled) {
scale: var(--eq-press);
}
@media (hover: hover) {
.eq-multi-select-clear:hover:not(:disabled) {
background: var(--eq-tint);
}
}
.eq-multi-select-clear:focus-visible,
.eq-multi-select-chip-remove:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-multi-select-clear:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-multi-select-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}