从0搭建Vue3组件库之Collapse组件

636 阅读10分钟

在当今的网页设计和应用开发中,折叠面板(Collapse Panel) 已经成为了一种不可或缺的交互元素。它不仅能够有效节省页面空间,还能通过动态展示和隐藏内容,为用户提供更加流畅、个性化的浏览体验。本文将带你深入了解如何从零开始构建一个基本的折叠面板组件,涵盖需求分析、方案确定、设计思路等关键步骤。

一、需求分析

折叠面板需要具备的功能:

1. 多面板支持

  • 用户可以在一个页面中存在多个折叠项,并且每个面板的行为可以独立控制。
  • 每个面板应有一个唯一的标识符(name),用于区分不同的面板。

2. 展开/收起功能

  • 用户可以通过点击面板标题来切换内容的显示状态(展开或收起)。
  • 支持初始状态配置,允许开发者设置哪些面板默认展开或收起。

3. 手风琴模式

  • 提供手风琴模式(accordion),即一次只能展开一个面板。当用户点击某个面板时,其他已展开的面板会自动收起。
  • 默认情况下,允许多个面板同时展开。

4. 动画效果

  • 为了提升用户体验,折叠和展开的过程应具备平滑的过渡动画。
  • 动画效果可以通过 CSS 或 Vue 的 <transition> 组件实现。

5. 事件监听

  • 提供 change 事件,通知父组件面板状态的变化(展开或收起)。
  • 事件参数应包含当前展开的面板标识符(name)或所有展开的面板列表。

6. 样式定制

  • 支持通过 scoped 样式或传递类名来自定义面板的外观。
  • 提供默认样式的同时,允许开发者根据项目需求进行个性化调整。

7. 禁用面板

  • 支持禁用某些面板,防止用户点击展开。禁用的面板应有明显的视觉提示(如灰色背景或禁用图标)。

8. 加载状态

  • 在内容加载时,提供加载状态的提示(如加载图标),避免用户误操作。

9. 自定义标题

  • 允许用户通过插槽(slot)自定义面板标题的内容,而不局限于简单的文本。

二、方案确定

在明确了需求后,接下来是选择合适的技术栈和实现方案。我们将使用 Vue 3TypeScript 来构建这个组件,以确保代码的类型安全和可维护性。以下是具体的方案确定:

1. 组件化设计

  • 将折叠面板拆分为两个主要组件:

    • Collapse:作为容器组件,管理所有折叠项的状态。
    • CollapseItem:作为单个折叠项,负责渲染标题和内容区,并处理展开/收起逻辑。
  • 这种组件化设计有助于提高代码的复用性和可维护性。

2. 状态管理

  • 使用 Vue 的响应式系统(ref 和 computed)来管理面板的展开/收起状态。
  • 通过 v-model 实现双向绑定,允许父组件控制哪些面板是展开的。
  • 对于非受控模式,使用内部状态管理面板的状态。

3. 依赖注入

  • 使用 Vue 的依赖注入机制(provide 和 inject),将 `
  • Collapse 的状态和方法传递给 CollapseItem`,从而避免父子组件之间的直接通信。
  • 这种方式使得子组件可以轻松访问父组件的状态,而不需要通过 props 传递。

4. 事件处理

  • 在 CollapseItem 中,当用户点击标题时,触发 handleItemClick 方法,该方法会更新父组件的状态,并触发 change 事件。
  • 父组件可以通过监听 change 事件来执行自定义逻辑。

5. 动画效果

  • 使用 Vue 的 <transition> 组件或 CSS 动画来实现平滑的展开和收起效果。
  • 通过 max-height 属性控制内容区的高度变化,确保动画效果流畅。

6. 样式定制

  • 提供默认样式的同时,允许开发者通过 scoped 样式或传递类名来自定义面板的外观。
  • 使用 CSS 变量或 Sass 变量来简化样式的修改和维护。

