在当今的网页设计和应用开发中,折叠面板(Collapse Panel) 已经成为了一种不可或缺的交互元素。它不仅能够有效节省页面空间,还能通过动态展示和隐藏内容,为用户提供更加流畅、个性化的浏览体验。本文将带你深入了解如何从零开始构建一个基本的折叠面板组件,涵盖需求分析、方案确定、设计思路等关键步骤。
一、需求分析
折叠面板需要具备的功能:
1. 多面板支持
- 用户可以在一个页面中存在多个折叠项,并且每个面板的行为可以独立控制。
- 每个面板应有一个唯一的标识符(
name),用于区分不同的面板。
2. 展开/收起功能
- 用户可以通过点击面板标题来切换内容的显示状态(展开或收起)。
- 支持初始状态配置,允许开发者设置哪些面板默认展开或收起。
3. 手风琴模式
- 提供手风琴模式(
accordion),即一次只能展开一个面板。当用户点击某个面板时,其他已展开的面板会自动收起。 - 默认情况下,允许多个面板同时展开。
4. 动画效果
- 为了提升用户体验,折叠和展开的过程应具备平滑的过渡动画。
- 动画效果可以通过 CSS 或 Vue 的
<transition>组件实现。
5. 事件监听
- 提供
change事件,通知父组件面板状态的变化(展开或收起)。 - 事件参数应包含当前展开的面板标识符(
name)或所有展开的面板列表。
6. 样式定制
- 支持通过
scoped样式或传递类名来自定义面板的外观。 - 提供默认样式的同时,允许开发者根据项目需求进行个性化调整。
7. 禁用面板
- 支持禁用某些面板,防止用户点击展开。禁用的面板应有明显的视觉提示(如灰色背景或禁用图标)。
8. 加载状态
- 在内容加载时,提供加载状态的提示(如加载图标),避免用户误操作。
9. 自定义标题
- 允许用户通过插槽(
slot)自定义面板标题的内容,而不局限于简单的文本。
二、方案确定
在明确了需求后,接下来是选择合适的技术栈和实现方案。我们将使用 Vue 3 和 TypeScript 来构建这个组件,以确保代码的类型安全和可维护性。以下是具体的方案确定:
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提供currentActiveNames、accordion和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);
}
最终效果
总结
本文从需求分析,确定方案,设计思路四个方面简单介绍了Collapse组件的基本实现方式,具体代码实现后续补充,如有错误还请大佬评论指正。