Reactコンポーネントを単独で使うための細かいテク

前回のReactコンポーネントを単独で使うに書いたようにしばらくやってみて、細かいところのいい感じのやり方がわかってきた。

ディレクトリ構成

└── src/
    ├── components/
    │   ├── react/
    │   │   ├── AwesomeApp.js
    │   │   └── Disclosure.js
    │   ├── AwesomeApp.js
    │   ├── Disclosure.js
    │   └── GlobalNavigation.js
    └── main.js

初期化を担うファイルはcomponents/の直下に、Reactコンポーネントcomponents/react/に配置する。Reactコンポーネントを初期化する処理は、components/にファイルを作成した上でそこに書く。Reactじゃないコンポーネントも同じディレクトリに配置する。

stateを制御するコンポーネントでラップする

プレゼンテーショナルなReactコンポーネントには、トップダウンで状態を渡すことが多い。

const Disclosure = ({ isExpanded, onToggle }) => (
  <div className="Disclosure">
    <button
      className="Disclosure__toggle"
      type="button"
      aria-expanded={String(isExpanded)}
      onClick={() => onToggle(!isExpanded)}
    >
      toggle
    </button>
    <div
      className="Disclosure__content"
      hidden={!isExpanded}
    >
      {children}
    </div>
  </div>
)

これを単独で機能させるというのをやりやすくするために、簡単にstateを管理できるコンポーネントを作る。あるいはreact-valueがちょうど良い。

import React from 'react'
import ReactDOM from 'react-dom'
import { Value } from 'react-value'
import Disclosure from './react/Disclosure'

export const init = () => {
  document.querySelectorAll('.react-Disclosure').forEach((containerEl) => {
    const initialExpanded = containerEl.dataset.open === 'true'
    ReactDOM.render(
      <Value
        defaultValue={initialExpanded}
        render={(value, onChange) => (
          <Disclosure
            isExpanded={value}
            onToggle={onChange}
          />
        )}
      />
      containerEl,
    )
  })
}

クラス名の命名規則

ReactコンポーネントReactDOM.render()で描画する以上、マウントする対象の要素が必要になる。そのため、単独で利用するReactコンポーネントには必ずラッパー要素ができる。

コンポーネントのクラス名が.Disclosureの場合、そのラッパー要素のクラス名は.react-Disclosureにする。このラッパー要素は、利用方法によって存在したりしなかったりするので、あってもなくても同じ振る舞いをするように実装する。

ラッパー要素のスタイル宣言は、内包するコンポーネントと同じファイルに記述する。

_Disclosure.scss:

.react-Disclosure {
  ...
}

.Disclosure {
  ...
}

data-*属性をコンポーネントに渡すpropsとして利用する

上記の例でもそうしたけど、ラッパー要素のdata-*属性にReactコンポーネントへ渡すpropsを設定しておくと便利。

<div class="react-Disclosure" data-open="true"></div>

propsへHTMLを渡したいときは、例えばPugなら次のようにすればいい。

div.react-Disclosure(hidden)
  ul
    li foo
    li bar

innerHTMLをpropsに渡しつつ、描画が終わったらhidden属性を取り除く。あまりきれいじゃないけど。

配列やオブジェクトを渡したいときは次のようにする。

- const array = JSON.stringify(['foo', 'bar', 'baz'])
- const object = JSON.stringify({k: 'val'})
div.react-MyComponent(data-array=array data-object=object)

data-*属性の値をJSON.parse()してpropsに渡す。無駄感はあるけど、テンプレートの管理の楽さとかを考えるとまあこれでいいかなという感想。

Reactコンポーネントを単独で使う

普通の静的なHTMLのサイトの中で、限定的に複雑になる部分だけをReactの小さいアプリとして実装するというパターンを個人的によくやる。その際に、Reactの中でもそれ以外の部分でも使うコンポーネントがあって、どう実装すればいいかと悩んだ。

というのも、普通のJSでの実装をReactで再利用できるように作るのは、初期化とかライフサイクル的に大変だ。あるいはもし、同じコンポーネントをそれぞれの専用に実装すると、お互いの挙動を一貫させるのが大変だし、UIの仕様が変わったときには追従に倍の手間が掛かる。

