5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Toggle ButtonをAngular Signalで実装する

Last updated at Posted at 2024-03-14

本記事では、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>で利用します。

  @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 {}
  @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 {}
  @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を利用します。

 @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();
+  }
+}

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

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

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

 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.changesoutputToObservableでObservableにし、イベントが流れてくる度に、対象以外のToggleButtondeselectを呼ぶことで実現します。

-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

     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では、selectedinputで受け取り、AfterViewInitで初期値とToggleButton.valueが一致するものを選択状態にします。

-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も忘れずに設定しましょう。

   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.selectedinput から model に変更し、トグルボタンが選択される度に値を更新するようにします。

   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 に変更することで、トグルボタンをクリックする度に値が変更されるようになりました。

   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プルリクエストを見ていて気になったoutputToObservableの使いどころはここかなーと思って遊び始めたのが、Signal Queriesの使い心地も確かめることが出来て、とても満足しました。

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

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

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?