Searchable Combobox 検索型選択リストを作ってみた (lwc)

# 背景
Lightningのcomboboxのいまいちな点は、プルダウンメニューが多い選択肢だと心が萎えるところ。lightning-comboboxは決められた選択肢の中から選択する必要があるのと、選択肢が多い場合の選択肢へ辿り着くのが面倒でした。(Shiftを押しながらタイプすればというのもありますが）

lightning-comboboxとは別に、slds-comboboxもあり、こちらはより自由度が高く設定ができるようなので、試してみました。

# デモ
![searchable-combobox.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15524/984eb7d1-58c6-9138-9653-f3a99dfe4e58.gif)

# 実装
実装はそこまで難しくなく、選択肢となるコレクションデータを取得して用意しておきます。
## SOQL
### AggregateResult
<details><summary> getGroupedData</summary>

```
    @AuraEnabled
    public static List<AggregateResult> getLeadsGroupedResult(String groupBy) {
        String query = 'SELECT COUNT(Id)cnt, ' + string.escapeSingleQuotes(groupBy) + ' FROM Lead';
        query += ' WHERE Status != \'Unqualified\' AND IsConverted = false';
        query += ' GROUP BY ' + groupBy;
        query += ' ORDER BY COUNT(Id) DESC';
        return Database.query(query);
    }
```
</details>
こちらで任意のgroupBy項目で集計したデータが返ってきます。今回はデフォルトでLeadSouceを取得します。

## LWC
### combobox
<details><summary>combobox (html)</summary>

```
            <div class="slds-combobox_container slds-m-horizontal_x-small">
                <div class={comboboxClasses} aria-expanded={isOpen} aria-haspopup="listbox" role="combobox">
                    <div class="slds-combobox__form-element slds-input-has-icon slds-input-has-icon_right" role="none">
                        <input type="text" class="slds-input slds-combobox__input" aria-controls="listbox-id-1"
                            autocomplete="off" role="textbox" placeholder="Search Lead Source" oninput={handleInput}
                            onfocus={handleFocus} onblur={handleBlur} value={leadSource} />
                    </div>
                    <div class="slds-dropdown slds-dropdown_length-5 slds-dropdown_fluid">
                        <ul class="slds-listbox slds-listbox_vertical" role="listbox">
                            <template for:each={filteredData} for:item="group">
                                <li key={group.LeadSource} role="presentation" class="slds-listbox__item">
                                    <div data-id={group.LeadSource}
                                        class="slds-listbox__option slds-listbox__option_plain slds-media slds-media_small slds-media_center"
                                        role="option" onclick={handleSelect}>
                                        <span class="slds-media__body">
                                            <span class="slds-truncate" title={group.LeadSource}>{group.LeadSourceName}
                                                ({group.cnt})</span>
                                        </span>
                                    </div>
                                </li>
                            </template>
                        </ul>
                    </div>
                </div>
            </div>
```
</details>

:::note warn
公式には slds-is-open のclassを挿入しないとできないとは書いてありますが、さらにaria-expandedもtrue, falseで制御する必要もあります。一括で行いために、class={comboboxClasses} で定義しました。
参照: [slds combobox - displaying options (salesforce)](https://www.lightningdesignsystem.com/components/combobox/#Displaying-options)
:::

### 集計データの取得
<details><summary> loadGroupedData </summary>

connectedCallbackで、集計データ取得を呼びます。

```
connectedCallback() {
    this.loadConnectedData(); // こちらはDatatable用データの取得
    this.loadGroupedData(); // こちらは集計データ、Comboboxに使用
}
```

呼び出されるloadGroupedData()は以下です。
```
    loadGroupedData() {
        return new Promise((resolve, reject) => {
            getLeadsGroupedResult({groupBy: this.groupBy})
            .then(data => {
                let groupedData = data && data.length ? data.map(item => {
                    if (item[this.groupBy] === undefined) {
                        return {...item, [this.groupBy]: '', [this.groupBy + 'Name']: '---'};
                    } else {
                        return {...item, [this.groupBy + 'Name']: item[this.groupBy]};
                    }
                }) : [];

                // Sort the groupedData array
                groupedData = groupedData.sort((a, b) => {
                    if (a[this.groupBy] === '' && b[this.groupBy] !== '') {
                        return 1;
                    } else if (a[this.groupBy] !== '' && b[this.groupBy] === '') {
                        return -1;
                    } else {
                        return 0;
                    }
                });

                this.groupedData = groupedData;
                const groupedTotal = groupedData.reduce((current, next) => current + next.cnt, 0);
    
                resolve(data);
            })
            .catch(error => {
                console.error(error);
            })
        });
    }
```
</details>

:::note warn
空の値の取得もありうるのでその空データがASCでは最上部になるので見栄えが悪く、選択肢の一番下へ行くようにmethod内でsortを行っています。
:::

### Comboxの制御
<details><summary>handleInput(event) </summary>

```
    /* Searchable Combobox */
    @track isOpen = false;
    @track filteredData = [];

    handleInput(event) {
        const searchKeyDirect = event.target.value;
        if (searchKeyDirect === '') {
            this.leadSource = '';
            this.stringSet.leadSource = '';
            this.filteredData = [...this.groupedData];
            this.initializeSearch();
            this.loadData();
            return;
        }
        const searchKey = event.target.value.toLowerCase();
        this.filteredData = this.groupedData.filter(group => {
            return (group.LeadSource || '').toLowerCase().includes(searchKey);
        });
        this.leadSource = searchKeyDirect;
        this.isOpen = true;
    }

    async handleSelect(event) {
        this.isLoading = true;
        const selectedLeadSource = event.currentTarget.dataset.id;
        this.leadSource = selectedLeadSource ? selectedLeadSource : '';
        this.stringSet.leadSource = selectedLeadSource ? selectedLeadSource : '';
        this.initializeSearch();
        await this.loadData();
        this.loadAggregatedResult();
        this.isLoading = false;
        this.isOpen = false;
    }

    handleFocus() {
        this.filteredData = [...this.groupedData];
        this.isOpen = true;
    }

    handleBlur() {
        setTimeout(() => {
            this.isOpen = false;
        }, 100);
    }

    get comboboxClasses() {
        return `slds-combobox slds-dropdown-trigger slds-dropdown-trigger_click ${this.isOpen ? 'slds-is-open' : ''}`;
    }
```
</details>

削除や空文字の場合、ボックスの非表示と選択肢の初期化を行います。
```
if (searchKeyDirect === '') {
    this.leadSource = '';
            this.stringSet.leadSource = '';
            this.filteredData = [...this.groupedData];
            this.initializeSearch();
            this.loadData();
    return;
}
```
comboboxの表示非表示のためslds-is-openの有無をこちらで制御します。
```
   get comboboxClasses() {
        return `slds-combobox slds-dropdown-trigger slds-dropdown-trigger_click ${this.isOpen ? 'slds-is-open' : ''}`;
    }
```

こちらはフォーカスが外れた際、即行うとcomboboxの取得ができないので、少し遅らせます。
```
    handleBlur() {
        setTimeout(() => {
            this.isOpen = false;
        }, 100);
    }
```

# 参照
[slds combobox (salesforce) ](https://www.lightningdesignsystem.com/components/combobox)
[lightning-combobox (salesforce)](https://developer.salesforce.com/docs/component-library/bundle/lightning-combobox/documentation)


## 関連投稿
[Mockarooでダミーデータを作成してみた](https://qiita.com/masahiro_sf/items/86551599b08ea4094d03)
[DatatableのInfinite Loadingを実装してみた (lwc)](https://qiita.com/masahiro_sf/items/e30b0414f6ae7b8b9f5b)

## github
https://github.com/liuxiachanghong/datatable-lwc/tree/main/force-app/main/default