Toggle ButtonをAngular Signalで実装する


:::note warn
本記事では、Developer Previewの機能を多数使用しています。今後、これらの機能の利用方法が変更される可能性があります。
:::


## はじめに

Angular v17.3で`output`がリリースされたことで、Angularのクラス内で使われているデコレータの大半をSignalに書き換えられるようになりました。

本記事では、トグルボタンの実装を例に、v17で強化されたSignalの機能を網羅的に使うように実装してみました。

※ 先に完成したものも置いておきます。
https://stackblitz.com/edit/stackblitz-starters-duwa8r?file=src%2Fmain.ts

## まずは見ためを整える

Angular Materialの`<mat-button-toggle>`のようなコンポーネントを自作するイメージで、`<app-toggle-button>`と`<app-toggle-button-group>`を作成し、`<app-root>`で利用します。

```typescript
  @Component({
    selector: 'app-toggle-button',
    standalone: true,
    host: {
      class:
        'inline-block border-none px-2 py-0.5 cursor-pointer text-slate-500 aria-selected:bg-indigo-500/10 aria-selected:text-indigo-500',
    },
    template: `<ng-content />`,
  })
  export class ToggleButton {}
```

```typescript
  @Component({
    selector: 'app-toggle-button-group',
    standalone: true,
    imports: [ToggleButton],
    host: {
      class:
        'border border-slate-300 divide-x divide-solid divide-slate-300 rounded-md overflow-hidden inline-block',
    },
    template: `<ng-content />`,
  })
  export class ToggleButtonGroup {}
```

```typescript
  @Component({
    selector: 'app-root',
    standalone: true,
    imports: [ToggleButtonGroup, ToggleButton],
    template: `
      <app-toggle-button-group>
        @for(option of options; track $index) {
          <app-toggle-button>{{ option }}</app-toggle-button>
        }
      </app-toggle-button-group>
      
      <p>value: {{ value }}</p>
    `,
  })
  export class App {
    readonly options = ['Option 1', 'Option 2', 'Option 3'];

    value = 'Option 1';
  }
```

## ToggleButton を選択できるようにする

ボタンをクリックしたら選択状態になるようにします。ここでは選択状態の解除は考えず、未選択のボタンを選択状態にできるようにします。また、状態管理には`signal`を利用します。

```diff_typescript
 @Component({
   selector: 'app-toggle-button',
   standalone: true,
   host: {
+    '[attr.aria-selected]': 'selected()',
     class:
       'inline-block border-none px-2 py-0.5 cursor-pointer text-slate-500 aria-selected:bg-indigo-500/10 aria-selected:text-indigo-500',
   },
   template: `<ng-content />`,
 })
-export class ToggleButton {}
+export class ToggleButton {
+  readonly selected = signal(false);
+
+  select() {
+    this.selected.set(true);
+  }
+
+  @HostListener('click')
+  handleClick() {
+    this.select();
+  }
+}
```

## 選択中のボタンを一つにする

トグルボタンは複数の選択肢を選べるような場合もありますが、今回は簡単のため選べるのは１つのみとします。

まずは `output`を利用して`ToggleButton`の選択状態を親コンポーネントに通知します。
`ToggleButtonGroup`で利用する`deselect`も合わせて追加しておきます。

```diff_typescript
 export class ToggleButton {
   readonly selected = signal(false);

+  readonly changes = output<ToggleButton>();
+
   select() {
     this.selected.set(true);
+    this.changes.emit(this);
+  }
+
+  deselect() {
+    this.selected.set(false);
   }

   @HostListener('click')
```

`ToggleButtonGroup`では`contentChildren`を利用して`ToggleButton`の一覧を取得します。

`effect`内で`ToggleButton.changes`を`outputToObservable`でObservableにし、イベントが流れてくる度に、対象以外の`ToggleButton`の`deselect`を呼ぶことで実現します。

```diff_typescript
-export class ToggleButtonGroup {}
+export class ToggleButtonGroup {
+  private readonly destroyRef = inject(DestroyRef);
+
+  private readonly children = contentChildren(ToggleButton, {
+    descendants: true,
+  });
+
+  constructor() {
+    effect(
+      () => {
+        const children = this.children();
+
+        // ToggleButton.changesのObservableを一つのObservableにまとめる
+        merge(...children.map((child) => outputToObservable(child.changes)))
+          .pipe(takeUntilDestroyed(this.destroyRef))
+          .subscribe((child) => {
+            children.forEach((c) => {
+              // 対象以外のToggleButtonは非選択状態にする
+              if (c !== child) {
+                c.deselect();
+              }
+            });
+          });
+      },
+      { allowSignalWrites: true }
+    );
+  }
+}
```

