先日 Angular Meetup feat. Miles Malerba に参加して、GoogleのAngular開発チームの Miles Malerba さんと話せる貴重な機会がありました。
以前うまく出来なかった「CdkListboxを利用してComboboxの実装を楽に出来ないか?」という疑問をざっくりと質問して、知見を得ることが出来たので改めて自作してみました。
はじめに
この記事ではW3Cが紹介しているCombobox Select Onlyのようなコンポーネントを実装しています。
また、あくまで触りだけなので実用的なComboboxを実装するには他にも考慮すべき点がいくつもあります。
実際に動いているサンプルはこちらです。
Combobox実装のために必要なもの
Comboboxはよく見るUIコンポーネントの中でも比較的複雑で、テキスト入力のないSelect Onlyな場合でも難易度が高めなので無理に自作せず、ライブラリにおまかせしていることも多いのではないでしょうか?
汎用的なコンポーネントを作るとなるとより難易度が上がり出来上がったときには大作になっていること間違いなしです。
実際にComboboxを実装する上で必要な機能は大まかに「フォーム要素としての対応」・「ポップアップの表示制御」・「選択状態の制御」・「キーボードによる操作支援」です。
この内フォーム要素としての対応はComboboxに限った話ではないので割愛しますが、それ以外の3つに関して少し掘り下げていきます。
ポップアップの表示制御
ポップアップやダイアログはAngular CDKを使っている方はお馴染みであろうCdkOverlayを使います。
最近はCdkMenuやCdkDialogなど便利な物も増えてきていますが、まだまだ使い所はたくさんあります。
今回はトリガーとなる要素も同じコンポーネントに含めるのでCdkConnectedOverlayを利用します。
@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, OverlayModule],
  template: `
  <button
    cdkOverlayOrigin
    #origin="cdkOverlayOrigin"
    (click)="opened.set(true)"
  >開く</button>
  <ng-template
    cdkConnectedOverlay
    [cdkConnectedOverlayOrigin]="origin"
    [cdkConnectedOverlayOpen]="opened()"
    cdkConnectedOverlayHasBackdrop
    cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
    (backdropClick)="opened.set(false)"
  >
    <p>ポップアップ要素</p>
  </ng-template>
  `,
})
export class App {
  opened = signal(false);
}
これでポップアップを表示する基礎が出来上がりました。ほんの数行でポップアップが実装できるCdkConnectedOverlayは本当に便利。
選択状態の制御とキーボードによる操作支援
今までなら選択状態を制御するためにSelectionModelを使ったり、キーボードによる操作支援のためにListKeyManagerを利用して複雑なキーボード操作を実現しているところですが、今回はCdkListboxを使います。
CdkListboxはW3CのListbox Patternの実装を助けるために作成されているので内部的にSelectionModelとListKeyManagerを持っているので面倒な制御をまるっとお願い出来ます。
CdkListboxも非常に簡単利用できるのであっさりと選択状態の制御とキーボード操作が実装できます。
// CdkListboxのValueは配列を受け取る
readonly selected = signal([]);
readonly options = ['First', 'Second', 'Third'];
// ListboxのValueが変更されたら、selectedに反映しつつポップアップを閉じる
valueChanged(event: ListboxValueChangeEvent<string>) {
  this.selected.set([...event.value]);
  this.opened.set(false);
}
<button
  cdkOverlayOrigin
  #origin="cdkOverlayOrigin"
  (click)="opened.set(true)"
>{{ selected() }}</button>
<!-- Listboxの操作(ESCなど)を考慮して detach時にもopendedをfalseにする -->
<ng-template
  cdkConnectedOverlay
  [cdkConnectedOverlayOrigin]="origin"
  [cdkConnectedOverlayOpen]="opened()"
  cdkConnectedOverlayHasBackdrop
  cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
  (backdropClick)="opened.set(false)"
  (detach)="opened.set(false)"
>
  <div>
    <ul
      cdkListbox
      cdkListboxUseActiveDescendant
      [cdkListboxValue]="selected()"
      (cdkListboxValueChange)="valueChanged($event)"
    >
      <li *ngFor="let option of options" [cdkOption]="option">{{ option }}</li>
    </ul>
  </div>
