LIFULL Creators Blog

LIFULL Creators Blogとは、株式会社LIFULLの社員が記事を共有するブログです。自分の役立つ経験や知識を広めることで世界をもっとFULLにしていきます。

よく使うStimulusコントローラの紹介 vol.1

アクセシビリティ推進グループの中島です。

過去同グループの発信した記事の中で、弊社がJavaScriptライブラリとしてStimulusを採用していると何度か紹介させていただきました。

www.lifull.blog

今回はその中で、どんな粒度で、どんな機能のStimulusコントローラを書いているのか少しばかり紹介しようと思います。

(全て書くととても長くなってしまうのでvol.1としてますが、vol.2以降を書くかどうかは今の所わかりません。)

button_controller.js

まずは最頻出のコントローラです。

このコントローラは37signalsから拝借したコントローラの一つです。

button_controller.jsの実装を見る

export default class extends Controller {
  keyboardClick(event) {
    if (['Enter', ' '].includes(event.key)) {
      event.preventDefault();
      event.stopPropagation();

      this.element.click();
    }
  }
}

このコントローラは要素にボタンとしての機能を簡単に提供するために使っているものです。

ボタンは通常、スペースキーやエンターキーといったキーボード操作でも操作可能であるべきです。

button要素で実装していればこのキーボード操作は標準で提供されるので、こちらがコードを書く必要はないのですが長く運用されたサイトでは往々にしてdivやaタグで実装されたボタンが目立ちます。

そういったものを要素自体を差し替えずに正しくボタンのように振る舞わせるのに役立ちます。

適用例

<!-- before -->
<div>click me</div>
<!-- after -->
<div
  # フォーカス可能に
  tabindex="0"
  # 支援技術にボタンであることを教える
  role="button"
  data-controller="button"
  # スペースキーとエンターキーをクリックイベントとして処理する
  data-action="keydown->button#keyboardClick"
>click me</div>

参考

www.w3.org

disclosure_controller.js

次によく使うコントローラはディスクロージャー(開閉UI)を実現するコントローラです。

物件種別の切り替えメニューを開閉させるUIのgifアニメ

disclosure_controller.jsの実装を見る

export default class extends Controller {
  static classes = ['collapsed'];

  toggle(evt) {
    evt?.preventDefault();

    if (this.isExpanded) {
      this.hide();
      return;
    }
    this.show();
  }

  show() {
    this.control.classList.remove(...this.hiddenClasses);
    this.element.ariaExpanded = 'true';
  }

  hide() {
    this.control.classList.add(...this.hiddenClasses);
    this.element.ariaExpanded = 'false';
  }

  get hiddenClasses() {
    return this.hasCollapsedClass ? this.collapsedClasses : ['!hidden'];
  }

  get isExpanded() {
    return this.element.ariaExpanded === 'true';
  }

  get control() {
    return document.getElementById(
      this.element.getAttribute('aria-controls').trim()
    );
  }
}

最近ではdetails要素で実現可能なディスクロージャーですが、例によって過去に作られたディスクロージャーはdiv等で作られ、ディスクロージャーとしての機能要件を満たしてないものが多くあります。

ディスクロージャーはトリガーである要素がフォーカス可能で、ボタンとして振る舞い、視覚だけでなく支援技術等でも開閉状態を把握できるように作られるべきです。

このコントローラを使うとそういった部分を担保したディスクロージャーを簡単に実装することができます。

適用例

<!-- before -->
<div>
  <p>ほげほげについて</p>
  <div class="!hidden">
    ほげほげはふがふがのことで
    一般的にぴよぴよとも
    呼ばれています。
  </div>
</div>
<!-- after -->
<div>
  <p
    # フォーカス可能に
    tabindex="0"
    # 支援技術にボタンであることを教える
    role="button"
    # 関連要素のidを指定することで支援技術でジャンプ機能等を提供する
    aria-controls="content"
    # 開閉情報を支援技術に公開する
    aria-expanded="false"
    data-controller="button disclosure"
    data-action="keydown->button#keyboardClick click->disclosure#toggle"
  >ほげほげについて</p>
  <div id="content" class="!hidden">
    ほげほげはふがふがのことで一般的にぴよぴよとも呼ばれています。
  </div>
</div>

参考

www.w3.org

inlay_controller.js

主にウェブで見かけるUIに「もっと見るボタン」を押したら、ボタンは消滅しつつ、隠れた要素が表示されるというものがあります。

