将文本输入与列表框相结合,允许用户过滤符合查询的项目的选项列表。

演示

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxList,
} from "@resolid/react-ui";

export default function App() {
  const collection = [
    { value: "Apple" },
    { value: "Banana" },
    { value: "Blueberry" },
    { value: "Grapes", disabled: true },
    { value: "Pineapple" },
  ];

  return (
    <Combobox collection={collection}>
      <ComboboxAnchor>
        <ComboboxInput placeholder="搜索" />
        <ComboboxTrigger />
      </ComboboxAnchor>
      <ComboboxContent className="p-1">
        <ComboboxEmpty />
        <ComboboxList />
      </ComboboxContent>
    </Combobox>
  );
}

用法

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxVirtualizer,
  ComboboxList,
} from "@resolid/react-ui";
  • Combobox: 组合框的根容器。
  • ComboboxAnchor: 组合框定位锚。
  • ComboboxInput: 组合框输入框。
  • ComboboxTrigger: 打开组合框的按钮。
  • ComboboxContent: 组合框要渲染的内容。
  • ComboboxEmpty: 组合框搜索结果为空时显示。
  • ComboboxVirtualizer: 组合框列表虚拟化。
  • ComboboxList: 组合框的项目列表。
<Combobox>
  <ComboboxAnchor>
    <ComboboxInput />
    <ComboboxTrigger />
  </ComboboxAnchor>
  <ComboboxPopup>
    <ComboboxEmpty />
    <ComboboxListbox />
  </ComboboxPopup>
</Combobox>

特点

  • 使用 WAI ARIA Combobox 设计模式作为 Combobox 辅助技术。
  • 可以控制或不受控制。
  • 全键盘导航。
  • 支持单选和多选。
  • 支持禁用选项。
  • 支持项目、标签、项目组。
  • 支持自定义占位符。

举例

默认值

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxList,
} from "@resolid/react-ui";

export default function App() {
  const collection = [
    { value: "Apple" },
    { value: "Banana" },
    { value: "Blueberry" },
    { value: "Grapes", disabled: true },
    { value: "Pineapple" },
  ];

  return (
    <Combobox defaultValue="Banana" collection={collection}>
      <ComboboxAnchor>
        <ComboboxInput placeholder="搜索" />
        <ComboboxTrigger />
      </ComboboxAnchor>
      <ComboboxContent className="p-1">
        <ComboboxEmpty />
        <ComboboxList />
      </ComboboxContent>
    </Combobox>
  );
}

多选

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxList,
} from "@resolid/react-ui";

export default function App() {
  const collection = [
    { value: "Apple" },
    { value: "Banana" },
    { value: "Blueberry" },
    { value: "Grapes", disabled: true },
    { value: "Pineapple" },
  ];

  return (
    <Combobox multiple collection={collection}>
      <ComboboxAnchor>
        <ComboboxInput placeholder="搜索" />
        <ComboboxTrigger />
      </ComboboxAnchor>
      <ComboboxContent className="p-1">
        <ComboboxEmpty />
        <ComboboxList />
      </ComboboxContent>
    </Combobox>
  );
}

虚拟化

使用 ComboboxVirtualizer 组件可以实现虚拟化

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxVirtualizer,
  ComboboxList,
} from "@resolid/react-ui";

export default function App() {
  const collection = Array.from({ length: 1000 }, (_, i) => ({
    value: `item ${i + 1}`,
  }));

  return (
    <Combobox collection={collection}>
      <ComboboxAnchor>
        <ComboboxInput placeholder="搜索" />
        <ComboboxTrigger />
      </ComboboxAnchor>
      <ComboboxContent className="max-h-60 px-1">
        <ComboboxEmpty className="pt-4" />
        <ComboboxVirtualizer>
          <ComboboxList />
        </ComboboxVirtualizer>
      </ComboboxContent>
    </Combobox>
  );
}

自定义渲染

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxList,
} from "@resolid/react-ui";
import { BrowserIcon } from "~/components/browser-icon";

