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

演示

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxPopup,
  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>
      <ComboboxPopup>
        <ComboboxContent className={"p-1"}>
          <ComboboxEmpty />
          <ComboboxList />
        </ComboboxContent>
      </ComboboxPopup>
    </Combobox>
  );
}

用法

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

特点

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

举例

默认值

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxPopup,
  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>
      <ComboboxPopup>
        <ComboboxContent className={"p-1"}>
          <ComboboxEmpty />
          <ComboboxList />
        </ComboboxContent>
      </ComboboxPopup>
    </Combobox>
  );
}

多选

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxPopup,
  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>
      <ComboboxPopup>
        <ComboboxContent className={"p-1"}>
          <ComboboxEmpty />
          <ComboboxList />
        </ComboboxContent>
      </ComboboxPopup>
    </Combobox>
  );
}

虚拟化

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

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxPopup,
  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>
      <ComboboxPopup>
        <ComboboxContent className={"max-h-60 px-1"}>
          <ComboboxEmpty className={"pt-4"} />
          <ComboboxVirtualizer>
            <ComboboxList />
          </ComboboxVirtualizer>
        </ComboboxContent>
      </ComboboxPopup>
    </Combobox>
  );
}

自定义渲染

import {
  Combobox,
  ComboboxAnchor,
  ComboboxInput,
  ComboboxTrigger,
  ComboboxPopup,
  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>
      <ComboboxPopup>
        <ComboboxContent className={"max-w-90 p-1"}>
          <ComboboxEmpty>没有找到浏览器</ComboboxEmpty>
          <ComboboxList />
        </ComboboxContent>
      </ComboboxPopup>
    </Combobox>
  );
}

搭配 TagsInput

Apple
Blueberry
import {
  Combobox,
  ComboboxAnchor,
  ComboboxEmpty,
  ComboboxContent,
  ComboboxInput,
  ComboboxList,
  ComboboxPopup,
  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
            placeholder={"搜索"}
            delimiter=""
            onChange={setValue}
            value={value}
            {...props}
          />
        )}
      >
        <ComboboxInput
          value={query}
          onChange={setQuery}
          render={(props) => {
            return <TagsInputInput {...props} />;
          }}
        />
        <ComboboxTrigger />
      </ComboboxAnchor>
      <ComboboxPopup>
        <ComboboxContent className={"p-1"}>
          <ComboboxEmpty />
          <ComboboxList />
        </ComboboxContent>
      </ComboboxPopup>
    </Combobox>
  );
}

嵌入 Textarea

import {
  ComboboxContent,
  ComboboxInput,
  ComboboxList,
  ComboboxPopup,
  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 deferedSearchValue = useDeferredValue(searchValue);

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

  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} />;
        }}
      />
      <ComboboxPopup placement={"bottom-start"}>
        <ComboboxContent className={"p-1"}>
          <ComboboxList />
        </ComboboxContent>
      </ComboboxPopup>
    </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

属性
duration

动画持续时间

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

是否多选

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

项目的集合

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

自定义 `value` 字段名

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

自定义 `label` 字段名

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

自定义 `disabled` 字段名

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

自定义 `children` 字段名

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

自定义过滤函数

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

自定义项目渲染

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

自定义组标签渲染

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

大小

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

选择后关闭

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

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

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

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

类型boolean默认值true必须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
属性
name

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

类型string默认值-必须false
属性
open

受控打开状态

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

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

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

打开状态改变时调用

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

是否禁用

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

是否必需

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

是否只读

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

是否无效

类型boolean默认值false必须false

ComboboxVirtualizer

属性
itemHeight

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

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

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

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

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

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

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

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

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

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

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

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

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

类型number默认值17必须false
属性
gap

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

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

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

类型boolean默认值false必须false

建议更改此页面