6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Vuetifyでoptgroup付きselectboxを作ってみる

Last updated at Posted at 2022-03-22

vuetifyを使用してoptgroup付きのselectboxを作成したときのメモです。

やりたいこと

  • multiple selectの場合はチェックボックスを表示し、optgroupも選択可能にする
  • optgroupをクリックしたときには配下のitemを全て選択したことにして非活性にする
  • 検索処理や更新処理など幅広く使えるようにしたいので、状況に合わせて使い分けられるように、以下の3パターンのデータを返却させる
    • groupの選択状況
    • itemの選択状況
    • groupもitemに置き換えた場合のitemの選択状況

スクリーンショット 2022-03-22 11.57.14.png

参考元

v-select Slots

list-item-groups selection-controls

GroupedSelect.vue

template

v-autocompleteのitem slotを拡張する方法で作成します。
また、選択結果にはv-chipを表示させたいのでdata slotにも手を入れます。

<template>
    <v-autocomplete
        v-model="selectedValues"
        v-bind="$attrs"
        color="primary"
        item-text="text"
        item-value="value"
        class="grouped-select"
        :placeholder="placeholder.selectable"
        :items="innerItems"
        :multiple="multiple"
        chips
    >
        <template #label>
            <slot name="label" />
        </template>
        <template #selection="data">
            <template v-if="multiple">
                <v-chip
                    v-if="!(singleLine && data.index > 0)"
                    class="chip--select-multi px-2"
                    :input-value="data.selected"
                    :color="isGroupOption(data.item) ? 'secondary darken-2' : 'primary'"
                    :close="!singleLine"
                    outlined
                    small
                    @click:close="removeItem(data.item)"
                >
                    {{ data.item.text }}
                </v-chip>
                <v-badge
                    v-if="singleLine && data.index > 0"
                    :content="`他${data.index}`"
                    :color="isGroupOption(data.item) ? 'secondary darken-2' : 'primary'"
                    absolute
                    top
                    left
                />
            </template>
            <template v-else>
                {{ data.item.text }}
            </template>
        </template>
        <template #item="{ item, attrs, on }">
            <v-list-item
                v-slot="{ active }"
                v-bind="attrs"
                class="px-1"
                :class="{ 'ml-6': isItemOption(item) && hasGroup }"
                :disabled="isGroupOption(item) ? !multiple : isGroupParentSelected(item)"
                v-on="on"
            >
                <v-list-item-action v-if="multiple" class="mr-2">
                    <v-checkbox
                        :disabled="isGroupOption(item) ? !multiple : isGroupParentSelected(item)"
                        :input-value="active"
                    />
                </v-list-item-action>
                <v-list-item-content
                    class="ml-2 text-caption"
                    @click="clickItem(item)"
                    v-text="item.text"
                />
            </v-list-item>
        </template>
    </v-autocomplete>
</template>

type

GroupedSelectValuesは選択結果のtype定義、GroupedSelectItemは選択肢の公開用type定義です。

export interface GroupedSelectValues {
  groupIdList: string[]
  itemIdList: string[]
  idList: string[]
}

interface GroupedSelectOption {
  optionType: 'group' | 'item'
  itemId: string
  groupId: string
  text: string
  value: string
}

export type GroupedSelectItem = Omit<GroupedSelectOption, 'value'>

props, data

groupIdList, itemIdListでそれぞれの選択状況を指定します。

  props: {
    multiple: {
      type: Boolean,
      default: false
    },
    singleLine: {
        type: Boolean,
        default: false
    },
    items: {
      type: Array as PropType<GroupedSelectItem[]>,
      default: () => []
    },
    groupIdList: {
      type: Array as PropType<string[]>,
      default: () => []
    },
    itemIdList: {
      type: Array as PropType<string[]>,
      default: () => []
    },
  },
  data () {
    return {
      selected: this.multiple ? [] : undefined
    }
  },

computed

選択状況の getter, setter

    selectedValues: {
      get (): string[] | string | undefined {
        return this.selected
      },
      set (v: string[] | string | undefined): void {
        if (v instanceof Array && v.length === this.selected?.length) {
          this.selected = v
        } else {
          this.$emit('change', this.getResult(v))
          this.selected = v
        }
      }
    },

${e.optionType}-${e.itemId}のルールで内部管理用のidを割り振ります。

    innerItems (): GroupedSelectOption[] {
      return (this.items || []).map((e) =>
        ({ ...e, value: `${e.optionType}-${e.itemId}` }))
    },

状態管理系処理

    hasGroup (): boolean {
      return !!(this.items || []).find(this.isGroupOption)
    },
    isGroupParentSelected () {
      const selected = this.selected
      return (item: GroupedSelectOption) =>
        castArray(selected).includes(`group-${item.groupId}`)
    },

watch

選択状況の変更監視
外部から選択状況が変更された場合のフォロー用です。

    groupIdList (val) {
      this.refreshSelectedValues(
        castArray(this.itemIdList),
        castArray(val))
    },
    itemIdList (val) {
      this.refreshSelectedValues(
        castArray(val),
        castArray(this.groupIdList))
    }

created

  created () {
    this.refreshSelectedValues(
      castArray(this.itemIdList),
      castArray(this.groupIdList))
  },

methods

