#app

script#multi-selection(type="text/x-template")
  .multi-selection
    template(v-for="option in cmpOptions")
      .multi-selection__item(
        :class="{\
          '-selected': option.isSelected,\
          '-selecting': option.isSelecting,\
          '-delete': option.isDelete,\
        }"
        @click="onClick(option)"
      )
        | {{ option.text }}

script#vue-app(type="text/x-template")
  div
    div 複数選択の状態管理
    .block
      .block__title MultiSelection1
      .block__content
        div
          button(@click="multiSelection.onCancel") cancel
          button(@click="multiSelection.onConfirm") confirm
        MultiSelection(
          :selectedIds="multiSelection.state.selectedIds"
          :selectingIds="multiSelection.state.selectingIds"
          :deleteIds="multiSelection.state.deleteIds"
          :options="OPTIONS"
          @select="multiSelection.onSelect"
        )
        div selectedIds: {{ multiSelection.state.selectedIds }}
        div selectingIds: {{ multiSelection.state.selectingIds }}
        div deleteIds: {{ multiSelection.state.deleteIds }}
    .block
      .block__title MultiSelection2
      .block__content
        div
          button(@click="multiSelection2.onCancel") cancel
          button(@click="multiSelection2.onConfirm") confirm
        MultiSelection(
          :selectedIds="multiSelection2.state.selectedIds"
          :selectingIds="multiSelection2.state.selectingIds"
          :deleteIds="multiSelection2.state.deleteIds"
          :options="OPTIONS"
          @select="multiSelection2.onSelect"
        )
        div selectedIds: {{ multiSelection2.state.selectedIds }}
        div selectingIds: {{ multiSelection2.state.selectingIds }}
        div deleteIds: {{ multiSelection2.state.deleteIds }}
View Compiled
* {
  box-sizing: border-box;
}

.block {
  padding: 10px;
  border: solid 1px #000;
  
  & + & {
    margin-top: 10px;
  }
  
  &__title {
    font-weight: bold;
  }
  
  &__content {
    margin-top: 5px;
  }
}

.multi-selection {
  display: flex;
  padding: 5px 0;
  
  &__item {
    border: solid 1px #ccc;
    padding: 5px 15px;
    border-radius: 5px;
    cursor: pointer;
    
    &.-selected {
      background-color: #cfc;
    }
    
    &.-selecting {
      color: #fff;
      background-color: #090;
    }
    
    &.-delete {
      background-color: #eee;
      border-style: dashed;
    }
    
    & + & {
      margin-left: 10px;
    }
  }
}
View Compiled
const { ref, reactive, computed } = Vue;

const OPTIONS = [
  { id: 'A', text: 'A' },
  { id: 'B', text: 'B' },
  { id: 'C', text: 'C' },
  { id: 'D', text: 'D' },
  { id: 'E', text: 'E' }
];

/**
 * 複数選択の状態を管理するモジュール
 */
function useMultiSelection(initialSelectedIds = []) {
  const state = reactive({
    selectedIds: initialSelectedIds,
    selectingIds: [],
    deleteIds: [],
  });

  return {
    state,
    onSelect: (option) => {
      {
        // 選択中の場合は選択を外す
        const index = state.selectingIds.findIndex((id) => id === option.id);
        if (index !== -1) {
          state.selectingIds.splice(index, 1);
          return;
        }
      }

      {
        // 削除中の場合は削除リストから外す
        const index = state.deleteIds.findIndex((id) => id === option.id);
        if (index !== -1) {
          state.deleteIds.splice(index, 1);
          return;
        }
      }

      {
        // 選択済みの項目なら削除リストに追加する
        const index = state.selectedIds.findIndex((id) => id === option.id);
        if (index !== -1) {
          state.deleteIds.push(option.id);
          return;
        }
      }

      // それ以外は選択中として登録
      state.selectingIds.push(option.id);
    },
    onCancel: () => {
      state.selectingIds = [];
      state.deleteIds = [];
    },
    onConfirm: () => {
      const addedIds = _.union(state.selectedIds, state.selectingIds);
      const excludedIds = _.difference(addedIds, state.deleteIds);
      state.selectedIds = excludedIds;
      state.selectingIds = [];
      state.deleteIds = [];
    },
  };  
}

const app = Vue.createApp({
  template: '#vue-app',
  setup() {
    const multiSelection = useMultiSelection();
    const multiSelection2 = useMultiSelection([OPTIONS[0].id, OPTIONS[1].id]);
    
    return {
      OPTIONS,
      multiSelection,
      multiSelection2,
    };
  },
});

app.component('MultiSelection', {
  template: '#multi-selection',
  emits: {
    select: (option) => {
      return option != null;
    },
  },
  props: {
    selectedIds: { type: Array },
    selectingIds: { type: Array },
    deleteIds: { type: Array },
    options: { type: Array },
  },
  setup(props, context) {
    const cmpOptions = computed(() => {
      return props.options.map((option) => {
        const isSelected = props.selectedIds.includes(option.id);
        const isSelecting = props.selectingIds.includes(option.id);
        const isDelete = props.deleteIds.includes(option.id);
        return {
          ...option,
          isSelected: !isDelete && isSelected,
          isSelecting,
          isDelete,
        };  
      });
    });
    
    return {
      cmpOptions,
      onClick: (option) => {
        context.emit('select', option);
      },
    };
  },
});

app.mount('#app');

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/vue/3.0.4/vue.global.js
  2. https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js