一个 多选搜索选择器 组件
# 图例


# props
- value
- 类型: [],默认值 undefined
- onChange
- options
- selectedBefore
- 选中的元素提到最前面展示
- renderLengthLimit
- 限制渲染的个数
- className
- 选择器的自定义类名
- popupClassName
- 下拉框的自定义类名
- wraps
- 继承 antd-select 的其他入参
# 实现代码
点击 展开/收起 jsx 代码
/**
* @des 多选带搜索的选择器
*/
import React, { useState, useMemo, useCallback, useEffect, memo } from 'react';
import PinyinEngine from 'pinyin-engine';
import { Select, Input, Button, Dropdown, Checkbox } from 'antd';
import { VariableSizeList as VirtualList } from 'react-window';
import Abbr from 'components/Abbr';
import classNames from 'classnames';
import _ from 'lodash';
import './MultiSelect.scss';
const { Option } = Select;
const MultiSelect = ({
selectedBefore = true,
className = '',
popupClassName = '',
options: optionInProps,
value,
onChange,
isVirtual,
renderLengthLimit,
...wraps
}) => {
const [searchText, setSearchText] = useState('');
const [allSelected, setAllSelected] = useState(false);
const [open, setOpen] = useState(false);
const [selectInnerValue, setSelectInnerValue] = useState([]);
useEffect(() => {
setSelectInnerValue(value || []);
}, [value]);
useEffect(() => {
setSearchText('');
}, [open]);
const options = useMemo(() => {
return selectedBefore
? optionInProps
.map((t) => ({ ...t, selected: value?.includes(t.value) ? 1 : 0 }))
.sort((a, b) => b.selected - a.selected)
: optionInProps;
}, [selectedBefore, optionInProps, value]);
// 过滤
const getFilteredOptions = useMemo(() => {
const results = _.filter(options, (t) => {
let filterKey;
if (_.isString(t.label)) {
filterKey = 'label';
} else {
filterKey = 'filterLabel';
}
const searchTextFilter = searchText
? t[filterKey]?.includes(searchText)
: true;
const engine = new PinyinEngine([t], [filterKey]);
const pinyin = engine?.query(searchText?.toLowerCase());
const pinyinFilter = pinyin?.length > 0;
return searchTextFilter || pinyinFilter;
});
if (renderLengthLimit && renderLengthLimit >= 0) {
return results.slice(0, renderLengthLimit);
}
return results;
}, [options, searchText, renderLengthLimit]);
// 是否是全选
const isAllChecked = useMemo(() => {
return getFilteredOptions.every((t) => selectInnerValue?.includes(t.value));
}, [getFilteredOptions, selectInnerValue]);
// 是否半选
const isHalfChecked = useMemo(() => {
return selectInnerValue?.length > 0 && !isAllChecked;
}, [selectInnerValue, isAllChecked]);
const handleOnChange = useCallback(
(val) => {
if (!_.isNil(val)) {
onChange(val);
} else {
onChange([]);
}
},
[onChange],
);
// 选中一个
const handleClickOption = useCallback((checked, val) => {
if (checked) {
setSelectInnerValue((prev) => [...prev, val]);
} else {
setSelectInnerValue((prev) => _.filter(prev, (t) => t !== val));
}
}, []);
// 全选
const handleClickSelectAll = useCallback(
(checked) => {
const nowOptions = getFilteredOptions.map(({ value }) => value);
if (checked) {
setSelectInnerValue((prev) => _.uniq([...nowOptions, ...prev]));
setAllSelected(true);
} else {
setSelectInnerValue((prev) =>
_.filter(prev, (t) => !nowOptions.includes(t)),
);
setAllSelected(false);
}
},
[getFilteredOptions],
);
// 点击确定
const handleOk = () => {
handleOnChange(selectInnerValue);
setOpen(false);
};
// 单个项目渲染组件
// eslint-disable-next-line react/display-name
const ItemRenderer = memo(({ index, style, data }) => {
const { label, value, des } = data[index];
return (
<div
key={value}
className={classNames('option-item', {
'option-item-active': selectInnerValue?.includes(value),
})}
style={{ ...style, height: '22px' }}
>
<Checkbox
checked={selectInnerValue?.includes(value)}
onChange={(e) => handleClickOption(e.target.checked, value)}
>
<div className="abbr-option-item">
<Abbr className="abbr-option-item-label" key={value} text={label} />
{des && <span className="abbr-option-item-des">{des}</span>}
</div>
</Checkbox>
</div>
);
});
return (
<Select
{...wraps}
mode="multiple"
value={value}
onChange={handleOnChange}
className={classNames('mutli-select-element', className)}
open={open}
showSearch={false}
onDropdownVisibleChange={setOpen}
popupClassName={classNames(
'mutli-select-element-dropdown',
popupClassName,
)}
onKeyDown={(e) => e.stopPropagation()}
dropdownRender={(menu) => (
<div className="dropdown-content">
<Input
className="search-input"
placeholder="请输入搜索文本"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
/>
<div className="option-container">
{isVirtual ? (
<VirtualList
width={'100%'}
height={300}
itemCount={getFilteredOptions.length}
itemSize={() => 30}
overscanCount={10}
itemData={getFilteredOptions}
>
{({ index, style, data }) => (
<ItemRenderer
index={index}
style={style}
data={data}
selectInnerValue={selectInnerValue}
handleClickOption={handleClickOption}
/>
)}
</VirtualList>
) : (
getFilteredOptions.map(({ label, value, des }) => (
<div
key={value}
className={classNames('option-item', {
'option-item-active': selectInnerValue?.includes(value),
})}
>
<Checkbox
checked={selectInnerValue?.includes(value)}
onChange={(e) => handleClickOption(e.target.checked, value)}
>
{_.isString(label) ? (
<div className="abbr-option-item">
<Abbr
className="abbr-option-item-label"
key={value}
text={label}
/>
{des && (
<Abbr className="abbr-option-item-des" text={des} />
)}
</div>
) : (
<div className="abbr-option-item">{label}</div>
)}
</Checkbox>
</div>
))
)}
</div>
<div className="footer">
<Checkbox
indeterminate={isHalfChecked}
checked={isAllChecked}
onChange={(e) => handleClickSelectAll(e.target.checked)}
>
全选
</Checkbox>
<Button type="primary" onClick={handleOk}>
确定
</Button>
</div>
</div>
)}
>
{getFilteredOptions.map(({ label, value }) => (
<Option key={value} value={value} style={{ display: 'none' }}>
{label}
</Option>
))}
</Select>
);
};
export default MultiSelect;
点击 展开/收起 css 代码
.mutli-select-element-dropdown {
.dropdown-content {
max-height: 370px;
overflow: hidden;
.search-input {
height: 30px;
}
.option-container {
max-height: 300px;
overflow-y: scroll;
padding: 4px 0;
.option-item {
padding: 4px 12px;
&.option-item-active {
background-color: var(--selected-background);
}
.ant-checkbox-wrapper {
width: 100%;
> span:nth-child(2) {
width: calc(100% - 40px);
}
}
}
.abbr-option-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
.abbr-option-item-label {
color: var(--black65);
}
.abbr-option-item-des {
color: var(--black45);
font-size: 12px;
max-width: 60px;
}
}
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--black10);
padding: 4px 6px 4px 12px;
}
}
}
.mutli-select-element.ant-select {
.ant-select-selection-item {
background-color: var(--brand15);
color: var(--brand);
}
}