Vue 实现多行文本“展开收起”

在很多示例中,多行文本的末尾都是显示"展开收起",但是在实际的需求中,可能需要图标替代"展开收起"文字。

一、示例:

template部分:

html 复制代码
<template>
  <!-- 当有内容时才显示整个组件 -->
  <div v-if="contentText" class="wrapper">
    <!-- 展开/收起的复选框,用于控制内容显示状态 -->
    <input
      v-if="state.showToggleButton"
      :id="uniqueId"
      class="exp"
      type="checkbox"
      @click.stop="displayAllContent"
    />
    <!-- 文本内容容器,根据是否需要展开按钮添加不同样式 -->
    <div
      ref="descRef"
      class="text"
      :class="{ 'text-white': isTextWhiteColor }"
      @click.stop="handleClickContentText"
    >
      <!-- 展开/收起按钮 -->
      <label
        v-if="state.showToggleButton"
        class="btn"
        :class="{ 'btn-white': isTextWhiteColor }"
        :for="uniqueId"
        aria-label="展开/收起"
      ></label>
      <!-- 展开/收起按钮 -->
      <span
        v-if="topic"
        class="topic-text"
        :class="{ 'topic-text-white': topicDisable }"
        @click.stop="handleClickTopicText"
      >
        {{ topic }}
      </span>
      <!-- 等级文本,如果等级有效则显示 -->
      <span v-if="level !== undefined && level !== null && level !== ''" class="g-font-number">
        {{ '等级' }}{{ level }}
      </span>
      <!-- 主要内容文本 -->
      {{ contentText }}
    </div>
  </div>
</template>

JavaScript部分:

javascript 复制代码
<script setup>
import { ref, reactive, nextTick, watch, onMounted } from 'vue'

// 文本内容容器的引用,用于获取DOM元素的高度信息
const descRef = ref(null)

// 随机创建一个唯一的ID,用于关联checkbox和label
const uniqueId = ref(`exp-${Math.random().toString(36).slice(2, 11)}`)

// 定义组件属性
const props = defineProps({
  // 用户等级
  level: {
    type: Number,
  },
  // 话题内容
  topic: {
    type: String,
  },
  // 文本内容
  contentText: {
    type: String,
    required: true, // 必填属性
  },
  // 最多显示几行
  maxLines: {
    type: Number,
    default: 2,
    validator: (value) => value > 0, // 验证器:值必须大于0
  },
  // 行⾼
  lineHeight: {
    type: Number,
    default: 20,
    validator: (value) => value > 0, // 验证器:值必须大于0
  },
  // 字体⼤⼩
  fontSize: {
    type: Number,
    default: 14,
    validator: (value) => value > 0, // 验证器:值必须大于0
  },
  // 内容文字颜色是否为白色
  isTextWhiteColor: {
    type: Boolean,
    default: false,
  },
  // 话题文字是否可点击
  topicDisable: {
    type: Boolean,
    default: false,
  },
})

const state = reactive({
  // 是否展⽰所有⽂本内容
  showAll: true,
  // 当前是展开/收起状态
  collapsedStatus: true,
  // 是否显⽰展开收起按钮
  showToggleButton: false,
})

// 定义组件触发的事件,包含参数验证
const emit = defineEmits({
  // 展开/收起状态变化事件
  expanded: (newVal) => {
    if (typeof newVal === 'boolean') {
      return true
    } else {
      console.error('newVal is undefined')
      return false
    }
  },
  // 点击话题文本事件
  handleClickTopicText: null,
  // 点击内容文本事件
  handleClickContentText: null,
})

/**
 * 处理点击话题文本事件
 * 如果话题被禁用,则不触发事件
 */
const handleClickTopicText = () => {
  if (props.topicDisable) {
    return
  }
  emit('handleClickTopicText')
}

/**
 * 处理点击内容文本事件
 * 只有当点击的不是按钮元素时才触发
 * @param {Event} event - 点击事件对象
 */
const handleClickContentText = (event) => {
  if (!event.target.classList.contains('btn') && !event.target.classList.contains('exp')) {
    emit('handleClickContentText')
  }
}

/**
 * 处理展开/收起内容事件
 * 切换显示状态
 * @param {Event} event - 点击事件对象
 */
const displayAllContent = (event) => {
  if (event.target.classList.contains('btn') || event.target.classList.contains('exp')) {
    state.showAll = !state.showAll
    state.collapsedStatus = !state.collapsedStatus
  }
}

/**
 * 计算最大高度
 * @returns {number} 最大高度值
 */
const getMaxHeight = () => {
  //后面的 + 1 很重要,是为了让省略号和展开图标不会被最后一行的文字覆盖
  return props.lineHeight * props.maxLines + 1
}

/**
 * 初始化布局,计算是否需要显示展开/收起按钮
 */
const initLayout = async () => {
  await nextTick()
  // 添加额外的检查,确保元素存在
  if (!descRef.value) {
    console.warn('Element not found in DOM')
    return
  }

  try {
    // 获取实际文本高度
    const descHeight = parseFloat(window.getComputedStyle(descRef.value).height)

    // 获取最大允许高度
    const maxHeight = getMaxHeight()

    //后面的 - 1 很重要,因为getMaxHeight里面加了 1
    //同时检查高度和是否真的有被截断的内容;
    const shouldCollapse =
      descHeight > maxHeight - 1 && descRef.value.scrollHeight > descRef.value.clientHeight

    // 设置是否显示展开/收起按钮
    state.showToggleButton = shouldCollapse
  } catch (error) {
    console.warn('Failed to compute element style:', error)
  }
}