街の口コミのもっとみるボタンがinlayパターンで実装されているgifアニメ

このパターンはARIA Authoring Practices Guide等でベストプラクティスが紹介されているわけではないのですが、我々はそういった振る舞いをinlayとよび、対応するコントローラを用意しています。

inlay_controller.jsの実装を見る

import { tabbable } from 'tabbable';

export default class extends Controller {
  show() {
    this.nextContent.classList.remove('!u-hidden');
    this.element.classList.add('!u-hidden');
    this.firstTabbableItem.focus({ preventScroll: true });
  }

  get nextContent() {
    let control = this.element.getAttribute('aria-controls');
    return document.getElementById(control);
  }

  get firstTabbableItem() {
    return tabbable(this.nextContent)[0] || this.nextContent;
  }
}

このパターンは押したボタンそのものが消滅することで、フォーカスが失われる(bodyに戻ってしまう)問題に対処するため、表示されたコンテンツ、あるいはそのコンテンツ内の最初のフォーカス可能な要素にフォーカスを移すことが重要と考えています。

そういったことが考慮されてない古いコードをこのコントローラに差し替えることで簡単にキーボード操作の要件を満たすことができるようになります。

適用例

<!-- before -->
<ul>
  <li><a href="...">a</a></li>
  <li><a href="...">b</a></li>
  <li><a href="...">c</a></li>
</ul>
<div>もっと見る</div>
<ul class="!hidden">
  <li><a href="...">d</a></li>
  <li><a href="...">e</a></li>
</ul>
<!-- after -->
<ul>
  <li><a href="...">a</a></li>
  <li><a href="...">b</a></li>
  <li><a href="...">c</a></li>
</ul>
<div
  # フォーカス可能に
  tabindex="0"
  # 支援技術にボタンであることを教える
  role="button"
  # 開閉可能なUIであることを支援技術に公開する
  aria-expanded="false"
  aria-controls="more-content"
  data-controller="button inlay"
  data-action="keydown->button#keyboardClick click->inlay#show"
>もっと見る</div>
<ul class="!hidden" id="more-content">
  <li><a href="...">d</a></li>
  <li><a href="...">e</a></li>
</ul>

参考

accessible-usable.net

anchor_sinon_controller.js

主にサイトアナリティクスの文脈で流入元を特定する目的でリンクにパラメータを付与することがよくあります。

ただ、googlebot等のクロールバジェットの消費を抑制する観点で、「パラメータ付与はJavaScriptでクリック時に行ってください」ということが要件に盛り込まれることが稀にあります。

目的は違えどGoogleAnalyticsのLinker機能などが類似の動きをしますね。

そういった時のためにURLの差し替えをさっさと行えるコントローラを用意しています。

anchor-sinon_controller.jsの実装を見る

export default class extends Controller {
  static values = {
    url: String
  };

  trick() {
    this.element.setAttribute('href', this.urlValue);
  }
}

私自身SEOの専門家ではないのでどの程度効果があるのかは詳しく分かってませんが、汎用的なURL差し替えを実現できるようになっています。

適用例

<!-- before -->
<a href="/path/to/page/?from=xxxx">some page</a>
<!-- after -->
<a href="/path/to/page/"
  data-controller="anchor-sinon"
  data-action="click->anchor-sinon#trick"
  data-anchor-sinon-url-value="/path/to/page/?from=xxxx"
>some page</a>

tabs_controller.js

複数のコンテンツをタブ付きインタフェースとして表現するケースがたまにあります。(特にPCサイトに多いような気がします。)

弊社でも家賃相場情報を間取りタイプごとにグルーピングしてタブで切り替えて閲覧できるといった機能があったりしますが、これはそういったUIパターンに適合するコントローラです。

品川駅周辺の家賃相場を間取りでグルーピングしタブ表示しているUIのgifアニメ

tabs_controller.jsの実装を見る

export default class extends Controller {
  static targets = ['tab', 'tabpanel'];
  static values = { index: { default: 0, type: Number } };

  select(evt) {
    let tab = evt.currentTarget;
    let index = this.tabTargets.indexOf(tab);
    this.indexValue = index;
  }

  selectAt(index) {
    this.indexValue = index;
  }

  next(evt) {
    evt.preventDefault();
    this.indexValue =
      this.indexValue === this.lastIndex
        ? this.indexValue
        : this.indexValue + 1;
    this.tabTargets[this.indexValue]?.focus({ preventScroll: true });
  }

  prev(evt) {
    evt.preventDefault();
    this.indexValue = this.indexValue === 0 ? 0 : this.indexValue - 1;
    this.tabTargets[this.indexValue]?.focus({ preventScroll: true });
  }

  first(evt) {
    evt.preventDefault();
    this.indexValue = 0;
    this.tabTargets[this.indexValue]?.focus({ preventScroll: true });
  }

  last(evt) {
    evt.preventDefault();
    this.indexValue = this.lastIndex;
    this.tabTargets[this.indexValue]?.focus({ preventScroll: true });
  }

  get lastIndex() {
    return this.tabTargets.length - 1;
  }

  indexValueChanged(current, prev) {
    let tabs = this.tabTargets;
    let tabpanels = this.tabpanelTargets;

    tabs[prev]?.setAttribute('aria-selected', 'false');
    tabs[prev]?.setAttribute('tabindex', '-1');
    tabpanels[prev]?.classList.add('!hidden');

    tabs[current]?.setAttribute('aria-selected', 'true');
    tabs[current]?.setAttribute('tabindex', '0');
    tabpanels[current]?.classList.remove('!hidden');
  }
}

タブ付きインタフェースはタブシーケンス中に一つだけタブストップを持つといった要件や、左右キーでタブ切り替えができる、 Home/Endキーでタブの先頭、末尾切り替えができるといった要件が存在します。

そういったタブナビゲーションをこのコントローラを利用すれば簡単に実現することができます。

適用例

<!-- before -->
<h2>〜区の家賃相場</h2>
<div>
  <ul>
    <li>1人暮らし向け</li>
    <li>2人暮らし向け</li>
    <li>ファミリー向け</li>
  </ul>
  <div>
  1人暮らし向け物件の家賃相場
  ...円
  </div>
  <div class="!hidden">
  2人暮らし向け物件の家賃相場
  ...円
  </div>
  <div class="!hidden">
  ファミリー向け物件の家賃相場
  ...円
  </div>
</div>
<!-- after -->
<h2 id="tab-label">〜区の家賃相場</h2>
<div data-controller="tabs">
  <ul role="tablist" aria-labelledby="tab-label">
    <li
      id="tab1"
      # 初期選択の要素だけタブシーケンスにタブストップを設定
      tabindex="0"
      role="tab"
      # タブが選択中であることを支援技術に公開
      aria-selected="true"
      data-tabs-target="tab"
      data-action="
        keydown.left->tabs#prev keydown.right->tabs#next
        keydown.home->tabs#first keydown.end->tabs#last
        click->tabs#select"
    >1人暮らし向け</li>
    <li
      id="tab2"
      tabindex="-1"
      role="tab"
      aria-selected="false"
      data-tabs-target="tab"
      data-action="
        keydown.left->tabs#prev keydown.right->tabs#next
        keydown.home->tabs#first keydown.end->tabs#last
        click->tabs#select"
    >2人暮らし向け</li>
    <li
      id="tab3"
      tabindex="-1"
      role="tab"
      aria-selected="false"
      data-tabs-target="tab"
      data-action="
        keydown.left->tabs#prev keydown.right->tabs#next
        keydown.home->tabs#first keydown.end->tabs#last
        click->tabs#select"
    >ファミリー向け</li>
  </ul>
  <div role="tabpanel" aria-labelledby="tab1" data-tabs-target="tabpanel">
  1人暮らし向け物件の家賃相場
  ...円
  </div>
  <div role="tabpanel" class="!hidden" aria-labelledby="tab2" data-tabs-target="tabpanel">
  2人暮らし向け物件の家賃相場
  ...円
  </div>
  <div role="tabpanel" class="!hidden" aria-labelledby="tab3" data-tabs-target="tabpanel">
  ファミリー向け物件の家賃相場
  ...円
  </div>
</div>

参考

www.w3.org

最後に

今回は記事の物量の関係上、よく使う5つのコントローラに絞って紹介しました。

他にもダイアログ関連のコントローラ、コンテンツの非同期読み込みコントローラ、コンボボックスを実現するコントローラ等、さまざまなコントローラがあるのでvol2があればそこで紹介しようと思います。


最後までお読みいただきありがとうございました。LIFULL では共に働く仲間を募集しています!

hrmos.co

hrmos.co