4千字CSS文本展开收起详细教程

1,311 阅读12分钟

前言

多行文本省略并展开收起,这算是很常见的需求了。本文将结合具体的业务详细介绍各种情况下的实现,总有一款适合你的情景

先放其中一个效果图:在表格中需要多行文本展开收起:

2.gif

一、文本省略

先介绍基本的文本省略的使用

1. 单行省略

  <div class="text">
    互联网信息服务(含发布网络广告);第二类增值电信业务中的信息服务业务(不含固定网电话信息服务和互联网信息服务);制作、发行动画片、专题、电视综艺,不得制作时政新闻及同类专题、专栏等广播电视节目(广播电视节目制作经营许可证有效期至2023年1月12日)
  </div>

css样式

    .text {
      width: 228px;
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
    }

2. 多行省略

    .text {
      width: 200px;
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      overflow: hidden;
    }

二、文本块省略

单行省略效果:

416ba5c9fa234266ac3f4212efae5e8d.jpg

期待效果:展示不全时,整块不展示

4447f124948748dc9f52205ac0e127db.jpg

关键点:span标签的display设为inline-block

    .person-card__desc {
      width: 200px;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      border: 1px solid #ccc;
    }

    span {
      display: inline-block;
    }
    
  <div class="person-card__desc">
    <span>负责人</span>
    <span>经理</span>
    <span>UI 设计师</span>
    <span>前端工程师</span>
  </div>

三、卡片-多行文本展开收起

先上效果

1.gif

准备一个div

  <div class="text">
    <button class="btn">展开</button>
    互联网信息服务(含发布网络广告);第二类增值电信业务中的信息服务业务(不含固定网电话信息服务和互联网信息服务);制作、发行动画片、专题、电视综艺,不得制作时政新闻及同类专题、专栏等广播电视节目(广播电视节目制作经营许可证有效期至2023年1月12日)
  </div>

1. 定义文本样式

    .text {
      display: -webkit-box;
      -webkit-line-clamp: 3;
      -webkit-box-orient: vertical;
      overflow: hidden;
      border: 1px solid black;
      width: 200px;
      line-height: 20px;
    }

2. 按钮右下角环绕效果

    //通过伪元素占位,使按钮从右上角移到右下角
    .text::before {
      content: "";
      float: right;
      width: 0;
      height: 40px;
    }

    //按钮右浮动并清除浮动效果
    .btn {
      float: right;
      clear: both;
      height: 20px;
      line-height: 18px;
    }

3. 动态高度

上面伪元素的占位高度是定死的,现在设置动态高度。用整个容器高度减去按钮的高度

    .text::before {
      content: "";
      float: right;
      width: 0;
      height: calc(100% - 20px);
    }

由于设置了100%,因此父元素必须有高度,这里采用flex布局,在text外再包一层wrap

  <div class="wrap">
    <div class="text">
      <button class="btn">展开</button>
      互联网信息服务(含发布网络广告);第二类增值电信业务中的信息服务业务(不含固定网电话信息服务和互联网信息服务);制作、发行动画片、专题、电视综艺,不得制作时政新闻及同类专题、专栏等广播电视节目(广播电视节目制作经营许可证有效期至2023年1月12日)
    </div>
  </div>
  
  .wrap {
      display: flex;
    }

此时效果

FE4F87A1A14F443280DA16DAC4F111CE.jpg

除此以外,动态高度也可以采用负的margin来实现

      .text::before {
        content: "";
        float: right;
        width: 0;
        /* height: calc(100% - 20px); */
        height: 100%;
        margin-bottom: -20px;
      }

4. 其他浏览器的兼容处理

此时UI那边开始提bug,因为在 safari、firefox和搜狗浏览器中,上面的样式会失去效果。如果不考虑这些浏览器则采用display: -webkit-box;实现折行。可以采用max-height模拟实现文本截断

      .text {
        width: 200px;
        overflow: hidden;
        border: 1px solid black;
        line-height: 20px;
        max-height: 60px;
        text-align: justify; //文字两端对齐,通过增大文字的间隙使两端对齐
      }

