先上效果图:
许多业务场景需要展示一组业务标签,受空间限制,通常设定最多展示几行,超出的隐藏,超出部分可以设计成展开收起功能,也可设计成省略号的形式。
核心思路是先计算出每个标签的宽度,然后再根据容器宽度和最大展示行数计算标签列表是否超出,以及超出部分在哪个位置做截断。
计算每个标签的宽度:
const tagDoms = spaceRef.current!.querySelectorAll('.label');
const _tagWidths: number[] = [];
for (let i = 0; i < list.length; i++) {
_tagWidths.push(tagDoms[i]?.getBoundingClientRect().width);
}
计算截断位置:
其中 ellipsisWidth 为收起、展开或省略号的宽度
let _breakIndex = 0; // 截断位置
let _isOverflow = false; // 是否超出
let curWidth = 0;
let curLine = 1;
for (let i = 0; i < tagWidths.length; i++) {
if (curWidth + tagWidths[i] + tagGap > spaceWidth) {
if (curLine < maxLine) {
curLine++;
curWidth = tagWidths[i] + tagGap;
} else {
_isOverflow = true;
if (i > 0 && spaceWidth - curWidth < ellipsisWidth) {
_breakIndex = i - 1;
if (_breakIndex > 0 && spaceWidth - curWidth + tagWidths[_breakIndex] + tagGap < ellipsisWidth) {
_breakIndex--;
}
} else {
_breakIndex = i;
}
break;
}
} else {
curWidth += tagWidths[i] + tagGap;
}
}
完整代码:
import { Space, Tag, Tooltip } from 'antd';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EllipsisOutlined } from '@ant-design/icons';
import styles from './index.module.scss';
import useSize from '@/hooks/useSize';
const lineHeight = 22;
const rowGap = 8;
const tagGap = 8;
const ellipsisWidth = 44;
interface IProps {
list: { value: string; name: string }[];
maxLine?: number; // 最多显示行数
needTooltip?: boolean; // 省略部分是否需要tooltip,如果false则用展开收起模式
selected?: string[]; // 选中的标签
style?: React.CSSProperties;
onChange?: (selected: string[]) => void;
}
function TagsView(p: IProps) {
const { list = [], maxLine = 1, needTooltip = false, selected } = p;
const spaceRef = useRef<HTMLDivElement>(null);
const [isOverflow, setIsOverflow] = useState(false); // 标签是否超出了容器
const [breakIndex, setBreakIndex] = useState(0);
const [isFold, setIsFold] = useState(true);
const [tagWidths, setTagWidths] = useState<number[]>([]);
const { width: spaceWidth } = useSize(spaceRef.current);
const canSelect = useMemo(() => Boolean(selected), [selected]);
const handleChange = (value: string, checked: boolean) => {
let newSelected: string[] = [];
if (checked) {
newSelected = [...(selected || []), value];
} else {
newSelected = (selected || []).filter((v) => v !== value);
}
p.onChange && p.onChange(newSelected);
};
const calcBreakIndex = useCallback(() => {
let _breakIndex = 0;
let _isOverflow = false;
let curWidth = 0;
let curLine = 1;
for (let i = 0; i < tagWidths.length; i++) {
if (curWidth + tagWidths[i] + tagGap > spaceWidth) {
if (curLine < maxLine) {
curLine++;
curWidth = tagWidths[i] + tagGap;
} else {
_isOverflow = true;
if (i > 0 && spaceWidth - curWidth < ellipsisWidth) {
_breakIndex = i - 1;
if (_breakIndex > 0 && spaceWidth - curWidth + tagWidths[_breakIndex] + tagGap < ellipsisWidth) {
_breakIndex--;
}
} else {
_breakIndex = i;
}
break;
}
} else {
curWidth += tagWidths[i] + tagGap;
}
}
setBreakIndex(_breakIndex);
setIsOverflow(_isOverflow);
}, [maxLine, tagWidths, spaceWidth]);
useEffect(() => {
if (!spaceWidth) {
return;
}
calcBreakIndex();
}, [maxLine, tagWidths, spaceWidth]);
useEffect(() => {
const tagDoms = spaceRef.current!.querySelectorAll('.label');
const _tagWidths: number[] = [];
for (let i = 0; i < list.length; i++) {
_tagWidths.push(tagDoms[i]?.getBoundingClientRect().width);
}
setTagWidths(_tagWidths);
}, [list]);
return (
<Space
size={[0, rowGap]}
wrap
ref={spaceRef}
style={{
maxHeight: isFold ? lineHeight * maxLine + rowGap * (maxLine - 1) + 2 : 'none',
overflow: 'hidden',
...p.style,
}}
className={styles.tagsView}
>
{(isOverflow && isFold ? list.slice(0, breakIndex) : list).map((item, i) => {
const checked = (selected || []).includes(item.value);
return (
<Tag
key={item.value}
className={`label ${
checked ? styles.checkedTag : canSelect ? styles.defaultTag2 : styles.defaultTag
}`}
onClick={canSelect ? () => handleChange(item.value, !checked) : undefined}
bordered={false}
>
{item.name}
</Tag>
);
})}
{isOverflow && needTooltip && (
<Tooltip
title={list
.slice(breakIndex)
.map((item) => item.name)
.join(';')}
>
<Tag className={styles.defaultTag} bordered={false}>
<EllipsisOutlined />
</Tag>
</Tooltip>
)}
{isOverflow && !needTooltip && (
<Tag className={styles.foldTag} onClick={() => setIsFold(!isFold)} bordered={false}>
{isFold ? '展开' : '收起'}
</Tag>
)}
</Space>
);
}
export default TagsView;
SCSS代码也附上,拷贝到自己项目中即可使用:
.tagsView {
display: flex;
@mixin default-tag {
background-color: #f4f6fa;
color: #222427;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
font-family: 'Alibaba PuHuiTi 3.0, Alibaba PuHuiTi 30';
font-size: 14px;
}
.defaultTag {
@include default-tag;
}
.defaultTag2 {
@include default-tag;
&:hover {
color: #1775fe;
background: #e8f1ff;
}
}
.checkedTag {
@include default-tag;
background-color: #1775fe;
color: #fff;
}
.foldTag {
@include default-tag;
color: #1775fe;
}
}