どうするもんかなと考えると、共有するコンポーネントはReactで実装して、利用する箇所ごとにReactDOM.render()すればいいことに気づいた。単純。

例えば、次のようなコンポーネントを共有したいとする。

import React from 'react'

export default class Disclosure extends React.PureComponent {
  render() {
    return ...
  }
}

Reactアプリの外で利用するには次のようにする。

<div class="js-react-disclosure" data-heading="Section heading">
  <p>Lorem ipsum dolor...</p>
</div>
import React from 'react'
import ReactDOM from 'react-dom'
import Disclosure from '../react/Disclosure'

document.querySelectorAll('.js-react-disclosure').forEach((el) => {
  const heading = el.dataset.heading
  const children = el.innerHTML
  ReactDOM.render(
    <Disclosure heading={heading}>{children}</Disclosure>,
    el,
  )
})

簡単な例はGitHubに上げた

propsの渡し方については、PugでJSONのデータをHTMLにJSON.stringify()して埋め込んでJS側でパースするとかでも良さそう。

全部Reactで(別にVue.jsでもいい)できたらなーとは思うものの、要件によってはそれが効率的でないことも多い。Custom ElementsとShadow DOMが使えれば完璧に解決するんだけど、ポリフィルもバグがあったり欲しい機能が実装できなかったりで厳しそうだし、10年後かなー、あー、みたいなことを最近は永遠に考えてる。コールドスリープしたい。

フォーカスリングの役割とマウスユーザーに向けた対応について

ブラウザは、フォーカスされた要素を可視化するためにフォーカスリングを実装している。青や黒のフォーカスされた要素を囲う枠線のことだ。outlineプロパティで表現される。これは主に、ウェブページをキーボードで操作可能にするためにある。

例として、ウェブページ上のボタンをクリックするという場面を想定する。キーボードによる操作では、まず、Tabキーなどによってフォーカスの位置を移動させながら、フォーカスを目的の要素まで辿り着かせる必要がある。その上で、スペースキーやエンターキーによってクリックに相当するアクションを実行する、というような流れになる。このような操作を行うためには、どの要素がフォーカスされているかを、ユーザーが視覚的に認識できることが前提になる。フォーカスリングが実装されているおかげで、開発者は適切なマークアップを行うだけでフォーカスを可視化することができる。

しかしながら、マウス操作を行うユーザーにとってはフォーカスの可視化は重要でないと言えそうだ。キーボードユーザーは、フォーカスを経てアクションを実行することが多い。だが、マウスユーザーは、要素をフォーカスさせるという行動を経由する必要が無いため、フォーカスを意識する必要性が低い。むしろ、フォーカスを意識していないため、フォーカスが可視化されているために操作上での混乱を呼ぶという可能性もある。

ブラウザはフォーカスリングを必要に応じて表示する。<button>要素の場合、マウスでのクリックによってフォーカスされた際にはフォーカスリングは表示されない。だが、キーボード操作によってフォーカスされた際にはフォーカスリングが表示される。これは前述したように、マウスユーザーにとってはフォーカスが可視化されている必要性が低いという考えに基づいている。とは言うものの、これは開発者によってスタイルが設定されてない場合の<button>要素の挙動であり、任意のスタイルを設定した途端、入力デバイスに関わらずフォーカスリングが表示されるようになってしまう。
一方、テキストフィールド(textタイプの<input>要素など)の場合、フォーカスリングは入力デバイスに関わらず表示される。これは、マウスによってフォーカスされようとも、キーボードによる入力が続く可能性が高いためだと考えられる。キーボード入力を行う以上、ユーザーはマウス操作時ほど明確に入力対象の要素を認識できない。そのため、キーボード入力が主になるUIの場合、フォーカスするために用いられた入力デバイスに関わらず、フォーカスリングは表示すべきだろう。また、項目数の多いフォームへ入力を行う際は、どのフィールドに対して入力途中であるかが明確になり、一時的にフォームから注意がそれてしまっても容易に入力を再開できるという利点もある。

以上の事情を考慮すると、ボタンはキーボード入力時のみ、テキストフィールドは常にフォーカスリングが表示されて欲しい。幸いにも現在のところ、このようにフォーカスリングを表示すべき場合にだけマッチする:focus-ring疑似クラスが提案されている:focus-visibleにリネームされた。これが実装されると、次のような宣言によって求めている挙動を実現できる。

