Angular forms: チェックボックスを使ってコントロールを無効にする

それを追加するためのディレクティブをどう書くか

そしてなぜそうすべきではないか

もし、コーダーとして、手順を自動化しようとしたけど、実際に必要だった数回を手作業で書き直したほうが資源の1/1000で済むことに気がつくほどの無駄な時間を費やしたことがなかったら、、、
嘘つきです!!!

正直に言いましょう:最初はみんな、_早すぎる最適化_という罠にはまったものです。これは全ての悪の根源であり、この使い古されたxkcdのコミックで完璧に要約されています:
xkcd salt long run
(わかっています、これは早すぎる最適化問題の「ニュアンス」の一つに過ぎませんが、今回はそれについて話を進めましょう)。

この記事で紹介するのは、そのような状況の例です:比較的頻繁に必要になるもので、DOMツリーにかなりの変更を加える必要があるもの。実際の使用シナリオでは、それ自体が十分に複雑なツールでスタイリングされていて、大量の妥協点と回避策が努力に見合っているとは感じられないでしょう。
そこで、ユーザーがコントロールを無効にできるようにチェックボックス要素をフォームコントロールに追加するディレクティブの実装を装って、Angularのベーシックなフレームワークの使用を少し超えたプロジェクトの操作を試みるツールを探索しましょう。


役に立たないハーネス

この最初の記事で例として取り上げるのは、いかにも普通のHTMLフォームで、スタイリングされていない平凡なコントロールで、それらがネイティブの<hr>タグで区切られています(リストさえ使っていません)。
これは日常のコーディングで遭遇することはありませんが(ひどい1990年代初期のコードベースに関わる幸運な人を除いて)、私たちの目標のために発行される汎用技術に注目しやすくするためです。

左側に私たちのシンプルなフォームがあり、右側にテンプレートの各コントロールにディレクティブを適用するだけで達成したいものがあります(フォーム自体にディレクティブを適用するように設計し、子コントロールのそれぞれにロジックを発行することもできますが、さらにシンプルにしておきましょう)。

plain forms before after
こちらがそのテンプレートです

<form [formGroup]="plainForm" (ngSubmit)="showSubmitObject()">
    <label>テキストコントロール</label><br>
    <input formControlName="text" selectablePlain><hr>
    <label>数値コントロール</label><br>
    <input formControlName="number" type="number" selectablePlain><hr>
    <label>ラジオコントロール</label><br>
    <div>
        <label for="yes">はい</label>
        <input formControlName="radio" type="radio" value="yes" id="yes">
        <label for="no">いいえ</label>
        <input formControlName="radio" type="radio" value="no" id="no" selectablePlain>
    </div><hr>
    <label>範囲コントロール</label><br>
    <input formControlName="range" type="range" selectablePlain><hr>
    <label>単一選択コントロール</label><br>
    <select formControlName="singleSel" selectablePlain>
        <option value="">--オプションを選んでください--</option>
        <option value="dog">犬</option>
        <option value="cat">猫</option>
        <option value="hamster">ハムスター</option>
    </select><hr>
    <label>複数選択コントロール</label><br>
    <select formControlName="multiSel" multiple selectablePlain>
        <option value="">--複数のオプションを選んでください--</option>
        <option value="dog">犬</option>
        <option value="cat">猫</option>
        <option value="hamster">ハムスター</option>
    </select><hr>
    <br>
    <button>送信</button>
</form>

フルスクリーンモードを出る

各コントロールには、通常のformControlNameディレクティブが適用されていて、コード内の対応するFormControlにバインドされていますが、selectablePlainという私たちのディレクティブのセレクタも適用されています。
特に変わったことはありません、私は最も一般的なタイプのコントロールを使いました:テキスト、数値、ラジオ、範囲、セレクト、マルチセレクト。
そしてこちらがそのバインディングコードです

export class PlainControlsComponent {

  plainForm = new FormGroup({
    text: new FormControl(''),
    number: new FormControl(0),
    radio: new FormControl('no'),
    range: new FormControl(0),
    singleSel: new FormControl(''),
    multiSel: new FormControl([]),
  });
}

フルスクリーンモードを出る

私たちのゴールを視覚化する

私たちのディレクティブの予想される機能を2つのタスクに分けることができます。

  • DOMの操作: テンプレートのコントロール要素の横に<input type="checkbox">ノードを追加するべきです
  • モデルの変更: _formControls_は、私たちのチェックボックスとのユーザーインタラクションによって有効/無効にされるべきです

