跳转至

搜索下拉(可搜索单选 combobox)

表单里的选项一多,普通下拉就没法扫了。combobox 把选择拆进一个弹出层:顶部一个过滤输入框,下面一列选项,键入收窄、键盘挑选、点中即写值收弹层。国家、负责人、关联记录这类字段,就是它。

试这几下:

  • 点开国家 / 地区,键入过滤 —— ↑↓ 移动高亮、Enter 选中、Esc 关闭并归还焦点;点弹层外任意处也收起
  • 选中后再点开:高亮自动停在已选项上(带 ✓);触发器右侧的 × 一键清除
  • 负责人(异步)每次键入都模拟 300ms 请求:先 loading,快速连打只落最后一次结果
  • 键入一串乱码看空态;两个字段都留空直接保存,看错误位

规矩

  • 字段契约与 TextField/SelectField 一致:label / 错误位 / 值一个形状,选中即写值并关闭弹层。demo 用受控 props + 局部 state 管值,真实版把它包进 FormField,值和校验交给表单系统(TanStack Form + zod)
  • 高亮和选中是两回事:高亮(aria-activedescendant)只是键盘光标 —— 随过滤收窄自动回位、打开时停在当前选中项;选中才是写进表单的值
  • 键盘可达:弹层打开后焦点落在过滤输入框上,↑↓ / Enter / Esc 全程不碰鼠标;列表用 role="listbox" / role="option" 播报。demo 手写这套按键,真实版由 Popover + 列表托管焦点
  • 异步选项由过滤词驱动:demo 用一个 300ms 定时器兼作防抖与模拟延迟 —— 新键入清掉上一次,天然丢弃过期响应;真实版换成 useDebouncedSearch + 数据请求,把结果喂给 options

蓝本:add-field-combobox.md(open-dashboard @ aa9815f,MIT,Invariants 已消化进上面「规矩」)