:focus {
  outline: none;
}

:focus-ring {
  outline: 2px dotted dimgray;
}

マウス入力の際にどの要素がマッチしないかという判断に関しては、Firefox:-moz-focusring疑似クラスという独自実装や、focus-ringポリフィルを参考にできる。

残念ながら、:focus-ring疑似クラスはまだ実装されていない仕様であるため、代わりにJavaScriptを用いてそれに相当するような実装をすることになる。What Input?というライブラリを利用すれば、入力デバイスを検出してCSSから参照することができる。検出した入力デバイス<html>要素のdata-*属性に反映されるため、セレクタとして次のように利用するパターンが考えられる。

@mixin focus-with-keyboard {
  html[data-whatinput="keyboard"] &:focus {
    @content;
  }
}

@mixin focus-without-keyboard {
  html[data-whatinput="initial"] &:focus,
  html[data-whatinput="mouse"] &:focus,
  html[data-whatinput="touch"] &:focus {
    @content;
  }
}

@mixin focus-clear {
  outline: none;
}

@mixin focus-clear-without-keyboard {
  @include focus-without-keyboard {
    @include focus-clear;
  }
}
// キーボード操作時以外はフォーカスリングを非表示
.my-button {
  @include focus-clear-without-keyboard;
}
// デフォルトのフォーカスリングを非表示、キーボード操作時のみ独自のフォーカススタイルを適用
.my-button {
  @include focus-clear;

  @include focus-with-keyboard {
    background-color: lightgray;
    outline: 2px solid red;
  }
}
// デフォルトのフォーカスリングを非表示、常に独自のフォーカススタイルを適用
.my-text-field {
  @include focus-clear;

  &:focus {
    outline: 3px solid green;
  }
}

少なくないウェブサイトからフォーカスリングが取り除かれてしまっているのは、マウスユーザーに対する個別最適化の結果だろう。そもそも開発者でさえフォーカスを意識していないことすら考えられる。これらは、フォーカスの明示がマウスユーザーに混乱を与えているということの結果ではないか。とすると、フォーカスリングの見た目が悪いから代替のスタイルを設定する、というような話は論点がズレていて、フォーカスを意識する必要のないユーザーに対してもフォーカスを明示していたことが間違いだったと考えるべきではないか。

キーボードユーザーにのみフォーカスリングを表示するという実装方法が、これからのデファクトになっていくべきだと僕は思う。

cssnextを使うべきか

cssnextは、未来のCSS構文を今のブラウザでも解釈できるようにトランスパイルするPostCSSプラグインだ。そう聞くとさも、将来のCSSの書き方をそのまま先取りできる素晴らしいツールであるような印象を抱く。だが実際は、cssnextで表現できる形と標準の仕様は大きく異なっていることがある。cssnextを前提にして書いたコードは、未来のブラウザで違う挙動をする可能性があるということだ。

cssnextは、単一の機能を持ついくつかのPostCSSプラグインをまとめたプラグインセットだ。それぞれのプラグインは単に、ある構文を現在のブラウザでそれっぽく動くコードに変換することしかできない。対象とされている構文の多くは、現状の実装でフォールバックすることが不可能であるため、プラグイン作者の主観に基づいたなんちゃって実装に置き換えるしかないのだ。
そのため、cssnextを通して利用できる構文のいくつかは、実際の仕様と異なった形で利用する必要があり、仕様と異なった挙動をするコードを生成する。

標準の仕様と異なった利用方法をするプラグインとして、postcss-custom-propertiesは顕著な例である。カスタムプロパティは、動的でDOMスコープのプロパティであるという仕様だ。対してこのプラグインでは、プリプロセッサ上でのみ有効な変数として解釈されて、Sassのように静的な値に置き換えられる。これではカスタムプロパティを利用する意味が変わってしまうので、本来の仕様の意図から外れたコードを書かざるを得ない。