export default function App() {
  const collection = [
    {
      value: "chrome",
      label: "Chrome",
      description: "Google Chrome 是一款快速、易于使用且安全的网络浏览器。",
    },
    {
      value: "firefox",
      label: "Firefox",
      description: "Firefox 是一款快速、轻量、注重隐私的浏览器,全平台可用。",
    },
    {
      value: "microsoft-edge",
      label: "Microsoft Edge",
      description: "Microsoft Edge 是 AI 驱动的浏览器。更智能的浏览方式。",
    },
    {
      value: "safari",
      label: "Safari",
      description: "Safari 是在所有 Apple 设备上体验互联网的最佳方式。",
    },
    {
      value: "opera",
      label: "Opera",
      description: "比默认浏览器更快、更安全、更智能。 功能齐全,可保护隐私、安全等。",
    },
  ];

  return (
    <Combobox
      collection={collection}
      renderItem={(item, { selected }) => {
        return (
          <div className="flex items-center gap-2">
            <div className="w-12 flex-1">
              <BrowserIcon size="2em" name={item.value} />
            </div>
            <div className="flex flex-col gap-1">
              <div>{item.label}</div>
              <div className={`text-sm ${!selected ? "text-fg-subtle" : ""}`}>
                {item.description}
              </div>
            </div>
          </div>
        );
      }}
    >
      <ComboboxAnchor>
        <ComboboxInput placeholder="搜索" />
        <ComboboxTrigger />
      </ComboboxAnchor>
      <ComboboxContent className="max-w-90 p-1">
        <ComboboxEmpty>没有找到浏览器</ComboboxEmpty>
        <ComboboxList />
      </ComboboxContent>
    </Combobox>
  );
}

搭配 TagsInput

Apple
Blueberry
import {
  Combobox,
  ComboboxAnchor,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxList,
  ComboboxContent,
  ComboboxTrigger,
  TagsInput,
  TagsInputInput,
} from "@resolid/react-ui";
import { useState } from "react";

export default function App() {
  const collection = [
    { value: "Apple" },
    { value: "Banana" },
    { value: "Blueberry" },
    { value: "Grapes", disabled: true },
    { value: "Pineapple" },
  ];

  const [value, setValue] = useState(["Apple", "Blueberry"]);
  const [query, setQuery] = useState("");

  const handleChange = (value) => {
    setValue(value);
    setQuery("");
  };

  return (
    <Combobox multiple value={value} onChange={handleChange} collection={collection}>
      <ComboboxAnchor
        render={(props) => <TagsInput delimiter="" onChange={setValue} value={value} {...props} />}
      >
        <ComboboxInput
          value={query}
          className="pe-6"
          render={(props) => {
            return <TagsInputInput placeholder="搜索" onChange={setQuery} {...props} />;
          }}
        />
        <ComboboxTrigger />
      </ComboboxAnchor>
      <ComboboxContent className="p-1">
        <ComboboxEmpty />
        <ComboboxList />
      </ComboboxContent>
    </Combobox>
  );
}

嵌入 Textarea

import {
  ComboboxInput,
  ComboboxList,
  ComboboxContent,
  ComboboxProvider,
  useCombobox,
  Textarea,
  getCaretCoordinates,
} from "@resolid/react-ui";
import { useDeferredValue, useLayoutEffect, useEffect, useMemo, useRef, useState } from "react";

