サジェスト表示付き検索欄制作時のHTML,CSS,JSに関する覚書

備忘録として、学んだことを列挙したものです。
検索欄という軸でまとめていますが、他の事案で活用できないわけではないです。

# HTML・CSS編
## アクセシビリティに配慮したフォームパーツ
### Labelタグ
各フォームパーツにはラベルをつけて何の情報を入力するのか、文字で明示しましょう。
テキストボックスのプレースフォルダだけでは不十分です。その情報は文字入力を始めると見えなくなります。
また、アイコンなどの画像情報だけに頼ると、スクリーンリーダー（音声読み上げソフト）などの支援ツールを利用しているユーザーの利用を妨げます。
ラベルを見える形で入れられない場合は、隠しラベルで対応しましょう。

```html
<label for="SearchTxt" class="hide-label">キーワード検索</label>
<input id="SearchTxt" type="search" placeholder="検索キーワードを入力">
```

```css
/*
 "display:none;" では、読み上げの対象外になる可能性があります
 */
.hide-label {
  display: block;
  width: 0;
  height: 0;
  overflow: hidden; 
}
```

[&lt;label&gt; | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Element/label)

### aria-label属性
隠しLabel以外で支援ツール利用者向けのラベルを設置する方法として`aria-label`属性があります。
この属性はフォームパーツ以外にも付与することが出来ます。
`aria-label`とテキストが両方ある場合、`aria-label`の値のみが使用されます。

```html
<a href="next.html" aria-label="トゲツルボソテヅルモヅルの記事の続きを読む">続きを読む</a>
```

