← Files DataARCHIVED FILE

templates/data-app/base/src/components/Dropdown.jsx

5.2 KB · Sep 30, 2026 · 23:19 UTC

↓ Download file

import React, { useEffect, useId, useMemo, useRef, useState } from "react";

import { Icon } from "./Icon.jsx";
import { Select as MenuSelect } from "./ui.jsx";
import { dropdownModel } from "./dropdown-model.js";

export function Dropdown({ searchable = false, choiceLabels = {}, formatChoice, ...props }) {
  const format = formatChoice ?? ((choice) => choiceLabels?.[choice]);
  if (searchable) return <SearchableDropdown {...props} formatChoice={format} />;
  return <MenuSelect {...props} formatChoice={format} />;
}

function SearchableDropdown({ label, value = "all", choices = [], onChange, allLabel = "All", placeholder,
  multiple = false, showLabel = false, formatChoice }) {
  const id = useId();
  const rootRef = useRef(null);
  const inputRef = useRef(null);
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [queryDirty, setQueryDirty] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const { display, displayedValue, isSelected, select } = useMemo(
    () => dropdownModel({ value, multiple, allLabel, formatChoice }),
    [value, multiple, allLabel, formatChoice],
  );
  const filteredChoices = useMemo(() => {
    const search = queryDirty ? query.trim().toLocaleLowerCase() : "";
    return search ? choices.filter((choice) => String(display(choice)).toLocaleLowerCase().includes(search)) : choices;
  }, [choices, query, queryDirty, display]);

  useEffect(() => {
    if (!open) return undefined;
    const close = (event) => {
      if (!rootRef.current?.contains(event.target)) {
        setOpen(false);
        setQuery("");
        setQueryDirty(false);
      }
    };
    document.addEventListener("pointerdown", close);
    return () => document.removeEventListener("pointerdown", close);
  }, [open]);

  useEffect(() => {
    setActiveIndex((index) => Math.min(index, Math.max(0, filteredChoices.length - 1)));
  }, [filteredChoices.length]);

  function choose(choice) {
    onChange?.(select(choice));
    if (!multiple) setOpen(false);
    setQuery("");
    setQueryDirty(false);
    inputRef.current?.focus();
  }

  function openMenu() {
    setOpen(true);
    setQuery("");
    setQueryDirty(false);
    const selectedIndex = choices.findIndex(isSelected);
    setActiveIndex(Math.max(0, selectedIndex));
  }

  return <div className="typeahead" ref={rootRef}>
    <div className="typeahead-control filter-trigger" data-state={open ? "open" : "closed"}
      onClick={(event) => {
        if (!event.target.closest("button")) inputRef.current?.focus({ preventScroll: true });
      }}>
      {showLabel && <span className="filter-label">{label}</span>}
      <input ref={inputRef} className="typeahead-input" role="combobox" aria-label={label}
        aria-autocomplete="list" aria-expanded={open} aria-controls={`${id}-listbox`}
        aria-activedescendant={open && filteredChoices[activeIndex] != null
          ? `${id}-option-${activeIndex}` : undefined}
        value={open && (multiple || queryDirty) ? query : displayedValue}
        placeholder={placeholder ?? (showLabel ? "Search" : `Search ${label.toLocaleLowerCase()}`)}
        onFocus={() => {
          if (!open) openMenu();
        }}
        onChange={(event) => {
          setQuery(event.target.value);
          setQueryDirty(true);
          setOpen(true);
          setActiveIndex(0);
        }}
        onKeyDown={(event) => {
          if (event.key === "ArrowDown") {
            event.preventDefault();
            if (!open) openMenu();
            else setActiveIndex((index) => Math.min(filteredChoices.length - 1, index + 1));
          } else if (event.key === "ArrowUp") {
            event.preventDefault();
            setActiveIndex((index) => Math.max(0, index - 1));
          } else if (event.key === "Enter" && open && filteredChoices[activeIndex] != null) {
            event.preventDefault();
            choose(filteredChoices[activeIndex]);
          } else if (event.key === "Escape") {
            setOpen(false);
            setQuery("");
            setQueryDirty(false);
          }
        }} />
      <button type="button" className="typeahead-toggle"
        aria-label={`${open ? "Close" : "Open"} ${label}`}
        tabIndex={-1} onClick={() => {
          if (open) { setOpen(false); setQuery(""); setQueryDirty(false); }
          else { openMenu(); inputRef.current?.focus(); }
        }}>
        <Icon name="chevronDown" className="chevron" />
      </button>
    </div>
    {open && <div id={`${id}-listbox`} className="popover typeahead-options" role="listbox" aria-label={label}
      aria-multiselectable={multiple || undefined}>
      {filteredChoices.map((choice, index) => <button type="button" role="option"
        id={`${id}-option-${index}`} className="menu-item" key={choice || "none"}
        aria-selected={isSelected(choice)} data-highlighted={index === activeIndex || undefined}
        onPointerMove={() => setActiveIndex(index)} onMouseDown={(event) => event.preventDefault()}
        onClick={() => choose(choice)}>
        <span className="menu-item-label">{display(choice)}</span>
        {isSelected(choice) && <Icon name="check" className="menu-check" />}
      </button>)}
      {!filteredChoices.length && <span className="typeahead-empty">No matches</span>}
    </div>}
  </div>;
}

SHA-256: e7761601666fd1a948541502e1ccc1985ebd30673237aecb865208e927533299