// 监听展开/收起状态变化,触发外部事件
watch(
  () => state.collapsedStatus,
  (newVal, oldVal) => {
    if (newVal !== oldVal) {
      //通知外部变化了
      emit('expanded', newVal)
    }
  },
)

// 监听内容文本变化,重新计算布局
watch(
  () => props.contentText,
  async (newVal) => {
    // 确保 DOM 更新后再执行
    await nextTick()
    initLayout()
  },
)

// 组件挂载完成后初始化布局
onMounted(async () => {
  // 确保 DOM 完全挂载后再执行
  await nextTick()
  initLayout()
})
</script>

css部分:

css 复制代码
<style scoped>
/* 包装器样式 */
.wrapper {
  display: flex;
  width: 100%;
  overflow: hidden;
}

/* 文本容器样式 */
.text {
  font-size: v-bind('props.fontSize + "px"');
  overflow: hidden;
  text-overflow: ellipsis;
  text-align: justify;
  position: relative;
  line-height: v-bind('props.lineHeight + "px"');
  max-height: v-bind('getMaxHeight() + "px"');
  color: rgba(255, 255, 255, 0.6);
}

/* 白色文字样式 */
.text-white {
  width: 100%;
  color: rgb(255, 255, 255);
}

/* 伪元素创建省略号效果 */
.text::before {
  content: '';
  height: calc(100% - v-bind('props.lineHeight + "px"'));
  float: right;
}

/* 按钮样式 */
.btn {
  position: relative;
  float: right;
  clear: both;
  margin-left: 20px;
  font-size: v-bind('props.fontSize + "px"');
  line-height: v-bind('props.lineHeight + "px"');
  border-radius: 4px;
  color: rgba(255, 255, 255, 0.6);
}

/* 展开按钮的省略号样式 */
.btn::before {
  content: '...';
  position: absolute;
  left: -5px;
  color: rgba(255, 255, 255, 0.6);
  transform: translateX(-100%);
}

/* 白色主题的按钮样式 */
.btn-white::before {
  color: rgb(255, 255, 255);
}

/* 展开/收起图标样式 */
.btn::after {
  /* content: '展开'; */
  content: '';
  display: block; /* 使伪元素为块级元素 */
  width: 16px;
  height: 16px;
  background-image: url('../assets/arrow-down.png');
  background-size: cover;
  background-repeat: no-repeat;
  transform: translate(0, 10%);
}

/* 隐藏复选框 */
.exp {
  display: none;
}

/* 展开状态下的文本样式 */
.exp:checked + .text {
  max-height: none;
}

/* 展开状态下的图标样式 */
.exp:checked + .text .btn::after {
  /* content: '收起'; */
  content: '';
  display: block;
  width: 16px;
  height: 16px;
  background-image: url('../assets/arrow-up.png');
  background-size: cover;
  background-repeat: no-repeat;
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translate(50%, 10%);
}

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

/* 话题文本样式 */
.topic-text {
  color: yellow;
  font-size: v-bind('props.fontSize + "px"');
  line-height: v-bind('props.lineHeight + "px"');
  margin-right: 12px;
}

/* 白色主题话题文本样式 */
.topic-text-white {
  color: rgb(255, 255, 255);
}

/* 等级文本样式 */
.g-font-number {
  font-size: v-bind('props.fontSize + "px"');
  color: rgb(255, 255, 255) !important;
  margin-right: 5px;
  line-height: v-bind('props.lineHeight + "px"');
}
</style>

二、父组件使用:

javascript 复制代码
import TextEllipsis from './components/TextEllipsis.vue'
html 复制代码
   <div class="wrapper">
      <TextEllipsis
        :level="8"
        :maxLines="3"
        topic="#今日话题"
        contentText="Vue (发音为 /vjuː/,类似 view) 是一款用于构建用户界面的 JavaScript 框架。它基于标准 HTML、CSS 和 JavaScript 构建,并提供了一套声明式的、组件化的编程模型,帮助你高效地开发用户界面。无论是简单还是复杂的界面,Vue 都可以胜任。"
      />
    </div>
css 复制代码
.wrapper {
  /** 宽高要设置 */
  width: 500px;
  min-height: 10px;
  
  background-color: black;
  margin-bottom: 200px;
  padding: 10px;
  border-radius: 5px;
}

最终效果:

(收起状态)

(展开状态)

以上部分也可以根据实际需求进行修改。

相关推荐
携欢2 分钟前
portswigger靶场之修改序列化数据类型通关秘籍
android·前端·网络·安全
前端小L3 分钟前
专题二:核心机制 —— reactive 与 effect
javascript·源码·vue3
GuMoYu3 分钟前
npm link 测试本地依赖完整指南
前端·npm
代码老祖3 分钟前
vue3 vue-pdf-embed实现pdf自定义分页+关键词高亮
前端·javascript
未等与你踏清风4 分钟前
Elpis npm 包抽离总结
前端·javascript
代码猎人4 分钟前
如何使用for...of遍历对象
前端
秋天的一阵风6 分钟前
🎥解决前端 “复现难”:rrweb 录制回放从入门到精通(下)
前端·开源·全栈
林恒smileZAZ6 分钟前
【Vue3】我用 Vue 封装了个 ECharts Hooks
前端·vue.js·echarts
颜酱7 分钟前
用填充表格法-继续吃透完全背包及其变形
前端·后端·算法
代码猎人7 分钟前
new操作符的实现原理是什么
前端