[aria-label 属性の使用 | MDN](https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-label_attribute)
[ARIA ラベルと関係性 | Google Developers](https://developers.google.com/web/fundamentals/accessibility/semantics-aria/aria-labels-and-relationships?hl=ja)

### Buttonタグ
ボタン要素は、`<button>`または`<input>`を利用しましょう。（&lt;button&gt;がお勧め）
`<p>, <div>, <span>`はクリック要素として用意されたタグではありません。支援ツールが正しく認識できない可能性があります。
また、キーボードユーザーがTabキーでフォーカスを移動させる際に、フォーカスの対象外とされ選択できません。
`tabindex`属性で個別に選択対象として指定することも可能ですが、管理が煩雑になりますし、最初から専用のタグを利用した方が良いと思います。
`<a href="">`はタブ移動時にフォーカスされますが、`<a href="javascript:void(0)">`は、`javascript: void(0)`をスクリーンリーダーが読み上げてしまいます。
HTML5にて`href`は省略可能となりましたが、省略するとタブ選択の対象外になります。
`href`省略時の用途は、将来的にハイパーリンクを置く予定のプレースホルダーだそうです。
`<button type="button">`は`<form>`に関係なく、ページ上のどこにでも設置できます。
アイコンのみのボタンの場合も、文字情報は省略しないようにしましょう。

```html
<button type="button" class="icon-menu">ナビゲーションメニューを開く</button>
```

```css
.icon-menu {
  width: 4.5rem;
  height: 4.5rem;
  text-indent: 110%;
  white-space: nowrap;
  overflow: hidden;
  background-image: url(menu_icon.png);
  background-size: cover;
}
```

[&lt;button&gt;: ボタン要素 | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Element/button)
[&lt;input type="button"&gt; | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Element/Input/button)
[はじめようアクセシビリティ改善！Backlogで行っている取り組みをご紹介](https://nulab.com/ja/blog/nulab/improving-accessibility-of-backlog/)
[4.5.1. The a element | HTML 5.1 W3C Recommendation](https://www.w3.org/TR/2016/REC-html51-20161101/textlevel-semantics.html#the-a-element)
[Is an anchor tag without the href attribute safe? | Stack Overflow](https://stackoverflow.com/questions/5292343/is-an-anchor-tag-without-the-href-attribute-safe)

## ブラウザの自動補完機能を無効化する
inputフォームに入力した内容がブラウザに保存され、再入力時にプルダウンで表示されるのを防ぐには、`autocomplete`属性に`off`に指定します。

```html
<input type="text" autocomplete="off">
```

[フォームの自動補完を無効にするには | MDN](https://developer.mozilla.org/ja/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion)

## Form要素の入れ子
ある`<form>`内で、特定の`input`または`button`要素の送信先URLを変えたい場合、`form`属性を利用することで離れた場所にある`<form>`要素と関連付けることが出来ます。

```html
<form id="hoge" action="/hoge"></form>
<form action="/fuga">
  <!-- 対象の<form>タグのIDを指定します -->
  <button type="submit" form="hoge">hogeに送信</button>
  <button type="submit">fugaに送信</button>
</form>
```

[&lt;input&gt;: 入力欄 (フォーム入力) 要素 #form | MDN](https://developer.mozilla.org/ja/docs/Web/HTML/Element/input#form)

## 文字入力時に現れるリセットボタンを設置する
ブラウザによっては、テキストボックスに文字を入力した際に削除ボタンが表示される場合があります。
独自に設置する場合は、削除の処理はJavaScriptを必要とします。（処理は下記JavaScriptの項を参照）

### 既存のスタイルの無効化
ブラウザのデフォルトスタイルで表示される削除ボタンを非表示にするには、`appearance`プロパティに`none`を指定します。
このプロパティは2019年3月現在は草案の段階で、ベンダープレフィックスを必要とします。

```css
input[type=text] {
  -ms-appearance: none;
  -moz-appearance: none;
  -webkit-appearance: none;
  appearance: none;
}
```

### プレースフォルダの表示状況を取得する
テキストボックスの`placeholder`の値が、表示されている状態を表す擬似要素`:placeholder-shown`を利用することで、文字が未入力の状態を取得します。
`placeholder`に何も文字を表示しない場合は、代わりにスペース`&nbsp;`を入れてください。
この擬似要素は2019年3月現在は草案の段階で、ブラウザによっては対応していない場合があります。

```html
<input type="search" placeholder="キーワード" class="search-txt">
<button type="reset" class="reset-btn" aria-label="削除する">×</button>
```

```css
/*
　placeholder表示中 → 文字未入力状態 → 削除ボタン非表示
 */
.search-txt:placeholder-shown + .reset-btn {
  display: none;
}
```

`:placeholder-shown`未対応ブラウザにも対応させる場合は、JavaScriptで文字の入力状況を監視し、CSSクラスを適用させます。

```css
/*
 文字未入力時はテキストボックスに is_empty クラスを適用させる
 */
.is_empty + .reset-btn {
  display: none;
}
```

# JavaScript編
## テキストボックスの文字の入力状況をリアルタイムで監視する
テキストボックスで入力値が変更された際には`change`イベントが発生しますが、これはフォーカスが外れたときにしか発生しません。
リアルタイムで監視するには、`keyup`イベントを利用します。
`keyup`イベントは、文字入力に限らずカーソルキーの移動なども含まれます。

```js
const elTextInput = document.getElementById('InputBox');
let inputText = '';

elTextInput.addEventListener('keyup', (e)=>{
  const newText = elTextInput.value.trim();
  // 矢印キーは無視
  if (inputText === newText) {
    return;
  }
  inputText = newText;
  console.log(inputText);
},false);
```

## iOSのSafariでもInputBoxにきちんと文字を入力してフォーカスさせる
テキストボックスの入力値をソース上で更新し、且つフォーカスを当てたい場合、基本的なコードは下記のとおりです。

```js
textInputElement.value = newKeyword; // 値を上書きor削除
textInputElement.focus();            // フォーカスを当てる
```

しかしiPhoneのSafariにて、入力した日本語が未確定の状態で上記のコードを実行した場合、文字列が上書きされない、フォーカスがあたらない、といった挙動をします。
この記事を執筆するに当たって確認した端末は、「iPhoneXS　iOS 12.1.1」です。
その他の端末、以前のOSバージョンでも同様の現象を確認しているので、恐らく現時点ではこういう仕様でしょう。
その場合は`setTimeout`関数を利用して値を上書きする処理を非同期化すると、意図した挙動を実現できます。

```js
textInputElement.focus();

setTimeout(_ => {
  textInputElement.value = newKeyword;
}, 0);
```

## Form要素へnameの値でアクセスする。
各フォーム要素へは`name`属性の値を辿ることで、HTMLのタグ構成を気にすることなく簡単に要素にアクセスできるようになります。
`name`が重複している場合は、`Array-like`という配列のようなオブジェクトの形式で取得されます。

```html
<form name="hoge">
  <input type="text" name="fuga">
  <div>
    <ul>
      <li><input type="radio" name="nyaa" value="1">1</li>
      <li><input type="radio" name="nyaa" value="2">2</li>
      <li><input type="radio" name="nyaa" value="3">3</li>
    </ul>
    <div>
      <button type="submit" name="piyo">送信</button>
    </div>
  </div>
</form>
```

```js
const formElement = document.forms.hoge,
      inputElement = formElement.fuga,
      radioElements = formElement.nyaa, // ラジオボタンのリストとして取得
      buttonElement = formElement.piyo;
```

## HTML特殊文字のエスケープ関数
外部から入力された値を表示する場合は、特殊文字のエスケープをお忘れなく。
cookieやlocalStorageに登録された値を使用する場合も同様です。
前回の登録以降に誰かが改ざんしているやもしれませぬ…。

```js
// 正規表現で該当する文字列を抽出してエンティティに置換
const escapeHtml = (str) => {
  if (typeof str !== 'string') {
    return str;
  }
  const map = {
    '&': '&amp;',
    '"': '&quot;',
    "'": '&#x27',
    '`': '&#x60',
    '<': '&lt;',
    '>': '&gt;',
  };
  return str.replace(/[&<>"'`]/g, char => map[char]);
}
```

[正規表現 | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Regular_Expressions)
[String.prototype.replace() | MDN](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/replace)

## DOMへのアクセスを最小限に留めて高速化
リストを動的に出力したい場合、`<li>`タグを一つずつ作ってはページ上の`<ul>`タグに追加するという処理では、毎回レンダリングを実施してしまうので処理が遅くなります。
文字列の状態でまとめて作って、ページへの出力は一回で済ませましょう。

```js
// <ul id="ListContainer"></ul>
// 何回もアクセスする場合は予めキャッシュを取得して、毎回アクセスしないようにする
const listContainer = document.getElementById('ListContainer');


let tmpHtml = '';
for(let value of itemList){
  const item = escapeHtml(value);
  tmpHtml += `<li>${item}</li>`;
}
// まとめてドン
listContainer.innerHTML = tmpHtml;
```

## Local Storage の操作
渡す値も貰う値も文字列です。
配列やオブジェクトを登録する場合は、`JSON.stringify()`で文字列化します。
登録されている文字列を配列やオブジェクトに戻すには、`JSON.parse()`でパースします。

|用途|構文|引数|戻り値|
|:--|:--|:--|:--|
|取得|localStorage.getItem(keyName)|keyName: 取得したいデータの名前|キーに対応するデータ<br>無い場合はnull|
|登録<br>更新|localStorage.setItem(keyName, keyValue)|keyName: 登録したいデータの名前<br>keyValue: 登録したいデータ|なし|
|削除|localStorage.removeItem(keyName)|keyName: 削除したいデータの名前|なし|

[Storage | MDN](https://developer.mozilla.org/ja/docs/Web/API/Storage)

## イベント委譲でリスナーをまとめる
各パーツに個別にイベントリスナーを設置するのではなく、親要素でまとめて対応することで、登録するリスナー数を減らしてメモリーを節約します。
また、JavaScriptで動的に出力した要素などに対して、毎回リスナーを設置する手間が省けます。

```html
<div id="root">
  <button type="button" data-param="morning">おはよう</button>
  <button type="button" data-param="afternoon">こんにちは</button>
  <button type="button" data-param="evening">こんばんは</button>
  <a href="tiredness.html">おつかれさまです</a>
</div>
```

```js
const elRoot = document.getElementById('root');

elRoot.addEventListener('click', (e)=>{
  const elTarget = e.target;

  if (elTarget.tagName.toLowerCase() !== 'button') {
    return;
  }
  console.log(elTarget.dataset.param);
},false);
```

ここでは、タグ名が`button`に該当するものを対象としています。その他に下記のような方法があります。

- 共通のclassを持つ要素: `elTarget.classList.contains('hoge')`
- 共通のdata属性を持つ要素: `elTarget.dataset.hoge`
- 共通のname属性を持つ要素: `elTarget.name && (elTarget.name === 'hoge')`
- 特定のセレクタと一致する要素: `elTarget.matches('.hoge[type="button"]')`

[Element.matches() | MDN](https://developer.mozilla.org/ja/docs/Web/API/Element/matches)
[Can I use matchesselector?](https://caniuse.com/#feat=matchesselector)

ボタン要素の配下に`<span>`等のタグがあれば、それが`target`になります。
予めCSSで`pointer-events:none`を指定しておくか、下記のように親要素を辿って目的のタグを取得します。

```js
const elRoot = document.getElementById('root');

elRoot.addEventListener('click', (e)=>{
  let elTarget = e.target;
  // ルート要素またはボタン要素にたどり着くまで
  while(true){
    if((elTarget === elRoot) || !elTarget){
      return;
    }
    if(elTarget.tagName.toLowerCase() === 'button'){
      break;
    }
    elTarget = elTarget.parentElement;
  }
  console.log( elTarget.dataset.param);
},false);
```

# サンプルコード
昨今のフロントエンドを取り巻く多様な状況を鑑みて、素のJavaScriptにしてみました。
<p class="codepen" data-height="265" data-theme-id="0" data-default-tab="js,result" data-user="bo_bee" data-slug-hash="XGeRGJ" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid black; margin: 1em 0; padding: 1em;" data-pen-title="Search Box (in editing)">
  <span>See the Pen <a href="https://codepen.io/bo_bee/pen/XGeRGJ/">
  Search Box (in editing)</a> by Bo_bee (<a href="https://codepen.io/bo_bee">@bo_bee</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>


# おわりに
学んだことを列挙したと言いつつ、記事に書き起こすに当たって新たに学んだこともありました。（特にアクセシビリティまわり）
というのも、実際に仕事で作ったのは数年前で、制作環境と対応ブラウザの関係でes5＆jQueryで作りました。
当時、HTMLがセマンティックであるかについては気にしていましたが、スクリーンリーダーのことまでは考慮に入れておりませんでした。
スクリーンリーダー以外にも、文字サイズや画面表示を拡大した際にも崩れなく読めるように、サイズを相対で指定するなどなどありますが、まだまだ要領がつかめておりません。
お洒落でかっこいいWEBサービスも魅力的ですが、やさしくて安全なWEBサービスが得意な人にもなりたいです。

# 追記
2019年3月17日の投稿直後`-webkit-appearance:none;`が効かず、文字入力時にブラウザデフォルトのリセットボタン「×」が表示されてしまう不具合を発見しました。
`type=text`に変更すると、この事象は無くなりました。
サンプルコードを制作していたのは投稿の数日前で、その時点では特に問題はありませんでした。
制作時のChromeバージョンは不明で、登校時のChromeバージョンはChrome 72.0.3626.121です。その後73になりましたが、×ボタンは変わらず表示されます。

この問題は、`[type=search]::-webkit-search-cancel-button`に`-webkit-appearance:none`を適用することで解決しました。
`[type=text]`はのキャンセルボタンは今のところ不要ですが、`[type=search]`のキャンセルボタンは別途指定が必要なようです。
問題が起きていないページのリセットCSSを確認したところ、判明しました。リセットCSSは大抵ありものを使うので、このプロパティの存在に気づいておりませんでした。
数年前からブラウザに実装されているそうで、むしろ制作時点で×ボタンが出て来なかったのは何故？という感じです。

`appearance`はまだ仕様が確定していないプロパティですが、古参のプロパティでもブラウザのバージョンアップで急に挙動が変わるということはたまにあります。
私の知識不足や投稿直前の確認不足が露呈して大変恥ずかしいのですが、自分への戒めと「初心者」タグで訪問された方々への情報提供のため、この情報を晒します。。。

慢心、ダメ、ゼッタイ

[::-webkit-search-cancel-button | MDN](https://developer.mozilla.org/ja/docs/Web/CSS/::-webkit-search-cancel-button)