LWCで入力コンポーネントを作成する

lightning-input、lightning-comboboxなど標準の入力コンポーネントでは要件を満たせず、入力コンポーネントの作成を依頼されることが度々あったので、押さえとくべきポイントをまとめました。
低コストで作成できるように簡略的な書き方になります。より高度な例は[salesforce/base-components-recipes](https://github.com/salesforce/base-components-recipes)が参考になります。

## コード例
<!-- ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼ -->
<details><summary>テンプレート(HTML)</summary>

```html:sampleInput.html
<template>
    <div class={formElementClass}>
        <!-- ラベル -->
        <label class={labelClass}>
            <template lwc:if={_required}>
                <abbr class="slds-required" title="必須">*</abbr>
            </template>
            {label}
        </label>

        <!-- ヘルプテキスト -->
        <lightning-helptext lwc:if={fieldLevelHelp} content={fieldLevelHelp}></lightning-helptext>

        <!-- フォーム -->
        <div class="slds-form-element__control">
        <!-- ★ここにフォームを実装する -->
        </div>

        <!-- エラーメッセージ -->
        <div lwc:if={_errorMessage} class="slds-form-element__help">{_errorMessage}</div>
    </div>
</template>
```
</details>
<!-- ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲ -->

<!-- ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼ -->
<details><summary>テンプレート(JavaScript)</summary>

```javascript:sampleInput.js
import { LightningElement, api } from "lwc";

const VARIANT = {
    STANDARD: "standard",
    LABEL_HIDDEN: "label-hidden",
    LABEL_STACKED: "label-stacked",
    LABEL_INLINE: "label-inline"
};

export default class SampleInput extends LightningElement {
    @api label;
    @api variant = VARIANT.STANDARD;
    @api fieldLevelHelp;
    @api messageWhenValueMissing = "この項目を入力してください。";
    @api placeholder;
    // @api disabled; フォームによって実装が異なる
    // @api readonly; フォームによって実装が異なる

    _value;
    _required = false;
    _errorMessage;
    _customValidityMessage;

    @api
    get value() {
        return this._value;
    }

    set value(value) {
        this._value = value;
    }

    @api
    get required() {
        return this._required;
    }

    set required(value) {
        this._required = this.normalizeBoolean(value);
    }

    @api
    checkValidity() {
        // required以外の制限（minなど）を実装するは処理を追加します。
        const checkRequired = !(!this.disabled && this._required && !this._value);
        const checkCustomValidate = !this._customValidityMessage;
        return checkRequired && checkCustomValidate;
    }

    @api
    reportValidity() {
        this.classList.toggle("slds-form-element");
        if (this.checkValidity()) {
            this._errorMessage = "";
            return false;
        }
        // required以外の制限（minなど）を実装するは処理を追加します。
        this._errorMessage = this._customValidityMessage || this.messageWhenValueMissing;
        return true;
    }

    @api
    setCustomValidity(message) {
        this._customValidityMessage = message;
    }

    @api
    showHelpMessageIfInvalid() {
        this.reportValidity();
    }

    get formElementClass() {
        let style = "slds-form-element";
        if (this.variant === VARIANT.LABEL_STACKED) {
            style += " slds-form-element_stacked";
        }
        if (this.variant === VARIANT.LABEL_INLINE) {
            style += " slds-form-element_horizontal";
        }
        if (this._errorMessage) {
            style += " slds-has-error";
        }
        if (this.readOnly) {
            style += " slds-form-element_readonly";
        }
        return style;
    }

    get labelClass() {
        const style = "slds-form-element__label";
        return this.variant === VARIANT.LABEL_HIDDEN ? style + " slds-assistive-text" : style;
    }

    // 値が変更されたときに下記のメソッドを呼び出し、changeイベントを発火させる
    dispatchChangeEvent(value) {
        this.dispatchEvent(
            new CustomEvent("change", {
                detail: {
                    value
                }
            })
        );
    }

    normalizeBoolean(value) {
        return typeof value === "string" || !!value;
    }

    // フォームによって実装が異なる
    // handleBlur() {
    //     this.reportValidity();
    // }
}
```
</details>
<!-- ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲ -->

下記のコード例は入力コンポーネントの作成例です。フリーテキスト入力、プルダウン選択に対応した入力フォームです。

![スクリーンショット 2023-12-12 3.31.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/f77165a4-c728-b80e-a0d9-c87b7e0e88dd.png)

<!-- ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼ -->
<details><summary>コード例(HTML)</summary>

```html:selectableInputText.html
<template>
    <div class={formElementClass}>
        <!-- ラベル -->
        <label class={labelClass}>
            <template lwc:if={_required}>
                <abbr class="slds-required" title="必須">*</abbr>
            </template>
            {label}
        </label>

        <!-- ヘルプテキスト -->
        <lightning-helptext lwc:if={fieldLevelHelp} content={fieldLevelHelp}></lightning-helptext>

        <!-- フォーム -->
        <div class="slds-form-element__control">
            <!-- この記事の本筋から逸れるため、下記のコンポーネントには触れません。発展の項で紹介しているsalesforce/base-components-recipesを参考にしてください。 -->
            <c-multi-select-combobox-base
                value={_value}
                placeholder={placeholder}
                options={options}
                onchange={handleChange}
                onselect={handleSelect}
                onblur={handleBlur}
                disabled={disabled}
            ></c-multi-select-combobox-base>
        </div>

        <!-- エラーメッセージ -->
        <div lwc:if={_errorMessage} class="slds-form-element__help">{_errorMessage}</div>
    </div>
</template>
```
</details>
<!-- ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲ -->

<!-- ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼ -->
<details><summary>コード例(JavaScript)</summary>

```javascript:selectableInputText.js
import { LightningElement, api } from "lwc";

const VARIANT = {
    STANDARD: "standard",
    LABEL_HIDDEN: "label-hidden",
    LABEL_STACKED: "label-stacked",
    LABEL_INLINE: "label-inline"
};

/**
 * @typedef {Object} ComboboxOption
 * @property {string} label
 * @property {string} value
 */

export default class SelectableInputText extends LightningElement {
    @api label;
    @api variant = VARIANT.STANDARD;
    @api fieldLevelHelp;
    @api messageWhenValueMissing = "この項目を入力してください。";
    @api placeholder;

    /**
     * @description サジェストの選択肢リストの全量。
     * @type {ComboboxOption[]}
     */
    @api options = [];

    _value;
    _required = false;
    _disabled = false;
    _readonly = false;
    _errorMessage;
    _customValidityMessage;

    @api
    get value() {
        return this._value;
    }

    set value(value) {
        this._value = value;
    }

    @api
    get required() {
        return this._required;
    }

    set required(value) {
        this._required = this.normalizeBoolean(value);
    }

    @api
    get disabled() {
        return this._disabled || this._readonly || false;
    }

    set disabled(value) {
        this._disabled = this.normalizeBoolean(value);
    }

    @api
    get readonly() {
        return this._disabled;
    }

    set readonly(value) {
        this._readonly = this.normalizeBoolean(value);
    }

    @api
    checkValidity() {
        // required以外の制限（minなど）を実装するは処理を追加します。
        const checkRequired = !(!this.disabled && this._required && !this._value);
        const checkCustomValidate = !this._customValidityMessage;
        return checkRequired && checkCustomValidate;
    }

    @api
    reportValidity() {
        this.classList.toggle("slds-form-element");
        if (this.checkValidity()) {
            this._errorMessage = "";
            return false;
        }
        // required以外の制限（minなど）を実装するは処理を追加します。
        this._errorMessage = this._customValidityMessage || this.messageWhenValueMissing;
        return true;
    }

    @api
    setCustomValidity(message) {
        this._customValidityMessage = message;
    }

    @api
    showHelpMessageIfInvalid() {
        this.reportValidity();
    }

    get formElementClass() {
        let style = "slds-form-element";
        if (this.variant === VARIANT.LABEL_STACKED) {
            style += " slds-form-element_stacked";
        }
        if (this.variant === VARIANT.LABEL_INLINE) {
            style += " slds-form-element_horizontal";
        }
        if (this._errorMessage) {
            style += " slds-has-error";
        }
        if (this.readOnly) {
            style += " slds-form-element_readonly";
        }
        return style;
    }

    get labelClass() {
        const style = "slds-form-element__label";
        return this.variant === VARIANT.LABEL_HIDDEN ? style + " slds-assistive-text" : style;
    }

    dispatchChangeEvent(value) {
        this.dispatchEvent(
            new CustomEvent("change", {
                detail: {
                    value
                }
            })
        );
    }

    normalizeBoolean(value) {
        return typeof value === "string" || !!value;
    }

    handleChange(event) {
        event.stopPropagation();
        if (event.detail.value === this._value) {
            return;
        }
        this._value = event.detail.value;
        this.dispatchChangeEvent(this._value);
    }

    handleSelect(event) {
        event.stopPropagation();
        if (event.detail.value === this._value) {
            return;
        }
        this._value = event.detail.value;
        this.dispatchChangeEvent(this._value);
    }

    handleBlur() {
        this.reportValidity();
    }
}
```
</details>
<!-- ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲ -->


## ラベル
lightning-inputなどの標準コンポーネントと一貫性を持たせるようにします。
[標準の入力コンポーネント](https://developer.salesforce.com/docs/component-library/bundle/lightning-input/specification)では、ラベルの表示形式をvariant属性で変更できます。自作する場合も同じ使い方ができるように作成します。
variant属性の値は下記の値を取れるように作成します。

- ① standard
    - ラベルとコンボボックスを垂直に配置します。デフォルトの値です。マージンが追加されないため、連続すると窮屈な印象を与えます。
- ② label-inline
    - ラベルと入力欄を水平に配置します。
    - `slds-form-element_stacked`に、`slds-form-element_horizontal`クラスを追加します。
- ③ label-stacked
    - ラベルとコンボボックスを垂直に配置し、ボトムマージンを追加します。
    - `slds-form-element_stacked`に、`slds-form-element_stacked`を追加します。
- ④ label-hidden
    - ラベルが非表示になります。
    - labelに`slds-assistive-text`クラスを追加します。

![multicombobox002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/a1a71db4-51c3-72b4-8c29-a129f80675c5.png)


上記の内容を実装すると下記のようになります。

```html:使用例
<c-sample-input label="氏名" variant="label-inline"></c-sample-input>
```

```html:sampleInput.html
<template>
    <div class={formElementClass}>
        <!-- ラベル -->
        <label class={labelClass}> {label} </label>
    </div>
</template>
```

:::note
チェックボックスグループなど複合した入力コンポーネントの場合、divの代わりにfieldset、labelの代わりにlegendを使用します。さらにslds-form-element__labelにslds-form-element__legendを追加します。
:::

```javascript:sampleInput.js
import { LightningElement, api } from "lwc";

const VARIANT = {
    STANDARD: "standard",
    LABEL_HIDDEN: "label-hidden",
    LABEL_STACKED: "label-stacked",
    LABEL_INLINE: "label-inline"
};

export default class SampleInput extends LightningElement {
    /**
     * @description 入力項目のラベル
     * @type {string}
     */
    @api label;

    /**
     * @description コンボボックスの外観を変える
     *              standard：ラベルとコンボボックスを垂直に配置する
     *              label-hidden:ラベルが非表示になる
     *              label-inline：ラベルとコンボボックスを水平に配置する
     *              label-stacked：ラベルとコンボボックスを垂直に配置し、ボトムマージンが追加される
     * @type {"standard"|"label-hidden"|"label-inline"|"label-stacked"}
     */
    @api variant = VARIANT.STANDARD;

    get formElementClass() {
        let style = "slds-form-element";
        if (this.variant === VARIANT.LABEL_STACKED) {
            style += " slds-form-element_stacked";
        }
        if (this.variant === VARIANT.LABEL_INLINE) {
            style += " slds-form-element_horizontal";
        }
        if (this._errorMessage) {
            style += " slds-has-error";
        }
        if (this.readOnly) {
            style += " slds-form-element_readonly";
        }
        return style;
    }

    get labelClass() {
        const style = "slds-form-element__label";
        return this.variant === VARIANT.LABEL_HIDDEN ? style + " slds-assistive-text" : style;
    }
}

```

:::note
sldsで始まるスタイルクラスは[SLDS（Salesforce Lightning Design System）](https://www.lightningdesignsystem.com/)で提供されています。SLDSを使用することで、一貫性のあるデザインを簡単に実現できます。
:::





## 必須
標準の入力コンポーネント同様に、必須時に「*」をラベルに表示し、送信時にバリデーションチェックされるようにします。

```html:使用例
<c-sample-input label="" required></c-sample-input>
```

```html:sampleInput.html
<template>
    <div class={formElementClass}>
        <!-- ラベル -->
        <label class={labelClass}>
            <!-- 必須 -->
            <template lwc:if={_required}>
                <abbr class="slds-required" title="必須">*</abbr>
            </template>
            {label}
        </label>
    </div>
</template>

```

```javascript:sampleInput.js
_required = false;

@api get required() {
    return this._required;
}

set required(value) {
    this._required = this.normalizeBoolean(value);
}

/**
 * @description 属性のためのブール正規化ユーティリティ。
 * @param {any} value - 正規化する値。
 * @return {boolean} - 正規化された値。
 */
normalizeBoolean(value) {
    return typeof value === "string" || !!value;
}
```

バリデーションに使用する公開メソッドはバリデーションのところで説明します。

:::note
abbrのtitleに日本語を使用していますが、多言語対応するにはカスタム表示ラベルを使用します。

```javascript
import labelRequired from "@salesforce/label/c.CMN_Required";
```
:::





## ヘルプテキスト
ヘルプテキストには標準コンポーネントのlightning-helptextが使えます。

![helptext.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/125621/499e53a4-8795-1e12-7c34-8521831fe501.png)


```html
<c-sample-input label="テスト" field-level-help="ヘルプテキスト"></c-sample-input>
```

```html:sampleInput.html
<template>
    <div class={formElementClass}>
        <!-- ラベル -->
        <label class={labelClass}>
            <template lwc:if={_required}>
                <abbr class="slds-required" title="必須">*</abbr>
            </template>
            {label}
        </label>

        <!-- ヘルプテキスト -->
        <lightning-helptext lwc:if={fieldLevelHelp} content={fieldLevelHelp}></lightning-helptext>
    </div>
</template>
```

```javascript:sampleInput.js
/**
 * @description 入力の目的と機能を詳しく説明したヘルプテキスト。
 * @type {string?}
 */
@api fieldLevelHelp;
```





## カスタムイベント
値が変更された時にchangeイベントを発生するようにします。発火させるタイミングはフォームの実装によって異なります。

```html:使用例
<c-sample-input label="サンプル" onchange={handleChange}></c-sample-input>
```

```javascript:sampleInput.js
dispatchChangeEvent(value) {
    this.dispatchEvent(
        new CustomEvent("change", {
            detail: {
                value
            }
        })
    );
}
```

changeイベントと同様にして、必要に応じてinputイベント、blurイベントなどのカスタムイベントの発火処理を実装します。

:::note
カスタムイベントは子コンポーネントから親コンポーネントに情報を伝えるために使用されます。[Lightning Web Component 間の通信の基礎 - Qiita](https://qiita.com/TaaaZyyy/items/ec82c0f048440e904d5e#%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88)
:::





## バリデーション用の公開メソッド
lightning-inputなど他の入力項目とのため、下記のメソッドを実装します。

| メソッド | 備考 |
|:-|:-|
| checkValidity | 入力が有効かどうかを確認します。 |
| reportValidity | 入力が不正な場合は、エラーメッセージを表示し、falseを返します。入力が有効な場合は、表示されたエラーメッセージをクリアし、trueを返します。 |
| setCustomValidity | フォームの送信時に表示されるカスタムエラーメッセージを設定します。 |
| showHelpMessageIfInvalid | 入力が不正な場合は、エラーメッセージを表示し、有効な場合はエラーメッセージがクリアされます。 |

```javascript:使用例
// 標準入力コンポーネントとカスタム入力コンポーネントをまとめてバリデーションする方法
// <c-sample-input label="サンプル" data-type="input"></c-sample-input>
// <lightning-input label="サンプル" data-type="input"></lightning-input>

handleClick(event) {
    const allValid = [
        ...this.template.querySelectorAll('[data-type="input"]')
    ].reduce((validSoFar, inputCmp) => {
        inputCmp.reportValidity(); // バリデーションがNGのとき、エラー内容を表示する
        return validSoFar && inputCmp.checkValidity(); // バリデーションNGの入力項目があった場合、falseを返す
    }, true);
    
    if (allValid) {
        // クライアントサイドのバリデーションがOKのときの処理
    } else {
        // バリデーションがNGのときの処理
    }
}
```

```javascript:使用例
// カスタムのバリデーションを設定する方法

validateCustom() {
    const cmp = this.template.querySelector('[data-field="AvailableDays__c"]');
    const str = this.form.AvailableDays__c
    if (str || == str.split(";").length <= 1) {
        cmp.setCustomValidity("2つ以上選択してください"); // エラーをセットする
    } else {
        cmp.setCustomValidity(""); // エラーをリセットする
    }
    return cmp.reportValidity(); // エラーを表示する
}
```

下記は実装例です。

```html:sampleInput.html
<template>
    <div class={formElementClass}>
        <!-- ラベル -->
        <label class={labelClass}>
            <template lwc:if={_required}>
                <abbr class="slds-required" title="必須">*</abbr>
            </template>
            {label}
        </label>

        <!-- ヘルプテキスト -->
        <lightning-helptext lwc:if={fieldLevelHelp} content={fieldLevelHelp}></lightning-helptext>

        <!-- フォーム -->
        <div class="slds-form-element__control">
            <!-- ★ここにフォームを実装する -->
        </div>

        <!-- エラーメッセージ -->
        <div lwc:if={_errorMessage} class="slds-form-element__help">{_errorMessage}</div>
    </div>
</template>

```

```javascript:sampleInput.js
@api
messageWhenValueMissing = "この項目を入力してください。";

_errorMessage;
_customValidityMessage;

@api
checkValidity() {
    // required以外の制限（minなど）を実装するは処理を追加します。
    const checkRequired = !(this._required && !this._value);
    const checkCustomValidate = !this._customValidityMessage;
    return checkRequired && checkCustomValidate;
}

@api
reportValidity() {
    if (this.checkValidity()) {
        this._errorMessage = "";
        return false;
    }
    // required以外の制限（minなど）を実装するは処理を追加します。
    this._errorMessage = this._customValidityMessage || this.messageWhenValueMissing;
    return true;
}

@api
setCustomValidity(message) {
    this._customValidityMessage = message;
}

@api
showHelpMessageIfInvalid() {
    this.reportValidity();
}
```

:::note
公開メソッドは親コンポーネントから子コンポーネントに情報を渡したり、操作するために使用します。- [Lightning Web Component 間の通信の基礎 - Qiita](https://qiita.com/TaaaZyyy/items/ec82c0f048440e904d5e#%E3%83%91%E3%83%96%E3%83%AA%E3%83%83%E3%82%AF%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89)

:::

## その他公開メソッド
blur、focusなどその他必要なメソッドを実装します。

## 画面フローでの使用

画面フローで使用する場合、validateメソッドを定義します。
また、`.js-meta.xml`ファイルに`<target>lightning__FlowScreen</target>`を追加する必要があります。

- [lightning__FlowScreen Target | Developers](https://developer.salesforce.com/docs/platform/lwc/guide/targets-lightning-flow-screen.html)
- [画面コンポーネントの検証方法 | Developers](https://developer.salesforce.com/docs/platform/lwc/guide/use-flow-validate-external-internal-methods.html)

## 発展
より汎用的に作成する場合は、下記のソースコードが参考になります。

- [salesforce/base-components-recipes](https://github.com/salesforce/base-components-recipes)
- [avonni/base-components-sfdx](https://github.com/avonni/base-components-sfdx)

## 参考
- [`<input>`: 入力欄（フォーム入力）要素 - HTML: ハイパーテキストマークアップ言語 | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Element/input#%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89)
- [lightning-input - documentation - Salesforce Lightning Component Library](https://developer.salesforce.com/docs/component-library/bundle/lightning-input/documentation)
- [Form Element - Lightning Design System](https://www.lightningdesignsystem.com/components/form-element/)





<!-- キャプチャ用 -->
<!--
```html
<template>
    <lightning-card title="①standard">
        <div class="slds-p-around_medium">
            <lightning-input label="standard" variant="standard" placeholder="standard"></lightning-input>
            <lightning-input label="standard" variant="standard" placeholder="standard"></lightning-input>
        </div>
    </lightning-card>
    <lightning-card title="②label-stacked">
        <div class="slds-p-around_medium">
            <lightning-input
                label="label-stacked"
                variant="label-stacked"
                placeholder="label-stacked"
            ></lightning-input>
            <lightning-input
                label="label-stacked"
                variant="label-stacked"
                placeholder="label-stacked"
            ></lightning-input>
        </div>
    </lightning-card>
    <lightning-card title="③label-inline">
        <div class="slds-p-around_medium">
            <lightning-input label="label-inline" variant="label-inline" placeholder="label-inline"></lightning-input>
            <lightning-input label="label-inline" variant="label-inline" placeholder="label-inline"></lightning-input>
        </div>
    </lightning-card>
    <lightning-card title="④label-hidden">
        <div class="slds-p-around_medium">
            <lightning-input label="label-hidden" variant="label-hidden" placeholder="label-hidden"></lightning-input>
        </div>
    </lightning-card>
</template>
```
-->