demo 源码:assets/demo-combobox.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:搜索下拉</title>
<!--
  demo-combobox.html —— 「搜索下拉」形状的最小可玩实现(蓝本:open-dashboard 的 ComboboxField + form-combobox)。
  复现的不变量:
  - 字段契约与 TextField/SelectField 一致:label / 错误位 / 值一个形状;选中即写值并关闭弹层
  - 弹出层 = 过滤输入框 + 选项列表;键盘可达:↑↓ 移动高亮、Enter 选中、Esc 关闭并归还焦点
  - 高亮(aria-activedescendant)与选中(表单值)是两回事:高亮随过滤收窄自动回位,打开时停在当前选中项
  - 点弹层外任意处关闭;无匹配有空态;已选项在触发器回显、可一键清除
  - 异步变体:过滤词驱动请求(300ms 定时器兼作防抖与模拟延迟,新键入丢弃过期响应),加载中有 loading 态
  运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  .form-card { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
  .field { display: flex; flex-direction: column; gap: 5px; }
  .field > label { font-size: 12px; color: var(--muted); }
  .req { color: var(--danger); }

  .cb { position: relative; }
  .cb-trigger { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px;
                padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
                background: var(--card); color: var(--fg); font: inherit; cursor: pointer; text-align: left; }
  .cb-trigger:focus-visible { outline: 2px solid var(--primary); outline-offset: -1px; }
  .cb-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .cb-value.muted { color: var(--muted); }
  .cb-caret { color: var(--muted); font-size: 11px; }
  .cb-clear { position: absolute; top: 50%; right: 30px; transform: translateY(-50%);
              border: 0; background: none; color: var(--muted); font-size: 15px; line-height: 1;
              cursor: pointer; padding: 2px 5px; border-radius: 6px; }
  .cb-clear:hover { color: var(--fg); }
  .cb-pop { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 10;
            background: var(--card); border: 1px solid var(--border); border-radius: 10px;
            box-shadow: 0 8px 24px rgba(0,0,0,.16); overflow: hidden; }
  .cb-search { display: flex; align-items: center; gap: 6px; padding: 7px 10px;
               border-bottom: 1px solid var(--border); color: var(--muted); }
  .cb-search input { flex: 1; border: 0; padding: 0; background: none; border-radius: 0; }
  .cb-search input:focus { outline: none; }
  .cb-list { max-height: 186px; overflow-y: auto; padding: 4px; }
  .cb-option { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 8px;
               border: 0; background: none; color: var(--fg); font: inherit; text-align: left;
               padding: 6px 8px; border-radius: 6px; cursor: pointer; }
  .cb-option.active { background: var(--chip-off-bg); }
  .cb-check { color: var(--primary); font-size: 12px; }
  .cb-empty { padding: 18px 10px; text-align: center; color: var(--muted); font-size: 13px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useMemo, useEffect, useRef } = React;
const html = htm.bind(React.createElement);

const COUNTRIES = [
  { value: "cn", label: "中国" },      { value: "jp", label: "日本" },
  { value: "kr", label: "韩国" },      { value: "sg", label: "新加坡" },
  { value: "th", label: "泰国" },      { value: "in", label: "印度" },
  { value: "id", label: "印度尼西亚" },{ value: "de", label: "德国" },
  { value: "fr", label: "法国" },      { value: "gb", label: "英国" },
  { value: "it", label: "意大利" },    { value: "es", label: "西班牙" },
  { value: "us", label: "美国" },      { value: "ca", label: "加拿大" },
  { value: "mx", label: "墨西哥" },    { value: "br", label: "巴西" },
  { value: "au", label: "澳大利亚" },  { value: "za", label: "南非" },
];
const OWNERS = [
  { value: "u01", label: "陈禾(增长组)" },   { value: "u02", label: "李慕白(增长组)" },
  { value: "u03", label: "王小满(渠道组)" }, { value: "u04", label: "赵青(渠道组)" },
  { value: "u05", label: "周芷(大客户组)" }, { value: "u06", label: "吴限(大客户组)" },
  { value: "u07", label: "郑舟(增长组)" },   { value: "u08", label: "冯霁(客服组)" },
  { value: "u09", label: "褚遂(客服组)" },   { value: "u10", label: "卫疏(渠道组)" },
  { value: "u11", label: "蒋声(大客户组)" }, { value: "u12", label: "沈砚(增长组)" },
  { value: "u13", label: "韩澈(渠道组)" },   { value: "u14", label: "杨湜(客服组)" },
];
// 「服务端」查询:按关键字过滤成员,真实版换成数据请求
const searchOwners = q => {
  const kw = q.trim().toLowerCase();
  return OWNERS.filter(o => !kw || o.label.toLowerCase().includes(kw));
};

// 可搜索单选。同步模式传 options(内存过滤);异步模式传 searchFn(模拟 300ms 请求)。
// 高亮(activeIndex,即 aria-activedescendant)与选中(selected,写回表单的值)是两回事。
function Combobox({ name, placeholder, selected, onChange, options, searchFn, emptyText = "没有匹配项" }) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);
  const [remote, setRemote] = useState({ items: [], loading: false });
  const rootRef = useRef(null);
  const triggerRef = useRef(null);
  const listRef = useRef(null);

  const isAsync = !!searchFn;
  const localItems = useMemo(() => {
    const kw = query.trim().toLowerCase();
    return (options || []).filter(o => !kw || o.label.toLowerCase().includes(kw));
  }, [options, query]);
  const items = isAsync ? remote.items : localItems;
  const loading = isAsync && remote.loading;

  // 异步加载:300ms 定时器既是模拟网络延迟也是防抖——新键入清掉上一次,天然丢弃过期响应
  useEffect(() => {
    if (!isAsync || !open) return;
    setRemote(r => ({ items: r.items, loading: true }));
    const t = setTimeout(() => {
      const list = searchFn(query);
      setRemote({ items: list, loading: false });
      if (!query && selected) { // 初次打开:高亮停在已选项上
        const i = list.findIndex(o => o.value === selected.value);
        if (i >= 0) setActiveIndex(i);
      }
    }, 300);
    return () => clearTimeout(t);
  }, [isAsync, open, query]);

  // 过滤收窄后高亮越界则回到顶部;高亮移动时滚到可见处
  useEffect(() => {
    if (activeIndex > items.length - 1) setActiveIndex(0);
  }, [items.length, activeIndex]);
  useEffect(() => {
    if (!open || !listRef.current) return;
    const el = listRef.current.querySelector('[data-active="true"]');
    if (el) el.scrollIntoView({ block: "nearest" });
  }, [activeIndex, open, items]);

  const close = refocus => {
    setOpen(false);
    setQuery("");
    if (refocus && triggerRef.current) triggerRef.current.focus();
  };
  const openPop = () => {
    setQuery("");
    const i = (options || []).findIndex(o => selected && o.value === selected.value);
    setActiveIndex(i >= 0 ? i : 0);
    setOpen(true);
  };
  const select = option => { onChange(option); close(true); }; // 选中即写值并关闭

  // 点弹层外任意处关闭
  useEffect(() => {
    if (!open) return;
    const onDown = e => { if (rootRef.current && !rootRef.current.contains(e.target)) close(false); };
    document.addEventListener("mousedown", onDown);
    return () => document.removeEventListener("mousedown", onDown);
  }, [open]);

  const onInputKeyDown = e => {
    if (e.key === "ArrowDown") { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, items.length - 1)); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); }
    else if (e.key === "Enter") { e.preventDefault(); const o = items[activeIndex]; if (o) select(o); }
    else if (e.key === "Escape") { e.preventDefault(); close(true); }
    else if (e.key === "Tab") close(false);
  };

  const listboxId = name + "-listbox";
  const optionId = i => name + "-option-" + i;

  return html`
    <div class="cb" ref=${rootRef}>
      <button type="button" class="cb-trigger" id=${name} ref=${triggerRef}
              aria-haspopup="listbox" aria-expanded=${open}
              onClick=${() => (open ? close(false) : openPop())}
              onKeyDown=${e => {
                if (!open && e.key === "ArrowDown") { e.preventDefault(); openPop(); }
                else if (open && e.key === "Escape") { e.preventDefault(); close(true); }
              }}>
        <span class=${"cb-value" + (selected ? "" : " muted")}>${selected ? selected.label : placeholder}</span>
        <span class="cb-caret" aria-hidden="true">▾</span>
      </button>
      ${selected && html`
        <button type="button" class="cb-clear" aria-label="清除已选"
                onClick=${() => { onChange(null); if (triggerRef.current) triggerRef.current.focus(); }}>×</button>`}
      ${open && html`
        <div class="cb-pop">
          <div class="cb-search">
            <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
              <circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.5"/>
              <path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
            </svg>
            <input autoFocus value=${query} placeholder="输入关键字过滤…"
                   role="combobox" aria-expanded=${open} aria-controls=${listboxId} aria-autocomplete="list"
                   aria-activedescendant=${items[activeIndex] ? optionId(activeIndex) : undefined}
                   onChange=${e => { setQuery(e.target.value); setActiveIndex(0); }}
                   onKeyDown=${onInputKeyDown} />
          </div>
          <div class="cb-list" id=${listboxId} role="listbox" ref=${listRef}>
            ${loading && html`<div class="cb-empty">加载中…</div>`}
            ${!loading && items.length === 0 && html`<div class="cb-empty">${emptyText}</div>`}
            ${!loading && items.map((o, i) => html`
              <button type="button" key=${o.value} id=${optionId(i)} role="option"
                      aria-selected=${!!selected && o.value === selected.value}
                      data-active=${i === activeIndex ? "true" : "false"}
                      class=${"cb-option" + (i === activeIndex ? " active" : "")}
                      onClick=${() => select(o)} onMouseEnter=${() => setActiveIndex(i)}>
                <span class="cb-value">${o.label}</span>
                ${!!selected && o.value === selected.value && html`<span class="cb-check" aria-hidden="true">✓</span>`}
              </button>`)}
          </div>
        </div>`}
    </div>`;
}

