Angularでのボタンコンポーネントの作成

2023/12/25に公開

これはAngularアドベントカレンダー 2023の25日目の記事です。昨日はAKAIさんの記事でした。無事25日間のバトンパスが繋がって、主催としてとても嬉しいです。参加してくださったみなさんありがとうございました!

https://qiita.com/ver1000000/items/36154cb31a3bdba1a51d

https://qiita.com/advent-calendar/2023/angular

さて、この記事では web.dev に以前投稿された “Building a button component” という記事を参考にしてAngularでボタンコンポーネントを実装します。プレーンなHTMLとCSSだけで実装する例が元記事では紹介されていますが、Angularのコンポーネントとしてできるだけ自然なインターフェースで、UIコンポーネントとして再利用しやすくなるようにアレンジします。

https://web.dev/articles/building/a-button-component?hl=ja

準備

スタートラインは元記事に倣い、次のようにボタンを並べ、全体をレイアウトするCSSを用意します。元記事ではbodyタグの直下にボタンを並べていましたが、こちらでは代わりに App コンポーネントのスタイルでレイアウトしています。まだボタンとしてコンポーネント分割はしていません。

ボタンコンポーネントの作成

まずはボタンコンポーネントを作成します。 AwesomeButton コンポーネントは awesome-button 属性を持つbutton要素とinput要素にマッチする属性セレクタを設定します。汎用的なボタンコンポーネントを実装する際に避けるべきことは、コンポーネントのセレクタを要素セレクタにして、コンポーネントの内部にHTML標準のbuttonタグを隠蔽してしまうことです。

import { Component } from '@angular/core';

@Component({
  selector: `
  button[awesome-button],
  input[type=button][awesome-button],
  input[type=file][awesome-button]
  `,
  standalone: true,
  template: `<ng-content />`,
  host: {
    class: 'awesome-button',
  },
})
export class AwesomeButton {}

HTML標準のbutton要素を内包した独自のボタンコンポーネントは、HTML標準の要素でサポートされているさまざまな機能を再実装しなければならなくなります。大半はコンポーネントのInputを内部のbutton要素にバインディングし、button要素のイベントを自身のOutputとして投げ直すことになり、たいていは不完全な伝言ゲームをするだけになります。アプリケーションの中でbutton要素に特定の属性(aria-labelなど)を付与したくなるたびにそれをinputからリレーする必要があります。

ボタンコンポーネントを属性セレクタで実装すると、ボタンコンポーネントを使うテンプレート上にはbutton要素がそのまま存在しているため、ボタンコンポーネントがbutton要素の振る舞いを再現するためのコードはまったく不要になります。ボタンコンポーネントは標準のbutton要素に追加したい振る舞いだけを責任範囲とできるわけです。

<h4>Buttons</h4>
<button awesome-button>&#60;button&#62;</button>
<button awesome-button type="submit">&#60;button type=submit&#62;</button>
<button awesome-button type="button">&#60;button type=button&#62;</button>
<button awesome-button type="reset">&#60;button type=reset&#62;</button>

<h4>Button State</h4>
<button awesome-button disabled>&#60;button disabled&#62;</button>

<h4>Input Buttons</h4>
<input awesome-button type="button" value="<input type=button>" />
<input awesome-button type="file" />

作成したコンポーネントに最低限のスタイルを加えます。元記事と同じくopen-propsを使ってCSS変数を導入し、AwesomeButtonコンポーネントのスタイルを設定した状態で一段落です。

https://open-props.style/

ホバー・フォーカス時のスタイル

最初に手を加えるのは、マウスでホバーしたときとキーボード操作でフォーカスしたときの強調されたスタイルです。元記事では:isセレクタによって、ホバーとフォーカスに同じスタイルを与える書き方が紹介されています。同じようにコンポーネントスタイルを記述します。

ホスト要素に対して特定の条件のためのセレクタを加える場合は:host()セレクタの引数を使います。SCSSを使っている場合は:host セレクタの中で&:is のようにネストさせてもよいでしょう。

https://developer.mozilla.org/en-US/docs/Web/CSS/:host_function

:host(:is(:hover, :focus)) {
  cursor: pointer;
  color: var(--blue-0);
  background-color: var(--blue-5);
}

また、フォーカス時にアウトラインが少しアニメーションするCSSも加えます。prefers-reduced-motion メディア特性が設定されていないときに限り、outline-offsetをややずらします。アニメーションを減らしたい設定をしているユーザーにはアニメーションしないようになります。

https://developer.mozilla.org/ja/docs/Web/CSS/@media/prefers-reduced-motion

@media (prefers-reduced-motion: no-preference) {
  :host(:focus) {
    transition: outline-offset 0.25s ease;
  }
  :host(:focus:not(:active)) {
    outline-offset: 5px;
  }
}

これでフォーカスとホバーの状態が視覚的に判別しやすくなりました。

カラースキームへの対応

次は、ブラウザのカラースキーム設定に応じてライトテーマとダークテーマが切り替わるようにします。元記事と同じように、prefers-color-schemeメディア特性に応じてCSS変数の値を切り替えることで実現します。コンポーネントスタイルでもCSS変数の宣言はできます。:hostセレクタの中で宣言すればそのコンポーネントスタイル中ではどこでも間違いなく参照できます。

:host {
  --_bg-light: white;
  --_bg-dark: black;
  --_bg: var(--_bg-light);

  background-color: var(--_bg);
}

@media (prefers-color-scheme: dark) {
  :host {
    --_bg: var(--_bg-dark);
  }
}

ただしコンポーネントスタイルでCSS変数を使う場合は、CSS変数が階層的なスコープを持つことに注意する必要があります。CSS変数のスコープはこのコンポーネントのテンプレート内に閉じず、DOMツリー上でこのコンポーネントの子孫にあたる要素もCSS変数を参照できます。それが便利な場面も多いですが、名前の衝突や意図せぬ上書きについての注意は必要です。

