Tag标签显示2行,超出部分支持折叠展开

308 阅读2分钟

先上效果图:

image.png image.png image.png

许多业务场景需要展示一组业务标签,受空间限制,通常设定最多展示几行,超出的隐藏,超出部分可以设计成展开收起功能,也可设计成省略号的形式。

核心思路是先计算出每个标签的宽度,然后再根据容器宽度和最大展示行数计算标签列表是否超出,以及超出部分在哪个位置做截断。

计算每个标签的宽度:

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;
  }
}