ほとんどのプラグインは、仕様通りの挙動を実現できないにも関わらず、無理やり標準の構文を利用することを目的にしてしまっている。それによってもたらされる弊害は、標準の構文を利用できるという利点よりも大きいだろう。
ユーザーが標準の仕様を誤解してしまうことも考えられるし、コードが仕様通りに動かないことを理解するためにプラグインの知識が必要になることもある。

また、標準と言えども破棄されてしまう仕様もある。仕様を追い続けた上で、利用していた構文が使えなくなったと決まれば、既存のコードを書き直す必要もあるだろう。

だったら無理に未来の構文に憧れを抱くのではなく、プリプロセッサの独自構文に身を委ねてしまったほうが安心できるはずだ。幸いSassは市民権を得た当たり前の言語になった。極端なコードを書かない限り、Sassに依存していることは大きな問題にならない。プリプロセッサ無しでみんなが書きたいCSSを書けるようになるのは、どうせかなり先の未来だろうし。


蛇足ながら、妥当に利用できそうなプラグインについても考えた。基準は次のふたつ。

  • 未来の仕様と全く同じ挙動を実現できる(単に糖衣構文である)
  • 仕様自体が破棄される可能性が高くない(Working Draft以降)

これに基づいて次のプラグインを選択できた。

列挙した全てのプラグインがベースにしている仕様は、現在Working Draftだ。慎重に考えるとどのプラグインも利用すべきではないのかもしれない。とは言え、僕にも未来の構文を使いたいという願望はある。ちょっとした個人プロジェクトでは利用してみたい。

日本語向けフォントスタックの現状

日本語のウェブサイト向けのフォントスタックの現状と無難な設定についてまとめた。sans-serifserifsystem-uiのそれぞれの総称フォントファミリーに基づいて、主要な端末(WindowsMaciOSAndroid)のフォントの搭載状況を整理する。

sans-serif

まず、Windowsメイリオ一択だと考えたほうが良い。游ゴシックはWindows 8.1ではかなり細く、Windows 10でも一般的なフォントと比べると少し細いのが問題だ。ハック的に回避する方法はあるものの、積極的に採用したくはない。メイリオWindowsユーザーにとって馴染みがあり、最も問題になりにくいフォントだと考えられるため、あえて別の選択をする必要性は低いと思う。
Yu Gothic UIという選択肢もあるが、本文向きでは無さそうだ。

Macでは問題なく游ゴシックが利用できるので、ヒラギノ角ゴシックかいずれかを選択できる。
ヒラギノ角ゴシックを選択する場合、ファミリーの名称はこれまでのようにHiragino Kaku Gothic ProNとするか、新しいHiragino Sansとするかが悩みどころになる。前者はウェイトのバリエーションがW3とW6の2段階しかなく、後者はW1からW9までの9段階あるのが違いだ。ウェイトの多い別のフォントとの併用を考えると、Hiragino Sansを指定しておく方が見た目の印象を一貫させやすいだろう。
注意点は、Hiragino Kaku Gothic ProNHiragino Sansにそのまま置き換えると違うウェイトになることがあるということ。CSSfont-weight: normalが指定されていると、前者ではW3が適応され、後者ではW4になる。boldの場合、前者ではW6、後者ではW7だ。ウェイトのバリエーションが増えたことにより、CSSの指定に対応するフォントが変わるということだ。あえてW3を利用したい場合、font-weight: 300と指定する必要がある。どのウェイトが最適かは場合によりけりだが、これまで慣れた見た目と違うものになり得ることは頭に入れておいたほうが良い。
ただし、ウェイトのバリエーションが増えるのはMacのみで、iOSはこれまでと同じくW3とW6だ。

これらを踏まえると次のような指定になる。

$font-stack-sans-serif: "Hiragino Sans", "Meiryo", sans-serif;

Windowsの主要なブラウザでは、sans-serifを指定するとメイリオが選択される。だが、Windows 7のIE11に限ってはMS Pゴシックで表示される。それを上書きするために指定する必要がある。
また、古いバージョンのMS Office(Office for Mac 2011?)をインストールすると、Macでもメイリオが有効になってしまう。Macではメイリオが選択されてしまわないようにするため、ヒラギノ角ゴシックを先に指定する。

Macで游ゴシックを利用したい場合は次のようになる。