function App() {
  const [country, setCountry] = useState(null);
  const [owner, setOwner] = useState(null);
  const [errs, setErrs] = useState({});
  const [toast, setToast] = useState("");

  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(""), 2200);
    return () => clearTimeout(t);
  }, [toast]);

  const submit = () => {
    const e = {};
    if (!country) e.country = "请选择国家 / 地区";
    if (!owner) e.owner = "请选择负责人";
    setErrs(e);
    if (Object.keys(e).length > 0) return;
    setToast(`已保存:${country.label} · ${owner.label}`);
  };
  const reset = () => { setCountry(null); setOwner(null); setErrs({}); };

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>新建客户</h1>
          <p class="sub">两个可搜索单选:左侧同步选项(内存过滤),右侧模拟 300ms 异步加载</p>
        </div>
      </header>

      <div class="card form-card">
        <div class="row2">
          <div class="field">
            <label htmlFor="country">国家 / 地区 <span class="req">*</span></label>
            <${Combobox} name="country" placeholder="选择国家 / 地区…" options=${COUNTRIES}
                         selected=${country} onChange=${o => { setCountry(o); setErrs(s => ({ ...s, country: null })); }} />
            ${errs.country && html`<p class="err">${errs.country}</p>`}
          </div>
          <div class="field">
            <label htmlFor="owner">负责人(异步) <span class="req">*</span></label>
            <${Combobox} name="owner" placeholder="搜索并选择负责人…" searchFn=${searchOwners}
                         emptyText="没有匹配的成员"
                         selected=${owner} onChange=${o => { setOwner(o); setErrs(s => ({ ...s, owner: null })); }} />
            ${errs.owner && html`<p class="err">${errs.owner}</p>`}
          </div>
        </div>
        <p class="sub">当前表单值:country = ${country ? country.value : "—"} · owner = ${owner ? owner.value : "—"}</p>
        <div class="actions">
          <button class="btn" onClick=${reset}>重置</button>
          <button class="btn primary" onClick=${submit}>保存</button>
        </div>
      </div>

      ${toast && html`<div class="toast">${toast}</div>`}
    </div>`;
}

ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>