</ng-template>
これでListboxを使ったSelect OnlyなComboboxの実装はほとんど完成です。
(あまりにも簡単すぎてSelectModelとListKeyManagerと戦ったあの苦労は何だったんだと・・・。)
Focusを制御してより自然な体験を作る
実は今のままでボタンをクリックしてもListboxにフォーカスが当たらないのでキーボード操作などは一切できません。また、ESC等でポップアップを閉じた場合にボタンにフォーカスが戻ることもないです。
最後にこれらを少しだけ調整して今回は終わりたいと思います。
@ViewChild(CdkOverlayOrigin, { read: ElementRef })
trigger!: ElementRef<HTMLButtonElement>;
@ViewChild(CdkListbox) listbox?: CdkListbox;
valueChanged(event: ListboxValueChangeEvent<string>) {
  this.selected.set([...event.value]);
  
  // 値を変更後にポップアップを閉じてButtonにフォーカスさせる
  this.closeAndFocusToTrigger();
}
// ポップアップがデタッチされたタイミングでButtonにフォーカスする
closeAndFocusToTrigger() {
  this.opened.set(false);
  this.ngZone.onStable.pipe(first()).subscribe(() => {
    this.trigger.nativeElement.focus();
  });
}
// ポップアップがアタッチでListboxにフォーカスする
focusToListbox() {
  this.ngZone.onStable.pipe(first()).subscribe(() => {
    this.listbox?.focus();
  });
}
<ng-template
  cdkConnectedOverlay
  [cdkConnectedOverlayOrigin]="origin"
  [cdkConnectedOverlayOpen]="opened()"
  cdkConnectedOverlayHasBackdrop
  cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
  (backdropClick)="opened.set(false)"
  (attach)="focusToListbox()"
  (detach)="closeAndFocusToTrigger()"
>
  ...
</ng-template>
これでポップアップの開閉時にフォーカスが移動するように出来ました。
完成したのソースコードものせておきます。
@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, OverlayModule, CdkListboxModule],
  template: `
  <button
    cdkOverlayOrigin
    #origin="cdkOverlayOrigin"
    (click)="opened.set(true)"
  >{{ selected() }}</button>
  <ng-template
    cdkConnectedOverlay
    [cdkConnectedOverlayOrigin]="origin"
    [cdkConnectedOverlayOpen]="opened()"
    cdkConnectedOverlayHasBackdrop
    cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
    (backdropClick)="opened.set(false)"
    (attach)="focusToListbox()"
    (detach)="closeAndFocusToTrigger()"
  >
    <div class="example-listbox-container">
      <ul
        class="example-listbox"
        cdkListbox
        cdkListboxUseActiveDescendant
        [cdkListboxValue]="selected()"
        (cdkListboxValueChange)="valueChanged($event)"
      >
        <li *ngFor="let option of options" [cdkOption]="option">{{ option }}</li>
      </ul>
    </div>
  </ng-template>
  `,
})
export class App {
  private readonly ngZone = inject(NgZone);
  readonly opened = signal(false);
  readonly selected = signal(['First']);
  readonly options = ['First', 'Second', 'Third'];
  @ViewChild(CdkOverlayOrigin, { read: ElementRef })
  trigger!: ElementRef<HTMLButtonElement>;
  @ViewChild(CdkListbox) listbox?: CdkListbox;
  valueChanged(event: ListboxValueChangeEvent<string>) {
    this.selected.set([...event.value]);
    this.closeAndFocusToTrigger();
  }
  closeAndFocusToTrigger() {
    this.opened.set(false);
    this.ngZone.onStable.pipe(first()).subscribe(() => {
      this.trigger.nativeElement.focus();
    });
  }
  focusToListbox() {
    this.ngZone.onStable.pipe(first()).subscribe(() => {
      this.listbox?.focus();
    });
  }
}
おわりに
今回はCdkListboxを利用してComboboxのようなものを作ってみましたが、実はこのまま進めて実用的なComboboxが実装できるかはわかっていないです。
ListKeyManagerは内部的に隠蔽されているため細かい設定を変更することが出来ないこと。 cdkListboxValueChangeは同じ値を選択しても発火しないので、実は this.listbox.valueChangeで制御しないといけないこと。等解決すべき問題はまだまだあります。
それでもAngular CDKの可能性を改めて感じることが出来て実装中はなかなか楽しかったです。
文頭にものせましたが、改めてサンプルをここにも置いておきます。