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

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

PropTypeDefaultWhat it does
labelReactNodenoneVisible label. Names the button, the search box and the list.
options(MultiSelectOption | string)[]noneThe choices, as { value, label, disabled } or plain strings.
valuestring[]noneControlled selection.
defaultValuestring[][]Uncontrolled starting selection.
onValueChange(value: string[]) => voidnoneCalled with the new selection, in option order.
namestringnoneEach selected value submits under this name, as from a native select multiple.
requiredbooleanfalseAt 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.
searchablebooleantrueAdds a search box above the options.
selectAllbooleantrueAdds a Select all row that toggles every enabled option matching the search.
placeholderstring"Choose"Shown while nothing is selected.
emptyTextstring"No matches"Shown, and announced, when the search matches nothing.
hintReactNodenoneHelp text under the control.
errorReactNodenoneError text. Marks the button aria-invalid and turns the border red.
disabledbooleanfalseDisables the button, the chips and the form value.
...restComponentProps<"div">noneEvery native div attribute on the wrapper.

Anatomy

root.eq-multi-selectdata-display is chips or count.
label.eq-multi-select-label
control.eq-multi-select-controlThe 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-removeOut of the tab order; Backspace on the button does the same.
trigger.eq-multi-select-triggerThe button that opens the list; the part's one tab stop.
value.eq-multi-select-valuePlaceholder or summary text.
chevron.eq-multi-select-chevron
native.eq-multi-select-nativeHidden select multiple that submits the values and carries required.
hint.eq-multi-select-hint
error.eq-multi-select-error
popover.eq-multi-select-popoverManual popover in the top layer, flips above when there is no room.
search.eq-multi-select-search
search input.eq-multi-select-search-inputrole=combobox; owns the keyboard while the list is open.
list.eq-multi-select-listrole=listbox with aria-multiselectable.
option.eq-multi-select-optiondata-active marks the row under visual focus.
select all.eq-multi-select-all
option label.eq-multi-select-option-label
box.eq-multi-select-boxThe 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-srThe spoken selection and the polite live region.

Keyboard

EnterSpaceArrowDownOn the button, opens the list on the first option.
ArrowUpOn the button, opens the list on the last option.
A-ZOn the button, opens the list and starts a search with that letter.
BackspaceDeleteOn the button, removes the last chosen option.
ArrowDownArrowUpMoves through the rows, wrapping.
Shift+ArrowDownShift+ArrowUpMoves and selects the row it lands on.
HomeEndGoes to the first or last row while the search is empty.
EnterToggles the highlighted row. Never submits the form.
SpaceToggles the highlighted row while the search is empty.
Ctrl+ACmd+ASelects or clears every enabled option while the search is empty.
EscapeCloses the list and returns focus to the button.
TabMoves 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index