export default function App() {
  const textareaRef = useRef();

  const [value, setValue] = useState("");
  const [trigger, setTrigger] = useState(null);
  const [searchValue, setSearchValue] = useState(null);
  const [caretOffset, setCaretOffset] = useState(null);
  const [comboboxValue, setComboboxValue] = useState(null);

  const deferredSearchValue = useDeferredValue(searchValue);

  const collection = useMemo(() => {
    return getList(trigger)
      .filter(
        (v) => !deferredSearchValue || v.toLowerCase().includes(deferredSearchValue.toLowerCase()),
      )
      .map((v) => ({ value: v }));
  }, [trigger, deferredSearchValue]);

  const handleInput = (e) => {
    const _trigger = getTrigger(e.currentTarget);
    const _searchValue = getSearchValue(e.currentTarget);

    if (_trigger) {
      setTrigger(_trigger);
      setOpen(true);

      if (textareaRef.current) {
        setPosition({
          getBoundingClientRect: () => {
            const { x, y, height } = getAnchorRect(textareaRef.current);
            return { x, y, height, top: y, left: x, width: 0 };
          },
        });
      }
    } else if (!_searchValue) {
      setOpen(false);
      setTrigger(null);
    }

    setValue(e.currentTarget.value);
    setSearchValue(_searchValue);
  };

  const handleKeydown = (e) => {
    if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
      setOpen(false);
    }

    if (open && e.key === "Enter") {
      e.preventDefault();
    }
  };

  const { open, setOpen, setPosition, ...combobox } = useCombobox({
    value: comboboxValue,
    collection,
    duration: 0,
    openOnChange: false,
    openOnArrowKeyDown: false,
    searchFilter: (_, item) => item,
    onChange: (value) => {
      if (!textareaRef.current) {
        return;
      }

      const offset = getTriggerOffset(textareaRef.current);
      const displayValue = getValue(value, trigger);

      if (!displayValue) {
        return;
      }

      setOpen(false);
      setComboboxValue("");

      setTrigger(null);
      setValue(
        (prev) =>
          `${prev.slice(0, offset) + displayValue} ${prev.slice(offset + searchValue.length + 1)}`,
      );

      setCaretOffset(offset + displayValue.length + 1);
    },
  });

  useEffect(() => {
    setOpen(collection.length > 0);
  }, [collection]);

  useLayoutEffect(() => {
    if (caretOffset != null) {
      textareaRef.current?.setSelectionRange(caretOffset, caretOffset);
    }
  }, [caretOffset]);

  return (
    <ComboboxProvider value={combobox}>
      <ComboboxInput
        value={value}
        onInput={handleInput}
        onKeyDown={handleKeydown}
        ref={textareaRef}
        render={(props) => {
          return <Textarea cols={39} rows={3} placeholder="输入 @, # 或者 :" {...props} />;
        }}
      />
      <ComboboxContent placement="bottom-start" className="p-1">
        <ComboboxList />
      </ComboboxContent>
    </ComboboxProvider>
  );
}

const defaultTriggers = ["@", "#", ":"];

const getTrigger = (element) => {
  const prevChar = element.value[element.selectionStart - 1];

  if (!prevChar) {
    return null;
  }

  const secondPrevChar = element.value[element.selectionStart - 2];
  const isIsolated = !secondPrevChar || /\s/.test(secondPrevChar);

  if (!isIsolated) {
    return null;
  }

  if (defaultTriggers.includes(prevChar)) {
    return prevChar;
  }

  return null;
};

const getTriggerOffset = (element) => {
  for (let i = element.selectionStart; i >= 0; i--) {
    const char = element.value[i];
    if (char && defaultTriggers.includes(char)) {
      return i;
    }
  }
  return -1;
};

const getAnchorRect = (element) => {
  const offset = getTriggerOffset(element);

  const { left, top, height } = getCaretCoordinates(element, offset + 1);
  const { x, y } = element.getBoundingClientRect();

  return {
    x: left + x - element.scrollLeft,
    y: top + y - element.scrollTop,
    height,
  };
};

const getSearchValue = (element) => {
  const offset = getTriggerOffset(element);

  if (offset === -1) {
    return "";
  }

  return element.value.slice(offset + 1, element.selectionStart);
};

const getList = (trigger) => {
  switch (trigger) {
    case "@":
      return users.map((user) => user.listValue);
    case "#":
      return issues.map((issue) => issue.listValue);
    case ":":
      return emoji.map((item) => item.listValue);
    default:
      return [];
  }
};

const getValue = (listValue, trigger) => {
  return (trigger === "@" ? users : trigger === "#" ? issues : trigger === ":" ? emoji : []).find(
    (item) => item.listValue === listValue,
  )?.value;
};

const users = [
  { value: "@diegohaz", listValue: "diegohaz" },
  { value: "@tcodes0", listValue: "tcodes0" },
  { value: "@SCasarotto", listValue: "SCasarotto" },
  { value: "@matheus1lva", listValue: "matheus1lva" },
  { value: "@tom-sherman", listValue: "tom-sherman" },
  { value: "@amogower", listValue: "amogower" },
  { value: "@lluia", listValue: "lluia" },
];