## 初期値を反映させる

`ToggleButton`の`<span #content>`で`<ng-content>`をラップし、`viewChild`で要素を取得可能にし、`computed`を利用して要素の値を取得する方法で値を取得します[^1]。

```diff_typescript
     class:
       'inline-block border-none px-2 py-0.5 cursor-pointer text-slate-500 aria-selected:bg-indigo-500/10 aria-selected:text-indigo-500',
   },
-  template: `<ng-content />`,
+  template: `<span #content><ng-content /></span>`,
 })
 export class ToggleButton {
   readonly selected = signal(false);

+  private readonly content = viewChild<ElementRef<HTMLSpanElement>>('content');
+
+  readonly value = computed(
+    // #contentのtextContentをvalueとして持っておく。
+    () => this.content()?.nativeElement.textContent ?? ''
+  );
+
   readonly changes = output<ToggleButton>();

   select() {
```

`ToggleButtonGroup`では、`selected`を`input`で受け取り、`AfterViewInit`で初期値と`ToggleButton.value`が一致するものを選択状態にします。

```diff_typescript
-export class ToggleButtonGroup {
+export class ToggleButtonGroup implements AfterViewInit {
   private readonly destroyRef = inject(DestroyRef);

+  readonly selected = input.required<string>();
+
   private readonly children = contentChildren(ToggleButton, {
     descendants: true,
   });
@@ -79,6 +92,14 @@
       { allowSignalWrites: true }
     );
   }
+
+  ngAfterViewInit() {
+    this.children().forEach((child) => {
+      if (child.value() === this.selected()) {
+        child.select();
+      }
+    });
+  }
 }
```

Input Signalに初期値を渡すため、`value`も忘れずに設定しましょう。

```diff_typescript
   standalone: true,
   imports: [ToggleButtonGroup, ToggleButton],
   template: `
-    <app-toggle-button-group>
+    <app-toggle-button-group [selected]="value">
       @for(option of options; track $index) {
         <app-toggle-button>{{ option }}</app-toggle-button>
       }
```

## Model Inputを利用して値を相互バインディングさせる

最後は選択中の ToggleButton の値を ToggleButtonGroup 経由で `App.value` に反映させます。

17.2 で相互バインディングが簡単にできる `model`が追加されたので、 `ToggleButtonGroup.selected` を `input` から `model` に変更し、トグルボタンが選択される度に値を更新するようにします。

```diff_typescript
   export class ToggleButtonGroup implements AfterViewInit {
   private readonly destroyRef = inject(DestroyRef);

-  readonly selected = input.required<string>();
+  readonly selected = model.required<string>();

   private readonly children = contentChildren(ToggleButton, {
     descendants: true,
@@ -85,6 +85,8 @@
             children.forEach((c) => {
               if (c !== child) {
                 // 対象以外のToggleButtonは非選択状態にする
                 c.deselect();
+              } else {
+                // 対象の場合は selected を更新する
+                this.selected.set(child.value());
               }
             });
           });
```

Model Signalになったので、 `App.value` も Banana-in-a-box に変更することで、トグルボタンをクリックする度に値が変更されるようになりました。

```diff_typescript
   standalone: true,
   imports: [ToggleButtonGroup, ToggleButton],
   template: `

```diff_typescript
   standalone: true,
   imports: [ToggleButtonGroup, ToggleButton],
   template: `
-    <app-toggle-button-group [selected]="value">
+    <app-toggle-button-group [(selected)]="value">
       @for(option of options; track $index) {
       <app-toggle-button>{{ option }}</app-toggle-button>
       }
```

これで実装は完了です。

## おわりに

`output`の[プルリクエスト](https://github.com/angular/angular/pull/54650)を見ていて気になった`outputToObservable`の使いどころはここかなーと思って遊び始めたのが、Signal Queriesの使い心地も確かめることが出来て、とても満足しました。

改めてAngularの進化とその便利さを実感できてこれからも使い続けるモチベーションになりました。
この記事を読んでくれた方も、ぜひ積極的にSignalを使っていきましょう。

[^1]: 本来は`<app-toggle-button [value]="option">`にしたほうが良いですが、 `viewChild`を使いたかったので今回は遠回りしています。