また、ここで今後のステップにそなえてコンポーネントのセレクタも修正します。コンポーネント側ではinput要素のtype=resettype=submitにも対応します。

@Component({
  selector: `
  button[awesome-button],
  input[type=button][awesome-button],
  input[type=submit][awesome-button],
  input[type=reset][awesome-button],
  input[type=file][awesome-button],
  `,
  standalone: true,
  template: `<ng-content />`,
  styleUrl: './button.component.css',
  host: {
    class: 'awesome-button',
  },
})
export class AwesomeButton {}

また、ファイル選択ボタンに適切なスタイルを与えるため、いままで:hostセレクタに一律で与えていたスタイルを修正します。元記事と同じように、input[type=file]の場合にはホスト要素ではなくその::file-selector-button疑似要素をボタンとしてのスタイリング対象にするため、次のようにセレクタを2つに分割します。 CSS変数の宣言については:host要素に残しています。

https://developer.mozilla.org/en-US/docs/Web/CSS/::file-selector-button

:host {
  --_bg-light: white;
  --_bg-dark: black;
  --_bg: var(--_bg-light);
}

:host(:where(button, input[type='button'], input[type='submit'], input[type='reset'])),
:host(:where(input[type='file'])::file-selector-button) {
  ...
}

スタイルの変更

ここまでのボタンコンポーネントのスタイルは常に同じでしたが、ボタンの種類や状態に応じて切り替わるように変更します。元記事と同じように、必要なCSS変数を一通り宣言し、各種スタイルに適用します。ほぼ元記事と同じことをするだけなのでコードは割愛します。気になる方はStackblitzで確認してください。

特筆すべき点として、ボタンがtype=submitである場合には強調されたスタイルになるようにします。この際、form要素の中でtype属性が指定されていないbutton要素もtype=submitとみなされます。このような場合、コンポーネントのホスト要素に対してその祖先側の条件を指定するために:host-contextセレクタを使うことができます。この例では、祖先のどこかにform要素があり、かつ自身がtype属性もdisabled属性も持たないbutton要素であるという条件を記述しています。

https://developer.mozilla.org/en-US/docs/Web/CSS/:host-context

/* Customizing submit buttons */
:host(:where([type='submit'])),
:host-context(form) :host(button:not([type], [disabled])) {
  --_text: var(--_accent);
}

また、ボタンコンポーネントにマウスカーソルが重なったときにはインタラクション可能であることをユーザーに伝えますが、元記事ではcursor: pointerだけでなく、touch-action: manipulationもセットしています。これにより、ユーザーがダブルタップなどしたときにデバイス側でのズーム機能などが反応してしまうことを防げるようです。

https://developer.mozilla.org/ja/docs/Web/CSS/touch-action#manipulation

:host(
    :where(
        button,
        input[type='button'],
        input[type='submit'],
        input[type='reset']
      )
  ),
:host(:where(input[type='file'])::file-selector-button) {
  cursor: pointer;
  touch-action: manipulation;
}

次のサンプルコードは以上の作業を終えた状態です。

ボタンのバリアント

最後に、ボタンコンポーネントに特定のパラメータを与えることでバリアントを切り替えられるようにします。元記事と同じように、customlargeの二種類を追加します。

まずは、<button awesome-button color="custom"> のように、colorインプットに対してcustomという値が渡されたときにスタイルをカスタマイズします。既定値はdefaultとし、colorプロパティの値をdata-color属性にバインディングすることでCSSセレクタからアクセスできるようにします。

export type AwesomeButtonColor = 'custom' | 'default';

@Component({
  selector: `
  button[awesome-button],
  input[type=button][awesome-button],
  input[type=submit][awesome-button],
  input[type=reset][awesome-button],
  input[type=file][awesome-button],
  `,
  standalone: true,
  template: `<ng-content />`,
  styleUrl: './button.component.css',
  host: {
    class: 'awesome-button',
    '[attr.data-color]': 'color',
  },
})
export class AwesomeButton {
  @Input() color: AwesomeButtonColor = 'default';
}

そしてボタンコンポーネントのスタイルでdata-color属性の値に応じてCSS変数を切り替えます。これで完了です。

/* Variants */
:host(:where([data-color='custom'])) {
  --_bg: linear-gradient(hsl(228 94% 67%), hsl(228 81% 59%));
  --_border: hsl(228 89% 63%);
  --_text: hsl(228 89% 100%);
  --_ink-shadow: 0 1px 0 hsl(228 57% 50%);
  --_highlight: hsl(228 94% 67% / 20%);
}

次に、ボタンの大きさに関するバリアントとして <button awesome-button size="large"> という使い方ができるようにします。customバリアントの例と同じように、sizeインプットを追加してdata-size属性にバインディングします。

次のコードが最終的な完成形です。

まとめ

ボタンコンポーネントの実装を通して、AngularでUIパーツとしてコンポーネントを作る際のちょっとしたテクニックを紹介してみました。誰かの役に立てば幸いです。いままで使ったことのなかったCSSの機能も知れて自分の収穫もありました。

今回の例ではinput[type=file]の特殊ケースを扱うことでCSSは少し複雑になりましたが、ネスト構文などを使えばもう少し整理されたCSSにできそうに思います。ただCSS変数の数がすごく多いので、変数管理のあたりは実用的にはまだまだ改善しなければならないですね。

Angular Materialもそうですが、CSS変数がいよいよ本格的にUIコンポーネント設計の中で考慮すべきものとして普及してきているように感じています。来年はもっとCSS変数を活用して上手にコンポーネントのスタイリングを実装していきたいものです。

Discussion