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

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

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

![スクリーンショット 2022-03-22 11.57.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333070/31b5522e-1964-d8d7-7a52-8821211ca191.png)

## 参考元

v-select Slots

https://vuetifyjs.com/ja/api/v-select/#slots

list-item-groups selection-controls

https://vuetifyjs.com/ja/components/list-item-groups/#selection-controls

## GroupedSelect.vue
### template
v-autocompleteのitem slotを拡張する方法で作成します。
また、選択結果にはv-chipを表示させたいのでdata slotにも手を入れます。
```html
<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定義です。

```typescript
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でそれぞれの選択状況を指定します。

```typescript
  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

```typescript
    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を割り振ります。

```typescript
    innerItems (): GroupedSelectOption[] {
      return (this.items || []).map((e) =>
        ({ ...e, value: `${e.optionType}-${e.itemId}` }))
    },
```
状態管理系処理

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

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

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

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

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


```typescript
    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
      }
    },
```
状態管理系処理
```typescript
    isGroupOption (item: GroupedSelectItem) {
      return item.optionType === 'group'
    },
    isItemOption (item: GroupedSelectItem) {
      return item.optionType === 'item'
    },
```
groupの子itemを取得します。
```typescript
    getGroupedItemList (group: GroupedSelectItem): GroupedSelectOption[] {
      if (this.isGroupOption(group)) {
        return this.innerItems.filter((item) =>
          this.isItemOption(item) && item.groupId === group.groupId)
      } else {
        return []
      }
    },
```
選択削除処理
```typescript
    removeItem (item: GroupedSelectOption) {
      if (this.selectedValues instanceof Array) {
        this.selectedValues = this.selectedValues.filter((v) => v !== item.value)
      } else {
        this.selectedValues = undefined
      }
    },
```
選択時処理
groupの場合、子itemの選択を外します。
```typescript
    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リスト


```typescript
    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
```vue
<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
```html
<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
```typescript
  model: {
    prop: 'value',
    event: 'change',
  },
  props: {
    multiple: {
      type: Boolean,
      default: false
    },
    value: {
      type: Object as PropType<Value>,
      default: undefined
    },
  },
```

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

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

### computed
選択肢の取得
```typescript
    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だけを使用します。

```typescript
    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)
      }
    },
```

その他パーツ
```typescript
import _castArray from "lodash/castArray"

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