ウェブコンポーネントとWAI ARIAの状態

LitElementでのJavaScriptプロパティとHTML属性の変換

#LitElement を触っていたら、プロパティと属性の変換をカスタマイズできることを知った(Properties - LitElement)。これを使えば、JavaScriptのプロパティとしてブール型を使いながら、DOM要素としてはWAI ARIAの状態属性にするのが簡単になるのではないかと思って、試してみた。

お題として、トグルボタンを作ることにした。HTMLではこんな感じ。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <style>
    body {
      padding-top: 6em;
      background: #fafafa;
      text-align: center;
    }
  </style>
  <script type="module" src="../toggle-button.js"></script>
</head>
<body>
  <toggle-button role=button tabindex=0>No aria-pressed at first</toggle-button>
  <toggle-button role=button tabindex=0 aria-pressed=false>Not pressed at first</toggle-button>
  <toggle-button role=button tabindex=0 aria-pressed=true>Pressed at first</toggle-button>
</body>
</html>

雰囲気で実装

attributeに文字列でHTML属性名を渡すと、その属性名に変換してくれる。 ここでは、JavaScriptでtoggleButton.pressed = trueとやるのとHTMLで<toggle-button aria-pressed="true"></toggle-button>とやるのとが同期されるようにする。

import { html, css, LitElement } from 'lit-element';

export class ToggleButton extends LitElement {
  static get styles() {
    return css`
      :host {
        padding: 0.5em 1em;
        background-color: #e8e8e8;
      }

      :host([aria-pressed="true"]) {
        background-color: #cccccc;
      }
    `;
  }

  static get properties() {
    return {
      pressed: { // JavaScriptでのプロパティ名
        attribute: 'aria-pressed', // HTMLでの属性名
        reflect: true // JavaScriptでのプロパティの変更を、HTML属性に反映させるかどうか
      }
    };
  }

  render() {
    return html`
      <span @click=${this.toggle}><slot></slot></span>
    `;
  }

  toggle(event) {
    this.pressed = ! this.pressed;
  }
}

toggle-button 大体よい。ただ、「Not pressed at first」ボタンの、最初の一回のクリックだけ、うまくいかない、aria-pressed"true"にならない。二回押すと"true"になる。それ以降はちゃんと、押す度に"true""false"が切り替わってくれる。

プロパティ値 <-> 属性値間のカスタムコンバーター

そこをちゃんとするためにコンバーターを定義してやったらうまくいく。

import { html, css, LitElement } from 'lit-element';

export class ToggleButton extends LitElement {
  // (snip)
  static get properties() {
    return {
      pressed: {
        attribute: 'aria-pressed',
        reflect: true,
        converter: {
          fromAttribute: (value) => {
            return value === 'true';
          },
          toAttribute: (value) => {
            return value ? 'true' : 'false';
          }
        }
      }
    };
  // (snip)
  }

素直に実装したい

ただ、最初の一回のために、こんなにたくさん書かないといけないのは、何となく不満だ。どうにかできないものか。問題は、初めてクリックする前、つまり初期化時に、HTMLに書いた属性を読み取ってくれないことだろうと思う。

初期化の時にプロパティがどうなっているか調べてみよう。converterを再び削除して、connectedCallback()でログを出してみる。

import { html, css, LitElement } from 'lit-element';

export class ToggleButton extends LitElement {
  // (snip)
  static get properties() {
    return {
      pressed: {
        attribute: 'aria-pressed',
        reflect: true
      }
    };
  }
  // (snip)
  connectedCallback() {
    super.connectedCallback();
    console.log(this.textContent);
    console.log(this.pressed, typeof this.pressed); // => false string
    console.log(this.getAttribute('aria-pressed'), typeof this.getAttribute('aria-pressed')); // false string
  }
  // (snip)
}

JavaScriptの方のプロパティ(this.pressed)も文字列になってる……。空じゃない文字列"false"はJavaScriptではtruthyだから、なるほど

  1. 最初はaria-pressed="false"this.pressed == true
  2. ここでクリックするとthis.pressed = falseになり、aria-pressed="false"として反映される
  3. クリック前も後もaria-pressed="false"に対してのCSSセレクターが使われ、変化がない。

ということか。

だったら初期状態でaria-pressed="false"なのをthis.pressed == falseとして解釈してやればいいんだな。

import { html, css, LitElement } from 'lit-element';

export class ToggleButton extends LitElement {
  // (snip)
  connectedCallback() {
    super.connectedCallback();
    this.pressed = this.getAttribute('aria-pressed') === 'true';
  }
  // (snip)
}

これでうまくいきました。

aria-pressedがない場合の扱い

ところで、aria-pressedが存在しない場合、そもそもデフォルトはどうなっているんだろう。定義を見てみる。

undefined (default) The element does not support being pressed.

そもそも「押す」という機能をサポートしない、つまりトグルボタンにならない……。これは、ユーザーがマークアップ時にaria-pressedを書かなかった場合に"true"にするか"false"にするか、このカスタムエレメントの場合を決めて、初期化処理を入れる必要があるということだな。今回は(そして多くの場合そうだろうが)、何もない場合はaria-pressed="false"として扱うことにする。

import { html, css, LitElement } from 'lit-element';

export class ToggleButton extends LitElement {
  // (snip)
  connectedCallback() {
    super.connectedCallback();
    if (! this.hasAttribute('aria-pressed')) {
      this.pressed = false;
    } else {
      this.pressed = this.getAttribute('aria-pressed') === 'true';
    }
  }
  // (snip)
}

これで、「No aria-pressed at first」ボタンも、自動的にaria-pressed="false"が付与されるようになった。

reflected-aria-attributes

これやっててふと思い出したんだけど、昔こうした機能を(ウェブコンポーネントじゃなくて)HTML要素に付与するNPMパッケージを作っていたなあ:reflected-aria-attributes

ソースコード

今日の試行錯誤の履歴(ソースコード)はこちら:KitaitiMakoto/lit-element-and-wai-aria-states