采用伪元素实现省略号,跟在按钮之前

      .btn {
        float: right;
        clear: both;
        height: 20px;
        line-height: 18px;
        margin-left: 20px;
        position: relative;
      }
      .btn::before {
        content: "...";
        position: absolute;
        left: -10px;
        color: #333;
        transform: translateX(-100%);
      }

此时,效果和上面一样,并且没有浏览器兼容问题

5. 展开和收起两种状态

利用 input type="checkbox" 进行css状态切换,首先加一个 input,然后把之前的 button 换成 label ,并且通过 for 属性关联起来

    <div class="wrap">
      <input type="checkbox" id="exp" class="exp">
      <div class="text">
        <label class="btn" for="exp">展开</label>
        互联网信息服务(含发布网络广告);第二类增值电信业务中的信息服务业务(不含固定网电话信息服务和互联网信息服务);制作、发行动画片、专题、电视综艺,不得制作时政新闻及同类专题、专栏等广播电视节目(广播电视节目制作经营许可证有效期至2023年1月12日)
      </div>
    </div>

这样,在点击 label 的时候,实际上是点击了 input 元素,现在来添加两种状态,分别是只显示 3 行和不做行数限制

      .exp:checked + .text {
        max-height: none;
      }

这时,可以点击展开和收起了。对于按钮内容需要动态修改,由展开改为收起,可以使用伪类content生成技术

<label class="btn" for="exp"></label><!--去除按钮文字-->
      .btn::after {
        content: "展开"; /*采用content生成*/
      }

添加 :checked 状态

      .exp:checked + .text .btn::after {
        content: "收起";
      }

兼容版本由于前面的省略号是模拟出来的,不能自动隐藏,所以需要额外来处理

      .exp:checked + .text .btn::before {
        visibility: hidden; /*在展开状态下隐藏省略号*/
      }

6. 文本行数的判断

当文本较少时,此时是没有发生截断,也就是没有省略号的,但是“展开”按钮却仍然位于右下角,如何隐藏呢

css可以利用伪类进行覆盖,当需要隐藏时进行覆盖,显示时不覆盖。这里通过text::after实现

      .text {
        position: relative;
      }
      .text::after {
        content: "";
        width: 10px;
        height: 10px;
        position: absolute;
        background: red;
      }

可以看到当省略号出现时,红块消失(因为在文本框外,又设置了overflow: hidden;所以消失),当省略号消失时(高度足够时),红块在文本框内

2b07286736a24c63a483af03c0ee7bdf.jpg

给红块设置一个足够大的宽度

      .text::after {
        content: "";
        width: 100%;
        height: 100%;
        position: absolute;
        background: red;
      }

可以看到当展开时,红块把展开按钮给覆盖了,然后再修改下,将背景改成白色

      .text::after {
        content: "";
        width: 100%;
        height: 100%;
        position: absolute;
        background: #fff;
      }

这样在文本少或者全展开时可以隐藏展开按钮

如果希望点击后仍然可见,添加下:checked状态即可,在展开时隐藏覆盖层

      .exp:checked + .text::after {
        visibility: hidden;
      }

最后再稍微美化下样式,将定义在text类上的盒子样式,定义到wrap上,并给按钮字体加颜色

      .wrap {
        display: flex;
        width: 200px;
        border: 1px solid black;
        padding: 10px;
      }

      .text {
        overflow: hidden;
        line-height: 20px;
        max-height: 60px;
        text-align: justify;
        position: relative;
      }

      .btn {
        float: right;
        clear: both;
        height: 20px;
        line-height: 18px;
        margin-left: 20px;
        position: relative;
        color: #025cdc;
        cursor: pointer;
      }

7. 最终代码