選択状況を内部管理idに変換します。

    refreshSelectedValues (itemIdList: string[], groupIdList: string[]) {
      if (this.multiple) {
        this.selectedValues = [
          ...groupIdList.map(v => `group-${v}`),
          ...(itemIdList).map(v => `item-${v}`)
        ]
      } else {
        this.selectedValues = itemIdList[0] ? `item-${itemIdList[0]}` : undefined
      }
    },

状態管理系処理

    isGroupOption (item: GroupedSelectItem) {
      return item.optionType === 'group'
    },
    isItemOption (item: GroupedSelectItem) {
      return item.optionType === 'item'
    },

groupの子itemを取得します。

    getGroupedItemList (group: GroupedSelectItem): GroupedSelectOption[] {
      if (this.isGroupOption(group)) {
        return this.innerItems.filter((item) =>
          this.isItemOption(item) && item.groupId === group.groupId)
      } else {
        return []
      }
    },

選択削除処理

    removeItem (item: GroupedSelectOption) {
      if (this.selectedValues instanceof Array) {
        this.selectedValues = this.selectedValues.filter((v) => v !== item.value)
      } else {
        this.selectedValues = undefined
      }
    },

選択時処理
groupの場合、子itemの選択を外します。

    clickItem (item: GroupedSelectItem) {
      if (this.multiple === true && this.isGroupOption(item)) {
        this.getGroupedItemList(item).forEach(this.removeItem)
      }
    },

選択状況を作成します。

  • groupIdList: groupの選択中idリスト
  • itemIdList: itemの選択中idリスト
  • idList: 選択状況を全てitemで表現した場合の選択中idリスト
    getResult (selected: string[] | string | undefined): GroupedSelectValues {
      return castArray(selected).reduce((prev: GroupedSelectValues, value) => {
        const item = this.innerItems.find((item) => item.value === value)

        if (item && this.isGroupOption(item)) {
          prev.groupIdList.push(item.groupId)
          prev.idList = union(
            prev.idList,
            this.getGroupedItemList(item).map(e => e.itemId))
        } else if (item && this.isItemOption(item)) {
          prev.itemIdList.push(item.itemId)
          prev.idList = union(prev.idList, [item.itemId])
        }

        return prev
      }, {groupIdList: [], itemIdList: [], idList: []})
    },

style

<style lang="scss" scoped>
.grouped-select ::v-deep .v-select__slot {
    .v-input__append-inner {
        margin-top: 10px !important;
        padding: 0;

        .v-input__icon--append,
        .v-input__icon--clear {
            width: 20px;
            min-width: 20px;
            height: 20px;
            font-size: 20px;

            i,
            button
            {
                font-size: 20px;
            }
        }
    }
    .v-select__selections > * {
        margin-top: 4px;

        &:is(input) {
            margin-left: 8px;
            margin-right: 8px;
            padding-left: 8px;
            padding-right: 8px;
            border-radius: 4px;
            background-color: $ORG_BLUE2;
        }
    }
}
</style>

使い方 (component)

シンプルなリストボックスとして使用する例です。
複数選択か単体選択かはpropsで制御できるようにします。

Group1, Item5が選択された場合には、Item1 ~ Item5のid [ 'i1', 'i2', 'i3', 'i5' ] が取得されることを期待します。

template

<template>
  <grouped-select
    v-bind="$attrs"
    :items="items"
    :group-id-list="castArray(value.group)"
    :item-id-list="castArray(value.item)"
    :multiple="multiple"
    @change="change"
  >
    <template #label>
      <slot name="label"/>
    </template>
  </grouped-select>
</template>

model, props

  model: {
    prop: 'value',
    event: 'change',
  },
  props: {
    multiple: {
      type: Boolean,
      default: false
    },
    value: {
      type: Object as PropType<Value>,
      default: undefined
    },
  },

vue2ではmodelがひとつしか使えないのでgroupとitemを保持できる構造にします。

export interface Value {
  group: string | string[]
  item : string | string[]
}

computed

選択肢の取得

    items (): GroupedSelectItem[] {
      // sample items
      return [
        { optionType: 'group', itemId: 'g1', groupId: 'g1', text: 'Group1' },
        { optionType: 'item', itemId: 'i1', groupId: 'g1', text: 'Item1' },
        { optionType: 'item', itemId: 'i2', groupId: 'g1', text: 'Item2' },
        { optionType: 'item', itemId: 'i3', groupId: 'g1', text: 'Item3' },
        { optionType: 'group', itemId: 'g2', groupId: 'g2', text: 'Group2' },
        { optionType: 'item', itemId: 'i5', groupId: 'g2', text: 'Item5' },
        { optionType: 'item', itemId: 'i6', groupId: 'g2', text: 'Item6' },
      ]
    },

methods

multiple selectの場合はstring[]、single selectの場合はstringで返却します。
例ではgroupの選択状況は不要でitemの選択状況だけがあれば良いのでidListだけを使用します。

    change (values: GroupedSelectValues) {
      if (this.multiple === true) {
        const value: Value = {
          group: values.groupIdList,
          item : values.itemIdList,
        }
        this.$emit('change', value)
      } else {
        const value: Value = {
          group: '',
          item : values.idList[0],
        }
        this.$emit('change', value)
      }
    },

その他パーツ

import _castArray from "lodash/castArray"

export function castArray <T> (v: T[] | T | undefined) {
    return V ? _castArray(v) : []
}
6
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?