後者は比較的簡単です。なぜなら、基本的にはどのJS/TSフレームワークのフォームインタラクションパッケージも使用することが想定されているからです。しかし、最初のタスクであるDOM操作は、わざと限られたこの特定のテストケースで全ての難しい側面は置いておいても、それを実装するためのより複数の問題を提示するかもしれません。

コードを見せてください

デコレーターの定義とコンストラクターインジェクション:

@Directive({
  selector: '[selectablePlain]',
  standalone: true,
})
export class SelectablePlainDirective {

  constructor(
      private renderer: Renderer2,
      private hostEl: ElementRef,
      private ctrl: NgControl
  )

フルスクリーンモードを出る

テンプレートでselectablePlainとして使用されるセレクターを設定しました。特に、このディレクティブをstandaloneとして宣言しています。これはAngularの世界の新しい流行です(Angular v14まで必須だった_NgModule_で宣言しても何も変わりませんでした)。
そして、私たちのロジックを実行できるいくつかの依存関係を注入します:

  • Renderer2: WebAPIのDocumentNodeインターフェースの上にあるAngularの抽象化であり、低レベルでプログラムによるDOMエレメントの操作を安全に行ういくつかの方法を提供しています。
  • ElementRef: このクラスをディレクティブに注入することで、ホスティングしている要素の参照にアクセスすることができます。実際には、ディレクティブが適用されているDOMエレメント(<input><select>など)をコード内でラッピングするオブジェクトを取得します。
  • NgControl: このクラスは、NgModelFormControlDirectiveFormControlNameの共通の祖先です。つまり、特定のサブクラスの代わりにこれを注入することで、ディレクティブをもっと汎用的で、ユーザーがテンプレートに選んだ特定のForm実装に依存しないものにします(私たちが各コントロールにformControlNameディレクティブをバインドしていたので、FormControlNameクラスを注入しても同じ結果になります)。

コンストラクターボディ

this.checkBox = this.renderer.createElement('input');
this.renderer.setAttribute(this.checkBox, 'type', 'checkbox');

フルスクリーンモードを出る

コンストラクター内で、_Renderer2_インスタンスを使用して追加する要素を作成します。生成されるタグの名前だけを引数に取る_createElement_メソッドが見られます。
その後、_setAttribute_メソッドを利用して、type="checkbox"をこの作成されたばかりの<input>要素に割り当てます。

this.renderer.insertBefore(
    this.renderer.parentNode(this.hostEl.nativeElement),
    this.checkBox,
    this.renderer.nextSibling(this.hostEl.nativeElement)
);

フルスクリーンモードを出る

少し複雑に見えるこの呼び出しは、実際には存在しない_insertAfter_メソッドの代わりです。
注入された_ElementRef_を利用して、そのnativeElementプロパティに保存されているホスティング要素を取得し、それを_insertBefore_へのいくつかの呼び出しの引数として使用します。
このメソッドは、新しいノードをターゲットノードの前の兄弟として挿入し、3つの引数を期待しています:

  • ターゲットノードの親
  • 挿入される新しいノード
  • 新しいノードを挿入する前のターゲットノード

最後のもののコツは、ホスティング要素の次の兄弟をターゲットノードとして渡すことで、実際には新しい要素をホスティング要素とその次の兄弟(存在する場合)の間に挿入することになります。

this.renderer.listen(
    this.checkBox,
    'change', 
    () => this.ctrl.disabled ? this.ctrl.control?.enable() : this.ctrl.control?.disable()
);

フルスクリーンモードを出る

最後に、私たちの真の目的であるものを行うためにチェックボックスのchangeイベントにリスナーを添付します:コントロールがすでに無効にされている場合、チェックボックスのクリックは有効化メソッドを呼び出すべきであり、それ以外の場合は無効化します。

遅い設定のためのngOnInit

このオープンチケットが示すように、NgControl.controlは_OnChange_ライフサイクルの後にのみ入力されるため、コンストラクターでは実行時のコントロール値に関連するすべての_NgControl_プロパティは未定義です。
それが私たちのロジックの一部を_ngOnInit_ライフサイクルコールバック内に置く必要がある理由です。

if (this.ctrl.control?.enabled) 
  this.renderer.setProperty(this.checkBox, 'checked', true);

フルスクリーンモードを出る

ここでは初期のチェックボックスの状態を設定しています。当初無効にされていたコントロールに関連するチェックボックスがオンになっているように見えてほしくないし、逆も同様です。
ですから、コントロールの初期状態を確認し、チェックボックスのcheckedプロパティをそれに応じて設定します。



こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/this-is-angular/angular-forms-checkbox-disabling-controls-29np