<!DOCTYPE html>
<html>
  <head>
    <title>testD3_chp15_1.html</title>

    <script type="text/javascript" src="https://d3js.org/d3.v5.min.js"></script>

    <meta name="keywords" content="keyword1,keyword2,keyword3" />
    <meta name="description" content="this is my page" />
    <meta name="content-type" content="text/html; charset=GBK" />

    <style>
      .wrap {
        display: flex;
        width: 200px;
        border: 1px solid black;
        padding: 10px;
      }

      .text {
        overflow: hidden;
        line-height: 20px;
        max-height: 60px;
        text-align: justify;
        position: relative;
      }

      .text::before {
        content: "";
        float: right;
        width: 0;
        /* height: calc(100% - 20px); */
        height: 100%;
        margin-bottom: -20px;
      }

      .text::after {
        content: "";
        width: 100%;
        height: 100%;
        position: absolute;
        background: #fff;
      }

      .btn {
        float: right;
        clear: both;
        height: 20px;
        line-height: 18px;
        margin-left: 20px;
        position: relative;
        color: #025cdc;
        cursor: pointer;
      }

      .btn::before {
        content: "...";
        position: absolute;
        left: -10px;
        color: #333;
        transform: translateX(-100%);
      }

      .btn::after {
        content: "展开";
      }

      .exp:checked + .text .btn::after {
        content: "收起";
      }

      .exp {
        display: none;
      }

      .exp:checked + .text {
        max-height: none;
      }

      .exp:checked + .text .btn::before {
        visibility: hidden;
        /*在展开状态下隐藏省略号*/
      }

      .exp:checked + .text::after {
        visibility: hidden;
      }
    </style>
  </head>

  <body>
    <div class="wrap">
      <input type="checkbox" id="exp" class="exp" />
      <div class="text">
        <label class="btn" for="exp"></label>
        互联网信息服务(含发布网络广告);第二类增值电信业务中的信息服务业务(不含固定网电话信息服务和互联网信息服务);制作、发行动画片、专题、电视综艺,不得制作时政新闻及同类专题、专栏等广播电视节目(广播电视节目制作经营许可证有效期至2023年1月12日)
      </div>
    </div>
  </body>
</html>

四、表格-多行文本展开收起

此时,UI跑过来和我说,除了卡片需要多行折叠,在表格中也需要。先上效果图

2.gif

思路:实现原理与上面卡片展开收起相同,只是换了业务情景,通过接口获取数据,并在表格内展示。因为有多个展开收起,因此input标签的id和label标签的for需要一一对应

可以用脚手架创建一个新的React项目,antd自行安装

引入antd样式,修改index.js

import 'antd/dist/antd.min.css'; //添加样式

修改App.js

import React, { useState, useEffect } from "react";
import { Table } from "antd";

import "./App.css";

const columns = [
  {
    title: "Name",
    dataIndex: "name",
    key: "name",
  },
  {
    title: "Age",
    dataIndex: "age",
    key: "age",
  },
  {
    title: "Address",
    dataIndex: "address",
    key: "address",
    render: (text, { key }) => (
      <div className="wrap">
        <input type="checkbox" id={`exp_${key}`} className="exp" />
        <div className="text">
          <label className="btn more" htmlFor={`exp_${key}`}></label>
          {text}
        </div>
      </div>
    ),
  },
];

//模拟接口
const getData = () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        {
          key: "1",
          name: "John Brown",
          age: 32,
          address:
            "互联网信息服务(含发布网络广告);第二类增值电信业务中的信息服务业务(不含固定网电话信息服务和互联网信息服务);制作、发行动画片、专题、电视综艺,不得制作时政新闻及同类专题、专栏等广播电视节目(广播电视节目制作经营许可证有效期至2023年1月12日)",
          more: false,
        },
        {
          key: "2",
          name: "Jim Green",
          age: 42,
          address:
            "互联网信息服务(含发布网络广告);第二类增值电信业务中的信息服务业务(不含固定网电话信息服务和互联网信息服务);制作、发行动画片、专题、电视综艺,不得制作时政新闻及同类专题、专栏等广播电视节目(广播电视节目制作经营许可证有效期至2023年1月12日)",
          more: false,
        },
        {
          key: "3",
          name: "Joe Black",
          age: 32,
          address:
            "计算机软、硬件的开发;计算机系统服务;销售计算机软、硬件及辅助设备",
          more: false,
        },
      ]);
    }, 200);
  });
};