$font-stack-sans-serif: "YuGothic", "Hiragino Sans", "Meiryo", sans-serif;

iOSには游ゴシックがインストールされていないため、ヒラギノ角ゴシックで表示されるように指定する。

Hiragino SansOS X El Capitan及びiOS 9以降から搭載されたフォントなので、それ以前のバージョンもサポートするには、Hiragino Sansに続いてHiragino Kaku Gothic ProNも指定する必要がある。

serif

Windowsには游明朝を利用させる。游ゴシックと同様に細く見える問題はあるが、MS P明朝と比べるとかなりいい状態で読める。現在のChromeではserifの既定のフォントは游明朝になっており、Firefox 57以降でも同じように変更されることになっている。

Macではヒラギノ明朝か游明朝を選択できる。ヒラギノ明朝を利用する場合は次のようになる。

$font-stack-serif: "Yu Mincho", serif;

游明朝を利用する場合は次のようになる。

$font-stack-serif: "YuMincho", "Yu Mincho", serif;

遊書体はWindows 8.1以降から搭載されたフォントであるため、Windows 7ではMS P明朝になる。
また、Android端末の場合、serifに対応するフォントは搭載されていない。ウェブフォントを利用するか、諦めてsans-serif等で代用するしかない。

system-ui

system-uisans-serifなどと同じ総称フォントファミリーだ。CSS Fonts Module Level 4で追加された。プラットフォームのUIと同じフォントを利用できる。現状ではChromeのみで実装されており、その他のブラウザのためにはフォールバックを書く必要がある。
フォールバックを含めた指定方法はいろいろ紹介されているが、英語圏のブログに書かれているような指定は日本語向きではなさそうだ。

Chromesystem-uiを指定した場合、MaciOSではSan Franciscoになる(ヒラギノ角ゴシックは含まれていない)。Windows 10ではYu Gothic UIで、Windows 8.1以前ではSegoe UIMeiryoの混植。AndroidではRobotoNotoの混植。
ブラウザをまたいでもこれと同じ結果にするためには、次のように指定する。

$font-stack-system-ui: system-ui, -apple-system, "Hiragino Sans", "Yu Gothic UI", "Segoe UI", "Meiryo", sans-serif;

それぞれの値の意味は次のようになる。

$font-stack-system-ui:
  // 1. OS X Chrome(欧文)、Windows Chrome(和文・欧文)、Android Chrome(和文・欧文)
  system-ui,

  // 2. OS X Safari(欧文)、iOS Safari(欧文)、OS X Firefox(欧文)
  -apple-system,

  // 3. OS XとiOS全て(和文)
  "Hiragino Sans",

  // 4. Windows 10 Chrome以外(和文・欧文)
  "Yu Gothic UI",

  // 5. Windows 8.1以前 Chrome以外(欧文)
  "Segoe UI",

  // 6. Windows 8.1以前 Chrome以外(和文)(Windows 7のIE11用の指定)
  "Meiryo",

  // 7. その他
  sans-serif;

Mac及びiOSの場合、1か2の指定でSan Franciscoを利用できる。それだけだと和文の指定が無いので3を指定する。
Windows 10の場合、Chromeは1の指定、その他のブラウザは4の指定でYu Gothic UIになる。Yu Gothic UI自体にSegoe UIが含まれているため、あえてSegoe UIを指定する必要はない。
Windows 8.1以前の場合、Chromeは1の指定、その他のブラウザは5と6の指定でSegoe UIMeiryoの混植になる。
Android Chromeは1の指定でRobotoNotoの混植になる。

San FranciscoはOS X El Capitan及びiOS 9以降から搭載されたフォントなので、それ以前のOSもサポートすると、Hiragino Sansの前にHelvetica Neueの指定が必要。加えて、Hiragino Sansも同じタイミングで搭載されたフォントなので、その後ろにHiragino Kaku Gothic ProNの指定が必要になる。

参考


よくある「font-familyの最適な指定」系の記事を見ると、違う文脈のファミリーをひとつのフォントスタックに押し込んで、自分の好きな順番に並べただけのようなまとめ方が多いと思っていた。なのでこの記事では、総称フォントファミリーを基準にした一般的(汎用的)な分類でまとめた。自分の主観に依らない視点になっていると嬉しい。

