Open12

Custom Elements に aria-* 属性をつけることを許す場合の実装

たとえばアイコンライブラリを Custom Element として実装しているとする(以下 <app-icon> )。

<app-icon> のアクセシビリティを確保するため、次のように書けるようにしたい。

<app-icon type="plus" size="16" aria-label="アイテムを追加">
<app-icon type="plus" size="16" aria-labelledby="add-new-item">
<div id="add-new-item">アイテムを追加</div>

なお <app-icon> は Shadow Root を持っているとする(ここでは暗に lit-html を使っている)

class AppIcon extends HTMLElement {
  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.render()
  }

  render() {
    return html`
      <div>
        <svg> ... </svg>
      </div>
    `
  }
}

この時、Shadow DOM ツリー内にある要素に aria 属性を「フォワードする」行為に意味はあるか?(あるいは必要か?)が気になったポイント。

  get ariaLabel() {
    return this.getAttribute('aria-label') ?? this.getAttribute('type') ?? ''
  }

  render() {
    return html`
      <!-- これ ↓ をやることに意味がある? -->
      <div role="img" aria-label="${this.ariaLabel}">
        <svg> ... </svg>
      </div>
    `
  }

React の癖で考えると props として受け取れるのに中で使わないなんて変だと考えられるが、Custom Element の aria 属性は別に :host にさえついてれば何も問題ないような気もする。

スクリーンリーダーが Shadow DOM 内の aria 属性を読み上げないのだとしたら書いても書かなくても良いし、読み上げる場合は二重に label を書いてる状態になってしまいそうに見える。

どうするのが良いのか?

React の癖で考えると props として受け取れるのに中で使わないなんて変だ

いや、HTMLElement を継承している時点で HTMLElement で受け取れる属性は(たとえ使わなくとも)全部受け取れて当然というべきなのか

Custom Elements で実装されているアイコンライブラリとしては Ionicon がある。
これは Stencil を使ってるので Stencil 特有の機能を読み解く必要があるが、こんな実装になっている

https://github.com/ionic-team/ionicons/blob/master/src/components/icon/icon.tsx#L32
  render() {
    const mode = this.mode || 'md';
    const flipRtl =
      this.flipRtl ||
      (this.ariaLabel &&
        (this.ariaLabel.indexOf('arrow') > -1 || this.ariaLabel.indexOf('chevron') > -1) &&
        this.flipRtl !== false);

    return (
      <Host
        role="img"
        class={{
          [mode]: true,
          ...createColorClasses(this.color),
          [`icon-${this.size}`]: !!this.size,
          'flip-rtl': !!flipRtl && (this.el.ownerDocument as Document).dir === 'rtl',
        }}
      >
        {Build.isBrowser && this.svgContent ? (
          <div class="icon-inner" innerHTML={this.svgContent}></div>
        ) : (
          <div class="icon-inner"></div>
        )}
      </Host>
    );
  }

ariaLabel の値を元にした分岐処理を書いたりはしているが、ariaLabel そのものを内部に引きまわすことはしてないっぽい。

spec を見ても、host につけることで自動的に別の attribute(ここでは role)が補われることはあるが、「内部のマークアップで使う」といった感じではなさそう。

https://github.com/ionic-team/ionicons/blob/master/src/components/icon/test/icon.spec.ts#L19-L31
    expect(root).toEqualHtml(`
      <ion-icon class="md" role="img" aria-hidden="true">
        <mock:shadow-root>
          <div class="icon-inner"></div>
        </mock:shadow-root>
      </ion-icon>
    `);

というわけで、こういうアイコンライブラリを作る場合 aria-label を受け取ることは想定するが、内部では使わなくても良いという話でいいか。

たとえば <app-icon> の JSX 向け型定義を提供する場合に必須な Props としては書くけどコンポーネントのクラス内では別に使わないという状況になる?

type Props = {
  type: ...
  size: number
} & (
  // どれかは必須!
  | {
    'aria-label': string
  }
  | {
    'aria-labelledby': string
  }
  | {
    'aria-hidden': boolean
  }
)

declare global {
  export namespace JSX {
    interface IntrinsicElements {
      'app-icon': Props
    }
  }
}

目的を改めて整理しておくと、<app-icon ... aria-label="アイコンを追加"> と書いてあった時に、通常の alt や aria-label がある HTML 要素と同じくスクリーンリーダーに認識され読まれることがゴール

……と、ここまでは Ionicon という先行例を見て推定したことがらで、できればスクリーンリーダーの挙動や WAI-ARIA、そして Shadow DOM の該当する仕様を見て裏を取りたいのがここから( VoiceOver を軽く触ったがそもそも普段使ってないので上手く検証できなかった )。

display: contents;role 属性に影響してはいけない仕様になっていても実装はそうなっていないという例があり、仕様を確認するしないにせよ実装側の確認が必要そうですね……。

https://hiddedevries.nl/en/blog/2018-04-21-more-accessible-markup-with-display-contents

特に Custom Elements っていうエッジケースではスクリーンリーダーが上手く対応できていないということも起きそうですし。

ぐえ、マジすか。アイコンに role=img 付けるというか(というかそうでなくても host を属性として受け取った場合)普通に起きそう…

ログインするとコメントできます