手风琴组件

大纲链接 §

[toc]


需求分析

  • 可展开显示项目内容
  • 可折叠隐藏内容区
  • 可禁用
  • 可配置一次只可展开一个项目,折叠其他项目
  • 默认折叠时显示向右箭头,展开时显示向下箭头

项目>标题>内容

1
2
3
4
5
6
7
...
  <VueCollapse>
    <VueCollapseItem title="标题1">内容1</VueCollapseItem>
    <VueCollapseItem title="标题2">内容2</VueCollapseItem>
    <VueCollapseItem title="标题3">内容3</VueCollapseItem>
  </VueCollapse>
...

API设计


创建手风琴组件

  • 父组件 VueCollapse.vue
  • 子组件 VueCollapseItem.vue
  • demo展示组件 Collapses.vue

显示/折叠内容区

VueCollapse.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<template>
  <div class="collapse">
    <slot>
      <!--占位-->
      <VueCollapseItem v-for="(item, index) in itemsData"
                       :title="item.title"
                       :isOpen="item.isOpen"
                       :key="index">
      </VueCollapseItem>
    </slot>
  </div>
</template>

<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import VueCollapseItem from './VueCollapseItem.vue';

@Component({
  components: {VueCollapseItem}
})
export default class VueCollapse extends Vue {
  name = 'VueCollapse';
  // 占位数据
  @Prop({
    type: Array,
    default() {return [];}
  }) itemsData!: [];
  
}
</script>

<style lang="scss" scoped>
$grey: #999;
$border-radius: 4px;
.collapse {
  border: 1px solid $grey;
  border-radius: $border-radius;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  width: 100%;
}
</style>

VueCollapseItem.vue

  • 点击切换@click="toggle"
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
<template>
  <div class="collapse-item">
    <header
      class="title"
      :class="{ 'title-show': isOpen}"
      @click="toggle">
      {{ title }}
    </header>
    <article
      class="content"
      :class="{ 'content-show': isOpen}"
      v-show="isOpen && !isDisabled">
      <slot>
        VueCollapseItem
      </slot>
    </article>
  </div>
</template>

<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';

@Component
export default class VueCollapseItem extends Vue {
  name = 'VueCollapseItem';
  isOpen = false;

  @Prop({
    type: String,
    default: '',
    required: true
  }) title!: string;

  toggle() {
    this.isOpen = !this.isOpen;
  }
  
}
</script>

<style lang="scss" scoped>
$grey: #999;
$border-radius: 4px;

@mixin border-bottom-radius($radius: 0px) {
  border-bottom-left-radius: $radius;
  border-bottom-right-radius: $radius;
}

.collapse-item {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  width: 100%;
  border-bottom: 1px solid $grey;

  > .title {
    width: 100%;
    min-height: 32px;
    display: flex;
    align-items: center;
    padding: 0 8px;
  }

  > .content {
    width: 100%;
    padding: 18px;

    &.content-show {
      border-top: 1px solid $grey;
    }
  }

  // .collapse-item
  &:first-child {

    > .title {
      // v-show = true
      &.title-show {
        border-top: none;
      }
    }
  }

  // .collapse-item
  &:last-child {
    border-bottom: none; //  覆盖 border-bottom: 1px solid $grey;

    > .title {
      // v-show = true
      &.title-show {
        @include border-bottom-radius;
      }
    }

    > .content {
      @include border-bottom-radius($border-radius);
    }
  }

}
</style>

Collapses.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
  <div>
    <form>
      <fieldset>
        <legend>Collapse 折叠面板</legend>
        <details open>
          <summary>基本样式</summary>
          <VueCollapse>
            <VueCollapseItem title="标题1">内容1</VueCollapseItem>
            <VueCollapseItem title="标题2">内容2</VueCollapseItem>
            <VueCollapseItem title="标题3">内容3</VueCollapseItem>
          </VueCollapse>
        </details>
      </fieldset>
    </form>
    
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import VueCollapse from './collapse/VueCollapse.vue';
import VueCollapseItem from './collapse/VueCollapseItem.vue';

@Component({
  components: {VueCollapseItem, VueCollapse}
})
export default class Collapses extends Vue {
  name = 'Collapses';
}
</script>