const issues = [
  { value: "#1165", listValue: "#1165 fix: Fix composite focus scroll issues" },
  { value: "#1059", listValue: "#1059 chore: Add checkbox-mixed example" },
  { value: "#1018", listValue: "#1018 feat: Add `Tree` components" },
  { value: "#1011", listValue: "#1011 Dependency Dashboard" },
  { value: "#983", listValue: "#983 [V2] Transition component" },
  { value: "#981", listValue: "#981 chore: Add `disclosure-animated` example" },
  { value: "#972", listValue: "#972 Add. turborepo" },
  { value: "#970", listValue: "#970 chore: Add `toolbar` example" },
];

const emoji = [
  { value: "😄", listValue: "😄 smile" },
  { value: "😆", listValue: "😆 laughing" },
  { value: "😊", listValue: "😊 blush" },
  { value: "😃", listValue: "😃 smiley" },
  { value: "😏", listValue: "😏 smirk" },
  { value: "😍", listValue: "😍 heart_eyes" },
];

属性

Combobox

属性
name

字段的名称, 提交表单时使用

类型string默认值必须false
属性
multiple

是否多个值

类型boolean默认值false必须false
属性
value

受控值

类型string | number | (string | number)[] | null默认值必须false
属性
defaultValue

默认值

类型string | number | (string | number)[] | null默认值null | []必须false
属性
onChange

onChange 回调

类型(value: string | number | (string | number)[] | null) => void默认值必须false
属性
open

受控打开状态

类型boolean默认值必须false
属性
defaultOpen

初始渲染时的默认打开状态

类型boolean默认值false必须false
属性
onOpenChange

打开状态改变时调用

类型(open: boolean) => void默认值必须false
属性
valueKey

自定义 `value` 字段名

类型string默认值"value"必须false
属性
labelKey

自定义 `label` 字段名

类型string默认值"label"必须false
属性
childrenKey

自定义 `children` 字段名

类型string默认值"children"必须false
属性
disabledKey

自定义 `disabled` 字段名

类型string默认值"disabled"必须false
属性
size

大小

类型"xs" | "sm" | "md" | "lg" | "xl"默认值"md"必须false
属性
duration

动画持续时间

类型number默认值250必须false
属性
disabled

是否禁用

类型boolean默认值false必须false
属性
required

是否必需

类型boolean默认值false必须false
属性
readOnly

是否只读

类型boolean默认值false必须false
属性
invalid

是否无效

类型boolean默认值false必须false
属性
closeOnSelect

选择后关闭

类型boolean默认值true必须false
属性
collection

项目的集合

类型T[]默认值必须true
属性
openOnArrowKeyDown

是否在按下箭头键时打开组合框

类型boolean默认值true必须false
属性
openOnChange

是否在输入值更改时打开组合框

类型boolean默认值true必须false
属性
renderGroupLabel

自定义组标签渲染

类型(group: T) => ReactNode默认值必须false
属性
renderItem

自定义项目渲染

类型(item: T, status: { active: boolean; selected: boolean; }) => ReactNode默认值必须false
属性
searchFilter

自定义过滤函数

类型(keyword: string, item: T) => boolean默认值必须false

ComboboxVirtualizer

属性
gap

虚拟化列表中项目之间的间距

类型number默认值必须false
属性
groupLabelHeight

分组标签的高度(以像素为单位)

类型number默认值必须false
属性
itemHeight

项目的高度(以像素为单位)

类型number默认值必须false
属性
overscan

在可见区域上方和下方渲染的项目数

类型number默认值3必须false
属性
paddingEnd

应用于虚拟器末尾的填充(以像素为单位)

类型number默认值4必须false
属性
paddingStart

应用于虚拟器开始处的填充(以像素为单位)

类型number默认值4必须false
属性
scrollPaddingEnd

滚动到元素时应用于虚拟器末尾的填充(以像素为单位)

类型number默认值4必须false
属性
scrollPaddingStart

滚动到元素时应用于虚拟器开始处的填充(以像素为单位)

类型number默认值4必须false
属性
useAnimationFrameWithResizeObserver

将 ResizeObserver 封装在 requestAnimationFrame 中,实现更顺畅的更新并减少布局抖动

类型boolean默认值必须false

建议更改此页面