手风琴组件

大纲链接 §

[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
<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
    
    <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
<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
    
    <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
<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的事件 间接改变数据

参考: