组合框
查看源代码将文本输入与列表框相结合,允许用户过滤符合查询的项目的选项列表。
演示
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
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 |