本記事では、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.changesをoutputToObservableでObservableにし、イベントが流れてくる度に、対象以外のToggleButtonのdeselectを呼ぶことで実現します。
-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では、selectedをinputで受け取り、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.selected を input から 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を使っていきましょう。
-
本来は
<app-toggle-button [value]="option">にしたほうが良いですが、viewChildを使いたかったので今回は遠回りしています。 ↩