本記事では、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
を使いたかったので今回は遠回りしています。 ↩