三、设计思路

  • 在Collapse组件中用一个数组来保存打开的item的name属性

  • 点击item的时候,先从数组中查找这个元素,也就是上边的name属性,在数组存在就删除,不在就添加,这样就实现了打开和关闭的状态改变

  • 这个数组将由Collapse组件传递给CollapseItem

  • 然后在Item组件内部,也就是CollapseItem组件内部,判断name是否存在于数组中,使用一个计算属性结合v-show,真正实现打开和关闭

1. 组件结构

1.1 Collapse.vue - 容器组件

  • 功能:管理所有折叠项的状态,并提供手风琴模式的支持。

  • 属性

    • modelValue:受控模式下的展开面板键(数组形式),允许父组件控制哪些面板是展开的。
    • accordion:是否启用手风琴模式,默认为 false
  • 事件

    • update:modelValue:当面板状态发生变化时,更新 modelValue
    • change:当面板状态发生变化时,触发此事件,传递当前展开的面板列表。

1.2 CollapseItem.vue - 单个折叠项

  • 功能:渲染每个折叠项的标题和内容区,并处理展开/收起逻辑。

  • 属性

    • title:面板标题,默认为简单文本,支持通过插槽自定义。
    • name:每个面板的唯一标识符,用于区分不同的面板。
    • disabled:是否禁用该面板,禁用的面板不能被点击展开。
  • 事件

    • 当用户点击标题时,触发 handleItemClick 方法,更新父组件的状态。

2. 状态管理

  • currentActiveNames:一个响应式的数组,存储当前展开的面板标识符。
  • isActive:计算属性,判断当前面板是否处于展开状态。
  • handleItemClick:当用户点击面板标题时,调用此方法更新 currentActiveNames,并触发 change 事件。

3. 动画效果

  • 使用 Vue 的 <transition> 组件,结合 CSS 的 max-height 属性,实现平滑的展开和收起效果。
  • 通过 el-collapse-transition 类名控制动画的进入和离开过程。

4. 依赖注入

  • 在 Collapse 中使用 provide 提供 currentActiveNamesaccordion 和 handleItemClick 方法。
  • 在 CollapseItem 中使用 inject 获取这些数据,从而避免父子组件之间的直接通信。

四、代码实现