:focus-ringの代用としてwhat-inputを試す

前回の記事で紹介した:focus-ringのポリフィルはイマイチだった。element.focus()で制御したときにいい感じにならないというつぶやきを見て知った。

具体的な例として、モーダルを閉じた後の挙動について考えたい。
モーダルを閉じた後は、フォーカスはモーダルを開いたボタンに戻るように実装する。そのために、button.focus()のようにしてフォーカスをスクリプトで制御する。この際、:focus-ringのポリフィルだとfocus-ringが有効になる処理が実行されず、キーボード操作をしていても適切なスタイルが表示されないことになる。

調べてみるとWhat Input?という似たライブラリがあった。ユーザーの入力端末を検出する機能があり、同じくfocus-ringの制御をすることが目的らしい。:focus-ringのポリフィルとは違い、入力端末の検出結果をhtml要素の属性などを通して公開している。フォーカスのスタイルはそれに基づいて設定すればいい。これを利用すれば、意図した通りにフォーカスのスタイルを機能させることができた。

提供される状態は、initialmousekeyboardtouchのいずれかだ。initialはまだ検出できていないことを示す。マウスとタッチデバイスではoutlineを非表示にしたいので次のようにする。

[data-whatinput="mouse"] :focus,
[data-whatinput="touch"] :focus {
  outline: none;
}

また、E:focus-ringと同じ意図を示すセレクタは次のようになる。

html:not([data-whatinput="mouse"]):not([data-whatinput="touch"]) .awesome-button:focus {
  // focus-ring style
}

ただこれでは冗長だ。E:hoverと併記したいことも考えると、Sassで次のように抽象化できる。

@mixin focus-ring() {
  html:not([data-whatinput="mouse"]):not([data-whatinput="touch"]) &:focus {
    @content;
  }
}

.awesome-button:hover {
  background-color: red;
}

.awesome-button {
  @include focus-ring() {
    @extend .awesome-button:hover;
  }
}

多分これで問題なく:focus-ring風の実装ができるはずだ。将来的に未知の入力端末が登場しても、マウスとタッチデバイス以外ではフォーカスのスタイルが表示されるため、ウェブサイトが操作不能になる可能性は低いと思う。
CSSセレクタが複雑になってしまう問題はあるが、フォーカスのスタイルを非表示にしたいという要望とのトレードオフだろう。

本当は:focus-ringがネイティブで実装される日を待ちたいが、これが今のところの現実解なんだと思う。

outline: noneをやめよう、focus-ringを使おう

次のようなスタイルが指定されたサイトを見かけることがある。

* {
  outline: none;
}

ボタンなどの要素をクリックしたときに、格好の悪いアウトラインが表示されてしまうのを打ち消したい、という意図だと思われる。

ボタンをクリックするとその周りに格好の悪いアウトラインが表示されてしまう

だが、上記のような指定をしてはいけない。サイトをキーボード操作することができなくなってしまうからだ。

クリックされたボタンはフォーカスされる。フォーカスされているということを視覚的にユーザーに伝えるためにアウトラインが表示される。キーボード操作するためには、現在のフォーカス位置が明示されている必要がある。上記のような方法によってフォーカス位置の手がかりを奪ってしまうと、キーボードユーザーにはそのサイトが利用できなくなってしまう。

とはいえ、マウスのみでサイトを利用するユーザーにとっては過剰な装飾に見えるかもしれない。マウスユーザーにとってはフォーカス位置が明示されている必要性は低いからだ。この考え方に基づいたアイデアとして、キーボード操作時のみフォーカスを明示するというものがある。

現在、:focusの代わりに:focus-ringという擬似クラスを使ってその機能が利用できる、という仕様を標準にするための作業が進められている(Editor’s Draft)

それに近い機能を実装してくれるポリフィルもあり、それを利用することで今から:focus-ringもどきなことができる。これがイマイチだったので代替手段を紹介しました。

キーボードでの操作性を確保しつつ、マウスユーザーには不要なアウトラインを表示しない。見た目の良さかアクセシビリティか、という二者択一にならない現実的な妥協点だと思う。積極的に利用していきたい。

参考

見やすいスタイル  |  Web  |  Google Developers