function App() {
  const [tableData, setTableData] = useState([]);

  useEffect(() => {
    getData().then((res) => {
      setTableData(res);
    });
  }, []);

  return (
    <div className="container">
      <Table columns={columns} dataSource={tableData} bordered />
    </div>
  );
}

export default App;

修改App.css

.container{
    width:600px
  }
  
  .wrap {
    display: flex;
  }
  
  .text {
    overflow: hidden;
    line-height: 20px;
    max-height: 60px;
    text-align: justify;
    position: relative;
  }
  
  .text::before {
    content: "";
    float: right;
    width: 0;
    height: 100%;
    margin-bottom: -20px;
  }
  
  .text::after {
    content: '';
    width: 100%;
    height: 100%;
    position: absolute;
    background: #fff;
  }
  
  .btn {
    float: right;
    clear: both;
    height: 20px;
    margin-left: 10px;
    position: relative;
    color: #025cdc;
    cursor: pointer;
  }
  
  .btn::before {
    content: "...";
    position: absolute;
    left: -5px;
    color: #333;
    transform: translateX(-100%);
  }
  
  .exp {
    display: none;
  }
  
  .btn::after {
    content: "展开";
  }
  
  .exp:checked + .text {
    max-height: none;
  }
  
  .exp:checked+.text .btn::after {
    content: '收起'
  }
  
  .exp:checked+.text .btn::before {
    visibility: hidden;
    /*在展开状态下隐藏省略号*/
  }
  
  .exp:checked+.text::after {
    visibility: hidden;
  }

五、表格-多行文本展示收起-悬停展示全部

UI那边又说折行的时候,需要鼠标悬浮展示全部。我心里:不是有展开按钮了吗,还要这个功能不是多次一举吗,嘴上:好的

方案一:title属性

修改下columns属性的内容

//原来
{text}

//改成
<span className="content" title={text}>
    {text}
</span>

//并在App.css中添加文本样式悬停样式
.content:hover {
  text-decoration: underline;
}

UI看了效果后直摇头,说浏览器默认效果太丑了,不行,需要实现他设计稿上的样式

方案二:通过js控制悬停组件tooltip

1. 修改text::after的高度

之前通过text::after在文本不需要折行时,覆盖展开按钮。之前高度设了一个较大值100%,由于这里需要计算文本内容的高度,需要把高度改为行高

.text::after {
    content: "";
    width: 100%;
    //height: 100%;
    height: 20px;
    position: absolute;
    background: #fff;
  }

2. 创建组件,新建titlePopover.js

先安装styled-components和ahooks两个插件

新建titlePopover.js