1. Collapse.vue` - 容器组件

  • 分为两个组件,将列表作为一个组件,每组展示都需要一个父元素包裹
// Collaspe.vue
<template>
    <div class="jd-collapse">
        <slot></slot>
    </div>
</template>

// CollapseItem.vue
<template>
    <div class="jd-collapse-item">
        <div class="jd-collapse-item__header">
         // 具名插槽, 用来放标题
            <slot name="title"></slot>
        </div>
        <div class="jd-collapse-item__wrapper">
            <div class="jd-collapse-item__content" >
            // 默认插槽, 用来放内容
                <slot></slot>
            </div>
        </div>
    </div>
</template>

  • 确定属性
export type NameType = string | number;
// 定义v-model和列表,将要呈现的列表相关信息放到一个数组管理
export interface CollapseProps {
  // 当前打开的item, 可以是一个或者多个, 例如ref['a','b']
  modelValue: NameType[];
  // 是否支持手风琴模式,一个打开,另一个关闭
  accordion?: boolean;
}
// item列表
export interface CollapseItemProps {
  // name属性作为每个Item的标识符,不能重复
  name: NameType;
  title?: string;
  // 禁用状态下不能操作该列表
  disabled?: boolean;
}
  • 确定事件
export interface CollaspeEmits {
  // 配合modelValue实现v-model
  (e: "update:modelValue", values: NameType[]): void;
  // change事件
  (e: "change", values: NameType[]): void;
}

Collapse.vue - 容器组件

<template>
  <div class="jd-collapse">
    <slot></slot>
  </div>
</template>

<script setup lang="ts">
// import type { CollapseItemProps } from "./types";
import { provide, ref, watch } from "vue";
import type { CollapseName, CollapseProps, CollapseEmits } from "./types";
import { collapseContextKey } from "./types";

// 定义组件名称
defineOptions({
  name: "JDCollapse",
});

// 定义组件属性
const props = defineProps<CollapseProps>();
// 定义组件事件
const emits = defineEmits<CollapseEmits>();
// 定义组件状态
const activeNames = ref<CollapseName[]>(props.modelValue);

// 监听modelValue的变化
watch(
  () => props.modelValue,
  () => {
    activeNames.value = props.modelValue;
  }
);
// 如果开启手风琴模式,并且activeNames.value的长度大于1,则警告
if (props.accordion && activeNames.value.length > 1) {
  // activeNames.value = [activeNames.value[0]];
  console.warn("accordion mode only supports one active item");
}

const handleItemClick = (item: CollapseName) => {
  // console.log("item", item);
  if (props.accordion) {
    // console.log("accordion", item, activeNames.value);
    // 手风琴模式下,点击时只保留当前面板
    // 如果点击的是展开项,那就清空激活数组,否则替换,这样就保证了只有一项被激活
    activeNames.value = [activeNames.value[0] === item ? "" : item];
  } else {
    // 非手风琴模式下,点击时切换当前面板的展开状态
    const index = activeNames.value.indexOf(item);
    if (index > -1) {
      // 如果存在,则删除
      activeNames.value.splice(index, 1);
    } else {
      // 如果不存在,则添加
      activeNames.value.push(item);
    }
  }
  // 触发更新事件,让父组件知道数据被改变
  emits("update:modelValue", activeNames.value);
  // 触发change事件
  emits("change", activeNames.value);
};


// 提供给子组件的数据
//这里由于使用的slot,所以不能再使用prop来向子组件传递属性和方法,要用到`provide`和`inject`依赖注入的方法来传递:
provide(collapseContextKey, {
  activeNames,
  // 将方法也传递到CollapseItem中,在点击Item列表时调用
  handleItemClick,
});

// defineProps<CollapseItemProps>();
</script>

CollapseItem.vue - 单个折叠项

这个时候,子组件要做的事情显而易见了 => 根据父组件Collapse传递过来的状态数组activeNames,决定展示或隐藏相应的列表项

  • 然后在Item组件内部,也就是CollapseItem组件内部,判断name是否存在于数组中,使用一个计算属性结合v-show,真正实现打开和关闭
<template>
  <div
    class="jd-collapse-item"
    :class="{
      'is-disabled': disabled,
    }"
  >
    <div
      class="jd-collapse-item__header"
      :class="{
        'is-active': isActive,
        'is-disabled': disabled,
      }"
      :id="`item-header-${name}`"
      @click="handleClick"
    >
      <slot name="title">{{ title }}</slot>
      <Icon icon="angle-right" class="header-angle" />
    </div>
    <Transition name="slide" v-on="transitionEvents">
      <div class="jd-collapse-item__wrapper" v-show="isActive">
        <div class="jd-collapse-item__content" :id="`item-content-${name}`">
          <slot />
        </div>
      </div>
    </Transition>
  </div>
</template>

<script setup lang="ts">
import { inject, computed } from "vue";
import type { CollapseItemProps } from "./types";
import { collapseContextKey } from "./types";
import Icon from "../Icon/Icon.vue";
defineOptions({
  name: "JDCollapseItem",
});
const props = defineProps<CollapseItemProps>();

// 从父组件注入的状态和方法
const collapseContext = inject(collapseContextKey);

// 计算当前面板是否处于展开状态
const isActive = computed(() => {
  return collapseContext?.activeNames.value.includes(props.name);
});
// console.log("isActive", isActive);

// 点击标题时触发展开/收起
const handleClick = () => {
  if (props.disabled) return;
  collapseContext?.handleItemClick(props.name);
};
// 动画事件
const transitionEvents: Record<string, (el: HTMLElement) => void> = {
  // 进入动画前
  beforeEnter(el) {
    el.style.height = "0px";
    el.style.overflow = "hidden";
  },
  // 进入动画
  enter(el) {
    el.style.height = `${el.scrollHeight}px`;
  },
  // 进入动画后
  afterEnter(el) {
    el.style.height = "";
    el.style.overflow = "";
  },
  // 离开动画前
  beforeLeave(el) {
    el.style.height = `${el.scrollHeight}px`;
    el.style.overflow = "hidden";
  },
  // 离开动画
  leave(el) {
    el.style.height = "0px";
  },
  // 离开动画后
  afterLeave(el) {
    el.style.height = "";
    el.style.overflow = "";
  },
};
</script>

<style scoped>
.jd-collapse-item__header {
  font-size: 50px;
}
</style>

// types.ts
import type { InjectionKey, Ref } from "vue";
export type CollapseName = string | number;

export interface CollapseProps {
  modelValue: CollapseName[];
  accordion?: boolean;
}

export interface CollapseItemProps {
  title?: string; // 面板标题
  name?: CollapseName; // 每个面板的唯一标识
  disabled?: boolean;
}

export interface CollapseContext {
  activeNames: Ref<CollapseName[]>;
  handleItemClick: (item: CollapseName) => void;
}

export interface CollapseEmits {
  (e: "update:modelValue", value: CollapseName[]): void;
  (e: "change", value: CollapseName[]): void;
}

// Symbol("collapseContextKey") 创建了一个唯一的符号,用于作为这个注入键
export const collapseContextKey: InjectionKey<CollapseContext> =
  Symbol("collapseContextKey");

五、使用示例

1. 基本用法

    <Collapse v-model="openedValue" accordion>
      <Item name="a" title="Title A">
        <h1>headline title</h1>
        <div>this is content a aaa</div>
      </Item>
      <Item name="b" title="Title B">
        <div>this is bbbbb test</div>
      </Item>
      <Item name="c" title="Disabled Title" disabled>
        <div>this is cccc test</div>
      </Item>
    </Collapse>

样式文件

.jd-collapse {
  --jd-collapse-border-color: var(--jd-border-color-light);
  --jd-collapse-header-height: 48px;
  --jd-collapse-header-bg-color: var(--jd-fill-color-blank);
  --jd-collapse-header-text-color: var(--jd-text-color-primary);
  --jd-collapse-header-font-size: 13px;
  --jd-collapse-content-bg-color: var(--jd-fill-color-blank);
  --jd-collapse-content-font-size: 13px;
  --jd-collapse-content-text-color: var(--jd-text-color-primary);
  --jd-collapse-disabled-text-color: var(--jd-disabled-text-color);
  --jd-collapse-disabled-border-color: var(--jd-border-color-lighter);
  border-top: 1px solid var(--jd-collapse-border-color);
  border-bottom: 1px solid var(--jd-collapse-border-color);
}
.jd-collapse-item__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: var(--jd-collapse-header-height);
  line-height: var(--jd-collapse-header-height);
  background-color: var(--jd-collapse-header-bg-color);
  color: var(--jd-collapse-header-text-color);
  cursor: pointer;
  font-size: var(--jd-collapse-header-font-size);
  font-weight: 500;
  transition: border-bottom-color var(--jd-transition-duration);
  outline: none;
  border-bottom: 1px solid var(--jd-collapse-border-color);
  &.is-disabled {
    color: var(--jd-collapse-disabled-text-color);
    cursor: not-allowed;
    background-image: none;
  }
  &.is-active {
    border-bottom-color: transparent;
    .header-angle {
      transform: rotate(90deg);
    }
  }
  .header-angle {
    transition: transform var(--jd-transition-duration);
  }
}
.jd-collapse-item__content {
  will-change: height;
  background-color: var(--jd-collapse-content-bg-color);
  overflow: hidden;
  box-sizing: border-box;
  font-size: var(--jd-collapse-content-font-size);
  color: var(--jd-collapse-content-text-color);
  border-bottom: 1px solid var(--jd-collapse-border-color);
  padding-bottom: 25px;
}
.slide-enter-active,
.slide-leave-active {
  transition: height var(--jd-transition-duration);
}

最终效果

PixPin_2024-12-01_14-06-03.gif

总结

本文从需求分析,确定方案,设计思路四个方面简单介绍了Collapse组件的基本实现方式,具体代码实现后续补充,如有错误还请大佬评论指正。