禁用显示/折叠

  • 子组件添加@Prop({type: Boolean, default: false}) isDisabled!: boolean;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<template>
  <div class="collapse-item">
    <header
      class="title"
      :class="{ 'title-show': isOpen && !isDisabled,
                'disabled': isDisabled}"
      @click="toggle">
      {{ title }}
    </header>
    <article
      class="content"
      :class="{ 'content-show': isOpen && !isDisabled}"
      v-show="isOpen && !isDisabled">
      <slot>
        VueCollapseItem
      </slot>
    </article>
  </div>
</template>

<script lang="ts">
import {Component, Inject, Prop, Vue} from 'vue-property-decorator';

@Component
export default class VueCollapseItem extends Vue {
  name = 'VueCollapseItem';
  isOpen = false;

  @Prop({
    type: String,
    default: '',
    required: true
  }) title!: string;
  @Prop({
    type: Boolean,
    default: false
  }) isDisabled!: boolean;

  @Inject() readonly eventBus!: Vue;
  @Inject() readonly isAllShowSingle!: boolean;

  toggle() {
    if (this.isOpen) {
      this.eventBus.$emit('remove:selected', this.title);
    } else {
      this.eventBus.$emit('add:selected', this.title);
    }
  }

  // listen to parent
  addBusListener() {
    this.eventBus?.$on('update:selected', (titleList: Array<string>) => {
      if (titleList.includes(this.title)) {
        this.isOpen = true;
      } else {
        this.isOpen = false;
      }
    });

  }

  initShow() {
    this.eventBus.$once('update:selected', (titleList: Array<string>) => {
      if (titleList.includes(this.title)) {
        this.isOpen = true;
      }
    });
  }

  mounted() {
    // listen to parent once
    this.initShow();
    // listen to parent
    this.addBusListener();
  }
}
</script>

<style lang="scss" scoped>...</style>

一种实现single的方式

  • 使用事件总线eventBus
  • 父组件提供数据 @Provide() eventBus = new Vue();
  • 子组件注入数据 @Inject() readonly eventBus!: Vue;

VueCollapse.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template>
  <div class="collapse">
    <slot>
      <VueCollapseItem v-for="(item, index) in itemsData"
                       :title="item.title"
                       :disabled="item.disabled"
                       :key="index">
      </VueCollapseItem>
    </slot>
  </div>
</template>

<script lang="ts">
import {Component, Prop, Provide, Vue} from 'vue-property-decorator';
import VueCollapseItem from './VueCollapseItem.vue';
@Component({
  components: {VueCollapseItem}
})
export default class VueCollapse extends Vue {
  name = 'VueCollapse';
  @Prop({
    type: Array,
    default() {return [];}
  }) itemsData!: [];
  @Provide() eventBus = new Vue();
}
</script>

VueCollapseItem.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<template>
  <div class="collapse-item"
       :class="{ 'title-show': isOpen && !isDisabled}">
    <header
      class="title"
      :class="{ 'title-show': isOpen && !isDisabled,
                'disabled': isDisabled}"
      @click="toggle">
      {{ title }}
    </header>
    <article
      class="content"
      v-show="isOpen && !isDisabled">
      <slot>
        VueCollapseItem
      </slot>
    </article>
  </div>
</template>

<script lang="ts">
import {Component, Inject, Prop, Vue} from 'vue-property-decorator';
@Component
export default class VueCollapseItem extends Vue {
  name = 'VueCollapseItem';
  isOpen = false;
  @Prop({
    type: String,
    default: '',
    required: true
  }) title!: string;
  @Prop({
    type: Boolean,
    default: false
  }) isDisabled!: boolean;
  @Inject() readonly eventBus!: Vue;
  toggle() {
    if (this.isOpen) {
      this.isOpen = false;
    } else {
      this.isOpen = true;
      this.eventBus.$emit('update:selected', this);
    }
  }
  close() {
    this.isOpen = false;
  }
  addBusListener() {
    this.eventBus.$on('update:selected', (vm: Vue) => {
      if (vm !== this) {
        this.close();
      }
    });
  }
  mounted() {
    this.addBusListener();
  }
}
</script>

实现 单向通知 执行回调

  • 子组件不直接变更状态
  • 子组件将变更通知发布给eventBus

组件经验总结

  • 单向数据流
    • 子组件不直接改变数据
    • 点击子组件不直接改变this.isOpen
  • 子组件监听 父组件传递给eventBus的事件,然后执行相应的回调函数
  • 子组件听从 父组件发布给eventBus的事件 间接改变数据

参考: