カスタムウィジェットの作成方法

HTML フォームで使用可能なウィジェットだけでは十分でない場合が多くあります。<select> 要素のようなウィジェットに高度なスタイル設定を行いたい場合や、独自の動作を提供したい場合は、あなた自身のウィジェットを作成するしかありません。

本記事では、そのようなウィジェットの作り方を見ていきます。そのため、次の例に取り組みます: <select> 要素の再構築です。

注記: ここではウィジェットの構築に注目しており、汎用かつ再利用可能なコードの作成法は見ていきません。それには JavaScript の重要なコードや未知のコンテキストでの DOM 操作が組み合わされており、本記事の対象から外れます。

デザイン、構造、セマンティクス

カスタムウィジェットを作成する前に、何をしたいかをはっきりと理解することから始めるべきです。これはあなたの貴重な時間を節約するでしょう。特に、ウィジェットの全状態を明確に定義することが重要です。これを行うには、状態や動作がよく知られている既存のウィジェットからスタートするとよいでしょう。この結果、簡単に可能な限りの模倣を行えます。

本記事の例では、<select> 要素を再構築します。以下が、私たちが実現したい成果です:

The three states of a select box

このスクリーンショットでは、ウィジェットの主要な状態 3 つを示しています: 通常状態 (左)、アクティブ状態 (中央)、そして開いた状態 (右) です。

動作の点では、ネイティブのウィジェットと同様に独自のウィジェットも、キーボードだけでなくマウスでも使用できるようにしたいと考えます。ウィジェットがどのように各状態に達するかを定義することから始めましょう:

ウィジェットは以下のときに通常状態になります:
  • ページを読み込む
  • ウィジェットはアクティブであったが、ユーザがウィジェット以外のどこかをクリックした
  • ウィジェットはアクティブであったが、キーボードを使用して別のウィジェットにフォーカスを移した

注記: ページ内のあちこちへのフォーカス移動は通常 Tab キーの押下によって行われますが、これはあらゆる環境における標準ではありません。例えば Safari でページ内のリンクを巡るには、デフォルトで Option+Tab の組み合わせを使用します。

ウィジェットは以下のときにアクティブ状態になります:
  • ユーザがウィジェット上でクリックする
  • ユーザが Tab キーを押下して、ウィジェットがフォーカスを得る
  • ウィジェットは開いた状態で、ユーザがウィジェットをクリックする
ウィジェットは以下のときに開いた状態になります:
  • ウィジェットが開いた状態ではないときに、ユーザがウィジェットをクリックした

状態をどのように変えるかを理解したら、ウィジェットの値をどのように変えるかの定義が重要になります:

以下のときに値が変わります:
  • ウィジェットが開いた状態であるときに、ユーザが選択肢をクリックする
  • ウィジェットがアクティブ状態であるときに、ユーザが上下矢印キーを押下する

最後に、ウィジェットの選択肢がどのように動作するかを定義しましょう:

  • ウィジェットが開いているとき、選択されている選択肢は強調されます
  • マウスポインタが選択肢の上にあるときはその選択肢が強調され、また前に強調されていた選択肢は通常状態に戻ります

この例の用途としては、ここまでです。しかし注意深い読者の方は、いくつかの動作が欠けていることに気づくでしょう。例えば、ウィジェットが開いた状態であるときにユーザが Tab キーを押すと何が起きると考えますか? その答えは... 何も起きません。正しい動作は明らかでしょうが、実際は私たちの仕様で定義されていないため、とても見逃されやすいのです。これは、ウィジェットの動作を設計する人と実装する人が異なるチーム環境で特に当てはまります。

別のおもしろい例です: ウィジェットが開いた状態であるときに上下矢印キーを押すと何が起きるのでしょうか? こちらはやや難しくなります。アクティブ状態と開いた状態をまったく別のものと考えるなら、その答えはやはり "何も起きません" です。これは、開いた状態でのキーボードの作用を定義していないためです。一方、アクティブ状態と開いた状態が少し重なると考えるなら、値は替わるかもしれませんがそれに対応して選択肢が強調されることはないでしょう。繰り返しになりますが、これはウィジェットが開いた状態の選択肢に対するキーボードの作用を定義していないためです (ウィジェットが開いた状態で何が起きるかだけを定義しており、その後がないためです)。

この例では欠けている仕様が明らかですので対処するでしょうが、めずらしい新たなウィジェットではどのような動作が正しいかについて、わずかなアイデアですら誰も持っていないため、真の問題になり得ます。動作の定義が貧弱であったり定義もれがあったりした場合、いったんユーザが使い始めると動作を再定義するのが非常に困難になると思われますので、設計段階に時間をかけることは賢明です。もし疑っているのでしたら、他の人に意見を聞きましょう。また予算を持っているのでしたら、ユーザテストの実施をためらってはいけません。このプロセスは、UX デザインと呼ばれます。この点について詳しく学びたいのでしたら、以下の役に立つリソースをご覧になるとよいでしょう:

注記: さらにほとんどのシステムでは、使用できる選択肢すべてを見るために <select> 要素を開く手段があります (これは <select> 要素をマウスでクリックするのと同じです)。これは Windows では Alt + 下矢印キー で実現できますが、この例では実装しません。しかし、仕組みはすでに click イベント向けに実装されていますので、行うのは簡単です。

HTML の構造とセマンティクスの定義

ウィジェットの基本的な機能が決まりましたので、構築を始めるときが来ました。最初のステップはウィジェットの HTML 構造の定義と、基本的なセマンティクスの付与です。こちらが、<select> 要素の再構築に必要な HTML です:

<!-- これはウィジェットの中心的なコンテナです。
     tabindex 属性は、ユーザがウィジェットにフォーカスを当てられるようにするものです。 
     これを JavaScript で設定する方がよいことは、後で見ていきます。-->
<div class="select" tabindex="0">
  
  <!-- このコンテナは、ウィジェットの現在の値を表示するために使用します。 -->
  <span class="value">Cherry</span>
  
  <!-- このコンテナは、ウィジェットで使用できるすべての選択肢を包含します。
       これはリストですから、ul 要素を使用するとよいでしょう。-->
  <ul class="optList">
    <!-- 各々の選択肢は表示される値だけを包含しており、フォームのデータで送信される
         実際の値を処理する方法は後で見ていきます。 -->
    <li class="option">Cherry</li>
    <li class="option">Lemon</li>
    <li class="option">Banana</li>
    <li class="option">Strawberry</li>
    <li class="option">Apple</li>
  </ul>

</div>

クラス名の使い方に注目してください。これらは基盤となる実際の HTML とは関係なく、フォームに関するそれぞれの部分を示します。これは CSS や JavaScript を強固な HTML の構造と結びつけないようにするために重要であり、そのためにウィジェットを扱うコードを壊すことなく、後から実装を変更することができます。例えば <optgroup> 要素と同等の機能を実装したい場合などです。

CSS でルックアンドフィールを作成する

構造ができましたので、ウィジェットのデザインを始められます。カスタムウィジェットを作成する上でのポイントは、望むとおりにウィジェットへスタイルを設定できることです。そのために、CSS を 2 つの部分に分けます: ひとつはウィジェットが <select> 要素のように動作するために欠かせない CSS ルールであり、もうひとつは希望する見た目にするための好みのスタイルで構成されます。

必須のスタイル

必須のスタイルは、ウィジェットの 3 つの状態を扱うために欠かせないものです。

.select {
  /* 選択肢のリスト向けのポジショニングコンテキストを作成します */
  position: relative;
 
  /* ウィジェットをテキストフローの一部かつまとまった大きさにします */
  display : inline-block;
}

アクティブ状態であるウィジェットのルックアンドフィールを定義するため、追加で active クラスが必要です。このウィジェットはフォーカスを得ることができますので、同様に動作させるためにカスタムスタイルを :focus 疑似クラスにも適用します。

.select.active,
.select:focus {
  outline: none;
 
  /* box-shadow プロパティは必須ではありませんが、これをデフォルト値として使用するのは
     アクティブ状態を見えるようにするために重要です。自由に書き換えてください。*/
  box-shadow: 0 0 3px 1px #227755;
}

次に、選択肢のリストを扱いましょう:

/* .select セレクタは、私たちが定義するクラスがウィジェットの内部にあることを
   確実にするためのシンタックスシュガーです。*/
.select .optList {
  /* 選択肢のリストが値の下部かつ HTML フローの外側に表示される
     ようにします。 */
  position : absolute;
  top      : 100%;
  left     : 0;
}

選択肢のリストが隠れている状態を扱うための追加クラスも必要です。これはアクティブ状態と開いた状態で完全には一致しない相違点を扱うために必要です。

.select .optList.hidden {
  /* これはアクセシブルな方法でリストを隠すための簡単な方法です。 
     アクセシビリティについては最後に説明します。 */
  max-height: 0;
  visibility: hidden;
}

美化

基本的な機能性を適切に置きましたので、戯れを始められます。以下は何ができるかの例であり、本記事の冒頭で示したスクリーンショットに一致するものです。とはいえ、自由に実験して何ができるかを見てみるとよいでしょう。

.select {
  /* アクセシビリティのため、すべてのサイズは em 単位の値で表します
     (ユーザがテキストのみのモードでブラウザのズーム機能を使用したときに、  
     ウィジェットをリサイズ可能にします)。算出結果は、ほとんどのブラウザで
     デフォルト値である1em == 16px を想定します。
     px から em への変換がわからない場合は http://riddle.pl/emcalc/ を試してください */
  font-size   : 0.625em; /* この値 (10px) は、本コンテキストにおける新たなフォントサイズの em 単位値です。 */
  font-family : Verdana, Arial, sans-serif;

  -moz-box-sizing : border-box;
  box-sizing : border-box;

  /* 後で追加する下向き矢印のためのスペースが必要です */
  padding : .1em 2.5em .2em .5em; /* 1px 25px 2px 5px */
  width   : 10em; /* 100px */

  border        : .2em solid #000; /* 2px */
  border-radius : .4em; /* 4px */
  box-shadow    : 0 .1em .2em rgba(0,0,0,.45); /* 0 1px 2px */
  
  /* 最初の宣言は、線形グラデーションをサポートしないブラウザ向けのものです。
     2 番目の宣言は、WebKit ベースのブラウザではまだ接頭辞付きであるためです。
     古いブラウザもサポートしたい場合は http://www.colorzilla.com/gradient-editor/ を試してください */
  background : #F0F0F0;
  background : -webkit-linear-gradient(90deg, #E3E3E3, #fcfcfc 50%, #f0f0f0);
  background : linear-gradient(0deg, #E3E3E3, #fcfcfc 50%, #f0f0f0);
}

.select .value {
  /* 値がウィジェットの幅より大きくなる可能性があるため、ウィジェットの幅を
     変更しないようにすることが必要です。 */
  display  : inline-block;
  width    : 100%;
  overflow : hidden;

  vertical-align: top;

  /* 内容物がオーバーフローした場合は、省略記号をつけるとよいでしょう。*/
  white-space  : nowrap;
  text-overflow: ellipsis;
}

下向き矢印をデザインするための追加要素は不要です。代わりに :after 疑似要素を使用します。ただし、select クラスでシンプルな背景画像を使用することによる実装も可能です。

.select:after {
  content : "▼"; /* Unicode 文字 U+25BC を使用します。http://www.utf8-chartable.de をご覧ください */
  position: absolute;
  z-index : 1; /* これは、矢印が選択肢のリストに重ならないようにするために重要です */
  top     : 0;
  right   : 0;

  -moz-box-sizing : border-box;
  box-sizing : border-box;

  height  : 100%;
  width   : 2em;  /* 20px */
  padding-top : .1em; /* 1px */

  border-left  : .2em solid #000; /* 2px */
  border-radius: 0 .1em .1em 0;  /* 0 1px 1px 0 */

  background-color : #000;
  color : #FFF;
  text-align : center;
}

次に、選択肢のリストにスタイルを設定しましょう:

.select .optList {
  z-index : 2; /* 選択肢のリストが下向き矢印より上になるよう、明示的に示します。 */

  /* ul 要素のデフォルトスタイルを初期化します。 */
  list-style: none;
  margin : 0;
  padding: 0;

  -moz-box-sizing : border-box;
  box-sizing : border-box;

  /* 値の幅がウィジェットの幅より小さい場合でも、選択肢のリストの幅が
     ウィジェット自体と同じになるようにします。 */
  min-width : 100%;

  /* リストが長すぎる場合に、内容物が垂直方向にはみ出します (自動的に 
     垂直スクロールバーを表示します) が、水平方向にはみ出しません 
     (幅を指定しないため、リストは自身の幅へ自動的に調整されます。 
     それができない場合は、内容物が切り詰められます) 。 */
  max-height: 10em; /* 100px */
  overflow-y: auto;
  overflow-x: hidden;

  border: .2em solid #000; /* 2px */
  border-top-width : .1em; /* 1px */
  border-radius: 0 0 .4em .4em; /* 0 0 4px 4px */

  box-shadow: 0 .2em .4em rgba(0,0,0,.4); /* 0 2px 4px */
  background: #f0f0f0;
}

選択肢向けに、ユーザが選択しようとしている (あるいは選択した) 値を示せるようにするための highlight クラスを追加しなければなりません。

.select .option {
  padding: .2em .3em; /* 2px 3px */
}

.select .highlight {
  background: #000;
  color: #FFFFFF;
}

これで、3 つの状態の結果は以下のようになります:

通常状態 アクティブ状態 開いた状態
ソースコードを確認する

JavaScript でウィジェットに命を吹き込む

デザインや構造の準備ができましたので、ウィジェットが実際に動作するようにするための JavaScript コードを記述できます。

警告: 以下は教育目的のコードであり、そのままで使用するべきではありません。ご覧のとおり、さまざまな箇所に将来性のないものや古いブラウザで動作しないものがあります。また、本番のコードでは最適化すべき冗長な箇所もあります。

注記: 再利用可能なウィジェットの作成は、若干難しいかもしれません。W3C Web Component draft は、具体的な問題に対する答えのひとつです。X-Tag project はこの仕様の試験的な実装です。こちらを見てみることをおすすめします。

なぜ動作しないのか?

始める前に、JavaScript に関する重要事項を覚えておくことが大切です: ブラウザ内で信頼できる技術ではありません。カスタムウィジェットを作成するとき、すべてをつなぎ合わせるために必要であることから JavaScript に頼らなければならないでしょう。ところが、JavaScript をブラウザで実行できない場合がいくつもあります:

  • ユーザが JavaScript を無効にしている。これはもっともめずらしいケースです。ごく一部の人々は、今でも JavaScript を無効にしています。
  • スクリプトが読み込まれません。これはよくあるケースのひとつであり、特にネットワークの信頼性が低いモバイル環境で発生します。
  • スクリプトに不具合があります。この可能性は常に考慮すべきです。
  • スクリプトがサードパーティのスクリプトと競合しています。これは、トラッキングのスクリプトやユーザが使用するブックマークレットとの間で発生する可能性があります。
  • スクリプトがブラウザの拡張機能 (Firefox の NoScript 拡張機能や Chrome の NotScripts 拡張機能など) と競合したり、拡張機能の影響を受けたりしています。
  • ユーザが古いブラウザを使用しており、必要な機能のいずれかがサポートされていません。これは、最先端の API を使用するときに頻繁に発生します。

このようなリスクがあるため、JavaScript が動作しない場合に何が起きるかを注意深く考えることが重要です。この問題について詳しく扱うのは、スクリプトをどれだけ汎用および再利用可能にしたいかと密接に関わりますので本記事の範囲を外れますが、本サンプルでは基本的な部分について考えていきます。

本記事の例では、JavaScript コードが実行されない場合に標準の <select> 要素にフォールバックします。これを実現するには、2 つのことが必要です。

第一に、カスタムウィジェットを使用する前に通常の <select> 要素を追加することが必要です。実際は、これは残りのフォームデータと共にカスタムウィジェットのデータを送信できるようにするために必要です。詳しくは後述します。

<body class="no-widget">
  <form>
    <select name="myFruit">
      <option>Cherry</option>
      <option>Lemon</option>
      <option>Banana</option>
      <option>Strawberry</option>
      <option>Apple</option>
    </select>

    <div class="select">
      <span class="value">Cherry</span>
      <ul class="optList hidden">
        <li class="option">Cherry</li>
        <li class="option">Lemon</li>
        <li class="option">Banana</li>
        <li class="option">Strawberry</li>
        <li class="option">Apple</li>
      </ul>
    </div>
  </form>

</body>

第二に、不要な要素 (すなわち、スクリプトを実行する場合における "本物の" <select> 要素や、実行しない場合におけるカスタムウィジェット) を隠せるようにするための新たなクラスが 2 つ必要です。デフォルトでは、HTML コードでカスタムウィジェットを隠すことに注意してください。

.widget select,
.no-widget .select {
  /* この CSS セレクタの基本的な意味は:
     - body のクラスを "widget" に設定して、本物の <select> 要素を隠す
     - または body のクラスを変更せずに "no-widget" のままにしておくことで、
       クラスが "select" である要素が隠される */
  position : absolute;
  left     : -5000em;
  height   : 0;
  overflow : hidden;
}

ここで、スクリプトを実行するか否かを判断するための JavaScript スイッチが必要になります。このスイッチはとても簡単です: ページを読み込むときにスクリプトを実行したら、no-widget クラスを削除して widget クラスを追加します。これにより <select> 要素やカスタムウィジェットの可視性を切り替えます。

window.addEventListener("load", function () {
  document.body.classList.remove("no-widget");
  document.body.classList.add("widget");
});
JS なし JS あり
ソースコードを確認する

注記: コードを本当に汎用かつ再利用可能にしたい場合はクラスを切り替えるのではなく、単に <select> 要素を隠すためのウィジェットのクラスを追加して、ページ内にあるすべての <select> 要素の後ろにカスタムウィジェットを表す DOM ツリーを動的に追加する方がはるかによいでしょう。

作業をより簡単に

作成しようとしているコードでは、必要な作業すべてのために標準の DOM API を使用するでしょう。ところが、ブラウザでの DOM API のサポート状況はよくなりましたが、古いブラウザではいつまでも問題があります (特に、古き良き Internet Explorer において)。

古いブラウザでの問題を避けたい場合、そのための手段が 2 つあります: jQuery$domprototypeDojoYUI などの専用フレームワークを使用するか、使用したい不足機能のポリフィルを使用します (これは、例えば yepnope ライブラリによって条件付きで読み込むことで簡単にできます)。

ここで使用するつもりである機能は以下のとおりです (リスクが高い順に並べています):

  1. classList
  2. addEventListener
  3. forEach (これは DOM ではなく最新の JavaScript 機能です)
  4. querySelector および querySelectorAll

これら特定機能を利用できるかに加えて、作業を始める前に残されている問題があります。querySelectorAll() 関数が返すオブジェクトは Array ではなく NodeList です。これは、Array オブジェクトは forEach 関数をサポートしているが NodeList はサポートしていないために重要な問題です。NodeListArray ととても似ており、また forEach はとても便利であることから、作業を楽にするため以下のように NodeListforEach をサポートさせることができます:

NodeList.prototype.forEach = function (callback) {
  Array.prototype.forEach.call(this, callback);
}

簡単と言ったのは、ふざけていたわけではありません。

イベントコールバックを作成する

基盤が整いましたので、ユーザがウィジェットと対話するたびに使用されるすべての関数を定義し始めることができます。

// この関数は、カスタムウィジェットを非アクティブにしたいときに使用します。
// 引数は 1 つあります。
// select : 非アクティブにする `select` クラスの DOM ノード
function deactivateSelect(select) {

  // ウィジェットがアクティブではないときは何もしません。
  if (!select.classList.contains('active')) return;

  // カスタムウィジェットの選択肢のリストを取得することが必要です。
  var optList = select.querySelector('.optList');

  // 選択肢のリストを閉じます。
  optList.classList.add('hidden');

  // そして、カスタムウィジェット自身を非アクティブにします。
  select.classList.remove('active');
}

// この関数は、ユーザがウィジェットをアクティブ/非アクティブにしたがっているときに使用します。
// 引数は 2 つあります:
// select : アクティブにする `select` クラスの DOM ノード
// selectList : `select` クラスであるすべての DOM ノードのリスト
function activeSelect(select, selectList) {

  // ウィジェットがすでにアクティブであるときは何もしません。
  if (select.classList.contains('active')) return;

  // すべてのカスタムウィジェットを非アクティブにすることが必要です。
  // deactivateSelect 関数は forEach コールバック関数の要件を
  // すべて満たしていますので、仲介する無名関数を使用せずに
  // 直接使用しています。
  selectList.forEach(deactivateSelect);

  // そして、指定されたウィジェットをアクティブ状態にします。
  select.classList.add('active');
}

// この関数は、ユーザが選択肢のリストを開く/閉じることを求めたときに使用します。
// 引数は 1 つあります:
// select : 表示を切り替えるリストの DOM ノード
function toggleOptList(select) {

  // リストはウィジェットから確保します。
  var optList = select.querySelector('.optList');

  // リストのクラスを表示/非表示に切り替えます。
  optList.classList.toggle('hidden');
}

// この関数は、選択肢を強調したいときに使用します。
// 引数は 2 つあります:
// select : 強調する選択肢を包含する `select` クラスの DOM ノード
// option : 強調する `option` クラスの DOM ノード
function highlightOption(select, option) {

  // カスタムウィジェットで使用可能なすべての選択肢のリストを取得します。
  var optionList = select.querySelectorAll('.option');

  // すべての選択肢から強調効果を取り除きます。
  optionList.forEach(function (other) {
    other.classList.remove('highlight');
  });

  // 適切な選択肢を強調します。
  option.classList.add('highlight');
};

以上が、カスタムウィジェットのさまざまな状態を制御するために必要なもののすべてです。

次に、これらの関数と適切なイベントを関連づけます:

// ドキュメントが読み込まれたときのイベントの関連づけを制御します。
window.addEventListener('load', function () {
  var selectList = document.querySelectorAll('.select');

  // 各々のウィジェットは初期化が必要です。
  selectList.forEach(function (select) {

    // すべての `option` も同様です。
    var optionList = select.querySelectorAll('.option');

    // ユーザが選択肢にマウスポインタを乗せるたびに、その選択肢を強調します。
    optionList.forEach(function (option) {
      option.addEventListener('mouseover', function () {
        // 注記: 変数 `select` および `option` は、関数呼び出しのスコープ内でのみ
        // 使用可能なクロージャです。
        highlightOption(select, option);
      });
    });

    // ユーザが独自の select 要素でクリックするたびに
    select.addEventListener('click', function (event) {
      // 注記: 変数 `select` は、関数呼び出しのスコープ内でのみ
      // 使用可能なクロージャです。

      // 選択肢のリストの可視性を切り替えます。
      toggleOptList(select);
    });

    // ウィジェットが再びフォーカスを得た場合
    // ユーザがウィジェットをクリックしたり、ウィジェットへアクセスするために
    // Tab キーを使用するたびに、ウィジェットはフォーカスを得ます。
    select.addEventListener('focus', function (event) {
      // 注記: 変数 `select` および `selectList` は、関数呼び出しのスコープ内でのみ
      // 使用可能なクロージャです。

      // ウィジェットをアクティブにします。
      activeSelect(select, selectList);
    });

    // ウィジェットがフォーカスを失った場合
    select.addEventListener('blur', function (event) {
      // 注記: 変数 `select` は、関数呼び出しのスコープ内でのみ
      // 使用可能なクロージャです。

      // ウィジェットを非アクティブにします。
      deactivateSelect(select);
    });
  });
});

この時点でウィジェットは設計どおりに状態が変わりますが、ウィジェット値はまだ更新されません。次の章でこれを扱います。

Live example
ソースコードを確認する

ウィジェットの値を制御する

ウィジェットが動作するようになりましたので、ユーザの入力に従って値を更新して、フォームデータと共にその値を送信できるようにするコードを追加しなければなりません。

これを行うもっとも簡単な方法は、覆い隠したネイティブウィジェットを使用することです。そのようなウィジェットはブラウザが提供するすべての組み込みのコントロールと共に値の経過を保持しており、フォームを送信するときは通常どおりに値を送信します。これらすべてを行えるようにするために、車輪の再発明を行うのは無駄です。

先ほど見たように、アクセシビリティの理由からフォールバック手段としてすでにネイティブの select ウィジェットを使用しています。単純に、その値をカスタムウィジェットの値と同期することができます:

// この関数は、表示される値を更新してネイティブウィジェットの値と同期します。
// 引数は 2 つあります:
// select : 更新する値を持つ `select` クラスの DOM ノード
// index  : 選択される値のインデックス
function updateValue(select, index) {
  // 指定されたカスタムウィジェット向けのネイティブウィジェットを取得することが必要です。
  // この例では、ネイティブウィジェットはカスタムウィジェットの兄弟です。
  var nativeWidget = select.previousElementSibling;

  // カスタムウィジェットの値のプレースホルダーの取得も必要です。
  var value = select.querySelector('.value');

  // そして、選択肢の全リストが必要です。
  var optionList = select.querySelectorAll('.option');

  // 選択した値のインデックスを、selectedIndex に設定します。
  nativeWidget.selectedIndex = index;

  // 上記に応じて、値のプレースホルダーも更新します。
  value.innerHTML = optionList[index].innerHTML;

  // そして、カスタムウィジェットで対応する選択肢を強調します。
  highlightOption(select, optionList[index]);
};

// この関数は、ネイティブウィジェットで現在選択されているインデックスを返します。
// 引数は 1 つあります:
// select : ネイティブウィジェットに関係する `select` クラスの DOM ノード
function getIndex(select) {
  // 指定されたカスタムウィジェット向けのネイティブウィジェットにアクセスすることが必要です。
  // この例では、ネイティブウィジェットはカスタムウィジェットの兄弟です。
  var nativeWidget = select.previousElementSibling;

  return nativeWidget.selectedIndex;
};

これら 2 つの関数で、ネイティブウィジェットとカスタムウィジェットを関連づけることができます:

// ドキュメントが読み込まれたときのイベントの関連づけを制御します。
window.addEventListener('load', function () {
  var selectList = document.querySelectorAll('.select');

  // 各々のウィジェットは初期化が必要です。
  selectList.forEach(function (select) {
    var optionList = select.querySelectorAll('.option'),
        selectedIndex = getIndex(select);

    // カスタムウィジェットがフォーカスを得られるようにします。
    select.tabIndex = 0;

    // ネイティブウィジェットがフォーカスを得ないようにします。
    select.previousElementSibling.tabIndex = -1;

    // デフォルトで選択されている値が正しく表示されるようにします。
    updateValue(select, selectedIndex);

    // ユーザが選択肢をクリックするのに応じて値を更新します。
    optionList.forEach(function (option, index) {
      option.addEventListener('click', function (event) {
        updateValue(select, index);
      });
    });

    // フォーカスがあるウィジェットでユーザがキーボードを使用するのに応じて、値を更新します。
    select.addEventListener('keyup', function (event) {
      var length = optionList.length,
          index  = getIndex(select);

      // ユーザが下矢印キーを押すと、次の選択肢にジャンプします。
      if (event.keyCode === 40 && index < length - 1) { index++; }

      // ユーザが上矢印キーを押すと、前の選択肢にジャンプします。
      if (event.keyCode === 38 && index > 0) { index--; }

      updateValue(select, index);
    });
  });
});

上記のコードで、tabIndex プロパティを使用していることは注目に値します。このプロパティは、ネイティブウィジェットがフォーカスを得ないようにすることと、ユーザがキーボードやマウスを使用するとカスタムウィジェットがフォーカスを得るようにするために必要です。

これで完了です! 結果は以下のとおりです:

Live example
ソースコードを確認する

ちょっと待ってください、本当に終わったのでしょうか?

アクセシブルにする

フル機能のセレクトボックスとはかけ離れていますが動作するものはできましたし、よく動作しています。しかし、私たちが行ってきたことは DOM の操作にすぎません。これには実際のセマンティクスがなく、またセレクトボックスのように見えていてもブラウザの視点からはそうではないため、支援技術はそれがセレクトボックスであるとは理解できません。つまり、このきれいなセレクトボックスはアクセシブルではありません!

幸いなことに解決策があり、それは ARIA と呼ばれます。ARIA は "Accessible Rich Internet Application" を表し、その W3C 仕様 は私たちがここで行っていることに特化して設計されています: Web アプリケーションやカスタムウィジェットをアクセシブルにします。これは基本的には、私たちが作り出した要素がネイティブウィジェットとして通るかのように、役割や状態や特性をより説明できるようにするために HTML を拡張する属性のセットです。これらの属性の使用はとても簡単ですので、行ってみましょう。

role 属性

ARIA で使用される主要な属性が、role 属性です。role 属性は、要素を何に使用するかを定義する値を受け入れます。それぞれのロールは、自身の要件や動作を定義します。本記事の例では、ロール listbox を使用します。これは "composite role" であり、このロールの要素は子要素を持ち、またそれぞれの子要素も特定のロールを持ちます (この例では、ロール option の子要素が少なくとも 1 つ)。

また、ARIA は標準の HTML マークアップにデフォルトで適用されるロールを定義することも特筆に値します。例えば、<table> 要素はロール grid に、<ul> 要素はロール list にマッチします。<ul> 要素を使用しているため、私たちのウィジェットのロール listbox が、<ul> 要素のロール list を置き換えるようにしなければなりません。そのために、ロール presentation を使用します。このロールは要素に特別な意味はないことを示せるようにするためのものであり、単に情報を与えるために使用されます。これを <ul> 要素に適用します。

ロール listbox をサポートするため、HTML を以下のように更新することが必要です:

<!-- 最初の要素に role="listbox" 属性を追加します -->
<div class="select" role="listbox">
  <span class="value">Cherry</span>
  <!-- ul 要素に role="presentation" を追加します -->
  <ul class="optList" role="presentation">
    <!-- すべての li 要素に role="option" 属性を追加します -->
    <li role="option" class="option">Cherry</li>
    <li role="option" class="option">Lemon</li>
    <li role="option" class="option">Banana</li>
    <li role="option" class="option">Strawberry</li>
    <li role="option" class="option">Apple</li>
  </ul>
</div>

注記: role 属性と class 属性の両方を含める方法は、CSS 属性セレクタに対応しない古いブラウザをサポートしたい場合にのみ必要です。

aria-selected 属性

role を使用するだけでは不十分です。ARIA は、状態や特性を表す多くの属性も提供します。これらをより多く使用すると、よくなったウィジェットが支援技術に理解されるようになります。ここでは、使用する属性を 1 つに絞ります: aria-selected です。

aria-selected 属性は、どの選択肢が現在選択されているかを示すために使用します。これにより、支援技術はユーザに現在何が選択されているかを伝えることができます。ここではユーザが選択肢を選択するたびに、選択された選択肢を示すためにこの属性を JavaScript で動的に使用します。このために、updateValue() 関数の変更が必要です:

function updateValue(select, index) {
  var nativeWidget = select.previousElementSibling;
  var value = select.querySelector('.value');
  var optionList = select.querySelectorAll('.option');

  // すべての選択肢が選択されていないようにします。
  optionList.forEach(function (other) {
    other.setAttribute('aria-selected', 'false');
  });

  // 指定された選択肢が選択されているようにします。
  optionList[index].setAttribute('aria-selected', 'true');

  nativeWidget.selectedIndex = index;
  value.innerHTML = optionList[index].innerHTML;
  highlightOption(select, optionList[index]);
};

以下がこれらの変更を施した最終結果です (NVDAVoiceOver などの支援技術でウィジェットを使用してみても、よい感触を得られるでしょう):

Live example
ソースコードを確認する

おわりに

独自のフォームウィジェットの作成方法を見てきましたが、ご覧いただいたようにこれは容易なことではなく、フロムスクラッチでコードを作るのではなくサードパーティのライブラリに頼るほうが簡単かつよいことも少なくありません (もちろん、そのようなライブラリを作ることが目的であれば話は別ですが)。

自分でコーディングする前に検討するとよいライブラリをいくつか紹介します:

ここから前進したいのであれば、本記事のコードには汎用および再利用可能にする前に改善が必要な箇所があります。これはあなたが取り組んでみるとよい課題です。ヒントを 2 つ挙げます: すべての関数の第 1 引数は同じであり、これはそれらの関数で同じコンテキストを必要になるということです。このコンテキストを共有するオブジェクトを作成することが賢明でしょう。また、機能への耐性を高めることも必要です。すなわち Web 標準の実装状況がまちまちである、多様なブラウザで良好に動作できるようにすることが必要です。楽しんでください!

ドキュメントのタグと貢献者

 このページの貢献者: chrisdavidmills, yyss, ethertank
 最終更新者: chrisdavidmills,