import {
    memo,
    useLayoutEffect,
    useRef,
    useState,
    useCallback,
    useEffect,
  } from "react";
  import styled from "styled-components";
  import { Popover } from "antd";
  import { useDebounceFn } from "ahooks";
  
  const TitlePopover = ({ text, id }) => {
    const refs = useRef();
    const [isPop, setIsPop] = useState(false); // 是否有弹框
  
    const autoSize = useCallback(() => {
      //内容折行
      if (refs.current?.scrollHeight > refs.current?.clientHeight) {
        setIsPop(true);
      } else {
        setIsPop(false);
      }
    }, []);
  
    useLayoutEffect(autoSize, [autoSize]);
  
    //防抖处理
    const { run } = useDebounceFn(autoSize, {
      wait: 500,
    });
  
    //缩放窗口时重新计算高度
    useEffect(() => {
      window.addEventListener("resize", run, false);
      return () => {
        window.removeEventListener("resize", run, false);
      };
    }, [run]);
  
    return (
      <Wrap>
        <input type="checkbox" id={`exp_${id}`} className="exp" />
        <div className="text" ref={refs}>
          <label className="btn more" htmlFor={`exp_${id}`}></label>
          {isPop ? (
            <Popover content={text} placement="top">
              {text}
            </Popover>
          ) : (
            text
          )}
        </div>
      </Wrap>
    );
  };
  
  export default memo(TitlePopover);
  
  const Wrap = styled.div`
    display: flex;
    .text {
      overflow: hidden;
      line-height: 20px;
      max-height: 60px;
      text-align: justify;
      position: relative;
    }
  
    .text::before {
      content: "";
      float: right;
      width: 0;
      height: 100%;
      margin-bottom: -20px;
    }
  
    .text::after {
      content: "";
      width: 100%;
      height: 20px;
      position: absolute;
      background: #fff;
    }
  
    .btn {
      float: right;
      clear: both;
      height: 20px;
      margin-left: 15px;
      position: relative;
      color: #025cdc;
      cursor: pointer;
    }
  
    .btn::before {
      content: "...";
      position: absolute;
      left: -5px;
      color: #333;
      transform: translateX(-100%);
    }
  
    .exp {
      display: none;
    }
  
    .btn::after {
      content: "展开";
    }
  
    .exp:checked + .text {
      max-height: none;
    }
  
    .exp:checked + .text .btn::after {
      content: "收起";
    }
  
    .exp:checked + .text .btn::before {
      visibility: hidden;
      /*在展开状态下隐藏省略号*/
    }
  
    .exp:checked + .text::after {
      visibility: hidden;
    }
  
    .content:hover {
      text-decoration: underline;
    }
  `;

3. 修改App.js

将原先的

{
    title: "Address",
    dataIndex: "address",
    key: "address",
    render: (text, { key }) => (
      <div className="wrap">
        <input type="checkbox" id={`exp_${key}`} className="exp" />
        <div className="text">
          <label className="btn more" htmlFor={`exp_${key}`}></label>
          {text}
        </div>
      </div>
    ),
  },

改为

import TitlePopover from "./titlePopover";
  
{
  title: "Address",
  dataIndex: "address",
  key: "address",
  render: (text, { key }) => <TitlePopover text={text} id={key} />,
},

六、问题

纯css方案,展开按钮是一直存在的,这就意味着它是一直占据着宽度的。会遇到文本不需要换行,而展开按钮换行了,因为文本后侧剩余宽度不足以展示全部的展开按钮

4f33871bd39c4b4cb51d4189a7aa3c52.jpg

解决方案:将text的伪元素的高度拉高,设置margin-left,并用box-shadow代替background

  //替换成
  .text::after {
    content: "";
    width: 999vw;
    height: 999vw;
    position: absolute;
    box-shadow: inset calc(100px - 999vw) calc(30px - 999vw) 0 0 #fff;
    margin-left: -100px;
  }

104c32a5d5ac4039b491b917e12e18cc.jpg

不足之处,下面多空了一行(这种情况比较少见,需要拖动窗口卡到那个距离上,才会出现),也破坏了上面的悬浮判断 element.scrollHeight > element.clientHeight,要悬停可采用方案一title属性

七、结语

如果产品比较注重性能,可以采用上面纯css的方式进行开发;我这边的产品比较注重UI,所以只能牺牲点性能采用js去实现,采用js可避开CSS方案这个不足点

于是我又开始新的一天码农搬砖生活,由于本文篇幅实在过长,所以js方案我放到下一篇来讲。等更新时我会把链接补充到下方

如果你有不理解的地方,欢迎在评论区留言;如果需要demo源代码的话,也可以留言,回头我把代码放到GitHub上;如果有其他业务情景,也可留言

码字不易,点赞支持!!!

巨人的肩膀:juejin.cn/post/696390…