より良いリンクの下線の実装
前回、リンクには下線を付けようという記事を書いた。が、実際のところ、デフォルトのリンクのスタイルはあまりイケてないと思ってる。リンク色と同色の下線は視覚的な主張として強すぎるし、下線の位置が文字の下端に隣接し過ぎていて見づらい。
幸いにも、CSS Text Decoration Moduleにはあまり知られていない便利なプロパティがあり、この野暮ったさを解消できそうに見える。text-decoration-color
では下線の色を指定できる。IE以外では実装されているので、プログレッシブエンハンスメントということにすれば問題無さそうだ。
a { color: hsl(240, 100%, 47%); text-decoration: underline; text-decoration-color: hsla(240, 100%, 47%, 0.5); }
text-underline-position
を使えば下線の位置を変更できる。次の宣言によってテキストの下に下線を配置できる。このプロパティの値は継承されるので、html
要素に指定しておくと良いだろう。
html { text-underline-position: under; }
ただし、こちらはChromeにしか実装されていない。手軽にはできるので、一部のユーザーだけでもいい感じにしたいのであればこれを指定しておくと良いかもしれない。
これらはCSS Text Decoration Module Level 3の範囲だが、CSS Text Decoration Module Level 4を眺めてみるともう少し可能性が広がりそうだ。前述の内容に関連したところだと、text-underline-offset
は下線の位置を<length>
型で指定できる。また、text-decoration-width
では下線の幅を指定できる。下線を引くためにborder
とかでがんばるのはあまりきれいじゃないと思っているので、この辺には期待したいところ。
現段階において独自の下線を実装するためには、やはりborder
を使うのが一番簡単だ。もし、文字のディセンダーを下線が横切らないようにしたいということなら、text-decoration-skip-ink
とか、text-shadow
とlinear-gradient()
を使ったハックがある。が、単に文字とぶつからないだけ下線の位置を下げるのが一番単純である。border
といっしょにpadding
を指定してやればいいだけだ。
a { padding-top: 2px; padding-bottom: 2px; color: hsl(240, 100%, 47%); text-decoration: none; border-bottom: 1px solid hsla(240, 100%, 47%, 0.5); }
ターゲットサイズが下にだけ伸びしてまうのが気持ち悪いので、padding-bottom
と同じサイズをpadding-top
にも指定している。
見た目を調整してあげれば下線は取り除いてしまわなくても済みそうだ。
で、どうですか?
:hoverの誤用について
ある要素がマウスオーバーされたことを伝える。それ以上の役割を:hover
に紐付くスタイルに持たせるべきではない。マウスオーバーするまでその要素のインタラクションがわからないデザインにしてはいけないのだ。
マウスオーバーするまでその要素がリンクであるという確信が持てないデザインによく遭遇する。それが本当にリンクであるか確かめるためには、わざわざマウスカーソルを移動させて試してみるしかない。スマホではタップだ。もし、ただのテキストだと期待して誤操作すれば、ページを戻る操作も必要になるかもしれない。
:hover
のスタイルを指定できることを拠り所にしてしまい、通常状態ではその振る舞いを期待させられる見た目になっていないことがよくある。しかし、マウスオーバーするということはインタラクションコストが掛かるということだ。また、:hover
によって何かしらの手掛かりが得られるかもしれないということにすら気づかれないことも当然ある。全ての要素にマウスオーバーしてくれるユーザーはほぼ存在しない。スマホにおいては無意味だ。
:hover
は、マウスオーバーされたということのみを伝えるために利用すべきだろう。それを意識することによって、マウスカーソルを見失いやすいユーザーやそういった状況を補助することができたり、要素のクリッカブルなエリアの境界をよりわかりやすくしたりできる。背景色など、変化が広い面で即座に見えるスタイルが好ましそうだ。
とは言え、:hover
のルール作りは思いの外問題を抱えがちだ。:hover
状態と通常状態の差をつけるために、いずれかを可視性の悪いスタイルにしてしまうこと。状態のバリエーションを増やすために見た目の一貫性を失ってしまうこと。そしてやはり、:hover
のスタイルで補うことに甘えてしまうことだ。:hover
のスタイルを実装することはもはや当たり前のように思われていることもあるが、あえて無視してしまった方が良い理由もある。:hover
のスタイルに振り回されることでそれ以外の部分に悪影響が及ぶのであれば、むしろ僕は積極的に:hover
をやめてしまうことを提案したい。
僕がこれについて気になったきっかけは、テキストリンクにマウスオーバーすると下線が付くという良くあるパターンには問題があると感じたからだ。それらの多くは、本文中のリンク以外の部分と色のみで区別されている。マウスオーバーするまでは、色以外の方法でリンクを区別する術が無い。
色は情報を区別するための手段として脆い。ディスプレイの品質や設定、部屋の照明や日の当たり具合によって容易に意味を失ってしまう。また、色覚異常やロービジョンなどの理由で色の区別が難しいユーザーも非常に多い。そのため、WCAGでは色が要素を判別するための唯一の手段になっていないことが達成基準となっている。前述したパターンでは、:hover
状態になるまでリンクであることを伝えていないことと同義となる。
ある要素がリンクであるということを伝えるとき、下線を引くことはこれ以上にないくらいに当たり前の表現だ。例えば、下線のないテキストリンクをリンクであると判断することはかなり文脈依存である。ボタンっぽい見た目のリンクも、それが本当にボタンなのかどうかの判断が難しいシーンがある。下線があればそれがリンクなのか迷うことはほとんど無い。ウェブにおいてかなり強い慣習だからだ。リンクから安易に下線を取ってしまわず、慣習に基づいてうまくやる方法を考えたいところだ。
Web Componentsを待ち望んでいる話
某所でWeb Componentsについて少し話す機会があったんですが、下調べが不十分で誤った意見を述べてしまってました。代わりにこの記事を出すことで訂正とさせてください、という意味で書きます。
コンポーネントを実装するための基盤となる仕組み
Web Componentsは、Custom ElementsやShadow DOMなどのいくつかの技術から構成される仕様だ。この記事ではそれぞれの詳しい仕組みについては説明しない。必要に応じてこの辺の記事を参照いただきたい。
これらは一言でいうと、コンポーネントを実装するための基盤になる仕組みである。Reactなどのコンポーネントライブラリのようなことを標準のAPIを用いて実装できる。だからと言って「あーSPAとか作るときに使うやつね」で終わる話でなくて、普通のウェブサイトを実装するときにこそ求められる機能だと思っている。特にSPAじゃないけどそこそこJavaScript書くようなページで。
ライブラリを利用せずにコンポーネントを実装する基盤となる仕組みを持ち込める
コンポーネントライブラリのような仕組みを用いずに、コンポーネントのようなものを実装しようとするとなかなか難しい。テンプレートの管理、初期化のタイミング、状態の反映など、それぞれのコンポーネントがよしなに処理してくれれば済む処理をプログラマーが自分で制御しなければならないからだ。
例として、次のような要件を含むページを実装するとする。
- サーバーで描画されるHTMLと、非同期的に追加されるHTMLに同じコンポーネントが含まれている
- 非同期的に追加されるコンポーネントのイベント発火時に状態を出力する
- それらのコンポーネントに影響を及ぼすページ固有の処理がある
単体で見るとそこまで難しくないが、組み合わさるとそこそこ見通しの悪いコードになる。コンポーネント固有の処理は簡易的に分離しつつ、ページ固有の処理はmain.js
に書くという設計で次のように実装してみた。
index.html
:
<header> <h1>Awesome Web Components</h1> <button id="open-all">Open all of sections</button> </header> <section class="Disclosure"> <h2 class="Disclosure__heading"> <button class="Disclosure__trigger" type="button">About</button> </h2> <div class="Disclosure__content" hidden> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p> </div> </section> <div id="products"></div>
components/Disclosure.js
:
import EventEmitter from 'events' const initializedEls = new WeakSet() export const create = (rootEl) => { // prevent double initialization if (initializedEls.has(rootEl)) { throw new Error('Disclosure has already been initialized') } initializedEls.add(rootEl) const triggerEl = rootEl.querySelector('.Disclosure__trigger') const contentEl = rootEl.querySelector('.Disclosure__content') let isExpanded = false const emitter = new EventEmitter() const open = () => { if (isExpanded) { return } contentEl.hidden = true isExpanded = true emitter.emit('toggle', isExpanded) } const close = () => { if (!isExpanded) { return } contentEl.hidden = false isExpanded = false emitter.emit('toggle', isExpanded) } const toggle = () => { if (isExpanded) { close() } else { open() } } triggerEl.addEventListener('click', toggle) return { open, close, toggle, on: emitter.on.bind(emitter), } } export const template = ({ trigger, content, initialExpanded }) => ` <section class="Disclosure"> <h2 class="Disclosure__heading"> <button class="Disclosure__trigger" type="button">${trigger}</button> </h2> <div class="Disclosure__content" ${initialExpanded ? '' : 'hidden'}> ${content} </div> </section> `
main.js
:
import * as Disclosure from './components/Disclosure' const disclosures = [] let shouldOpenAll = false // initialize to server side rendered markup document.querySelectorAll('.Disclosure').forEach((rootEl) => { const disclosure = Disclosure.create(rootEl) disclosures.append(disclosure) }) document.querySelector('#open-all').addEventListener('click', () => { if (shouldOpenAll) { return } disclosures.forEach((disclosure) => { disclosure.open() }) shouldOpenAll = true }) // load from Web API and append components ;(async () => { const res = await fetch('/api/products') const { products } = await res.json() const productsContainerEl = document.querySelector('#products') productsContainerEl.innerHTML = products .map(({ name, content }) => Disclosure.template({ trigger: name, content, initialExpanded: shouldOpenAll, }), ) .join('') productsContainerEl.querySelectorAll('.Disclosure').forEach((rootEl, idx) => { const disclosure = Disclosure.create(rootEl) const product = products[idx] disclosure.on('toggle', (isExpanded) => { console.log(isExpanded) }) disclosures.append(disclosure) }) })()
コンポーネント固有の処理は分離しつつも、main.js
で気にしなければいけないことが多過ぎる。いくつかある問題点としては次だ。
設計による問題もあるが、簡易的にやろうとする限りはどれも一長一短だろう。
これらの処理を上手いことやるためのライブラリはたくさんある。たくさんあり過ぎて、多くの人が混乱して嫌悪感さえ抱くほどに。
SPAを作らない人にとってそれらのライブラリは高機能過ぎる。機能はもっと最小限で必要十分だし学習量も減らせる。Web Componentsを利用すれば、ライブラリに振り回されることなく、簡単に堅牢なコンポーネントを実装できるようになるのだ。
先ほどの例をWeb Componentsを利用して書き直してみる。
index.html
:
<header> <h1>Awesome Web Components</h1> <button id="open-all">Open all of sections</button> </header> <my-disclosure> <span slot="trigger">About</span> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p> <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p> </my-disclosure> <div id="products"></div>
elements/my-disclosure.js
:
export default class MyDisclosure extends HTMLElement { static get observedAttributes() { return ['open'] } get open() { return this.hasAttribute('open') } set open(val) { if (val) { this.setAttribute('open', '') } else { this.removeAttribute('open') } } constructor() { super() this.attachShadow({ mode: 'open' }).innerHTML = ` <style> #trigger {} #content {} </style> <section> <h2> <button id="trigger" type="button"> <slot name="trigger"></slot> </button> </h2> <div id="content" ${this.open ? '' : 'hidden'}> <slot></slot> </div> </section> ` } connectedCallback() { const triggerEl = this.shadowRoot.querySelector('#trigger') triggerEl.addEventListener('click', () => { this.open = !this.open }) } attributeChangedCallback(name, oldVal, newVal) { if (name === 'open' && oldVal !== newVal) { const contentEl = this.shadowRoot.querySelector('#content') contentEl.hidden = !this.open this.dispatchEvent(new CustomEvent('toggle')) } } }
main.js
:
import MyDisclosure from './elements/my-disclosure' customElements.define('my-disclosure', MyDisclosure) let shouldOpenAll = false document.querySelector('#open-all').addEventListener('click', () => { if (shouldOpenAll) { return } document.querySelectorAll('my-disclosure').forEach((el) => { el.open = true }) shouldOpenAll = true }) // load from Web API and append components ;(async () => { const res = await fetch('/api/products') const { products } = await res.json() const productsContainerEl = document.querySelector('#products') products.forEach(({ id, name, content }) => { const disclosure = document.createElement('my-disclosure') disclosure.open = shouldOpenAll disclosure.innerHTML = ` <span slot="trigger">${name}</span> ${content} ` disclosure.addEventListener('toggle', () => { console.log(disclosure.open) }) productsContainerEl.appendChild(disclosure) }) })()
このようにかなり簡単にコンポーネントを実装できるようになった。初期化はHTMLが追加されたタイミングで自動的に行われ、ドキュメント上に存在するインスタンスはdocument.querySelector()
で取得できる。DOMの構造は隠蔽され、利用方法だけ知っていれば良くなった。
ページ上に存在するコンポーネントの複雑性が増えるにつれて、こうして設計が単純化された恩恵がより大きくなっていく。この辺はReactと変わらないが、より少ないコストで実現できるようになるというところが大きい。
ライブラリをまたいで利用できるコンポーネントを実装できる
固有のライブラリに依存しない実装になることで、複数のライブラリで互換性があるように作れるようになる。React、Vue.js、Angularなど、それぞれのライブラリ固有に実装されたコンポーネントは、基本的には同じライブラリでしか利用できない。Custom Elementsという標準仕様に則って実装することで、ライブラリは仕様に追従してそれをサポートできるようになるというわけだ。
これによって、様々な場面で利用できるUIコンポーネントがnpmなどで公開されやすくなることが期待できる。現状では、同じような機能のコンポーネントが、ライブラリごとに実装し直されて公開されることがほとんどである。互換性が容易に実現できるようになれば、コンポーネント提供側のメンテナンスコストが下がり、利用側のユーザーは機能を主体として選択できるようになる。これによって、プロジェクトとしても息の長いものになり、機能や品質が洗練されていきつつ、多様性も生まれやすくなるはずだ。
現在の課題としては、主要ライブラリがCustom Elementsの利用を十分にサポートしていないことがある。具体的にはReactにおいて、プロパティ(属性ではなく)を渡すこと、関数を渡すことを宣言的に書けない。
プロパティを渡すというのは、setAttribute()
するのではなくてelement.prop = data
のようにすることだ。属性を経由すると文字列しか渡すことができない。配列やオブジェクトなどを渡すためにはプロパティを介する必要がある。
Vue.jsでは既に次のように実現できるようになっている。
App.vue
:
<template> <div id="app"> <my-dropdown :options.prop="options" @toggle="toggle"></my-dropdown> <form @submit.prevent="submit"> <label> <input type="text" v-model="text"> <button type="submit">add</button> </label> </form> </div> </template> <script> export default { name: 'app', data() { return { text: '', options: [ { key: 0, text: 'foo' }, { key: 1, text: 'bar' }, { key: 2, text: 'baz' }, ], } }, methods: { toggle() { console.log('dropdown toggled') }, submit() { const lastKey = this.options.slice(-1)[0].key this.options.push({ key: lastKey + 1, text: this.text }) this.text = '' }, }, } </script>
v-bind.prop
を介することでプロパティとしてデータを渡せる。Angularにも同様の仕組みがある。
関数については、Reactは独自のSyntheticEvent(合成イベント)によってイベントをラップしていて、カスタムイベントを受け取れるようになってない。ちなみにPreactの場合は合成イベントを実装してないので、カスタムイベントをハンドリングすることはできたりする。とは言え今のところは自分でaddEventListener
するしかない。この問題を解消するために、ReactDOM.createCustomElementType()
というAPIを追加しようみたいな議論をしている最中のようだ。
で、プロパティを通してデータを渡す場合のカスタム要素の実装は次のようになる。
my-dropdown.js
:
export default class MyDropdown extends HTMLElement { set open(value) { this._open = value this._render() this.dispatchEvent(new CustomEvent('toggle')) } get open() { return this._open } set options(value) { this._options = value this._render() } get options() { return this._options } set selected(value) { this._selected = value this._render() } get selected() { return this._selected } constructor() { super() this.attachShadow({ mode: 'open' }) this._open = false this._options = [] this._selected = null } connectedCallback() { this._render() } _render() { this.shadowRoot.innerHTML = ` <div id="root"> <button id="toggle" type="button" aria-expanded="${this.open}">${ (this.selected !== null ? this.options.find(({ key }) => key === this.selected) : this.options[0] ).text }</button> <ul id="list" ${this.open ? '' : 'hidden'}> ${this.options .map( ({ key, text }) => ` <li> <button name="${key}" type="button">${text}</button> </li> `, ) .join('')} </ul> </div> ` this.shadowRoot.querySelector('#toggle').addEventListener('click', () => { this.open = !this.open }) this.shadowRoot.querySelectorAll('#list button').forEach((buttonEl) => { buttonEl.addEventListener('click', () => { this.selected = Number(buttonEl.name) }) }) } }
便宜上ドロップダウンと命名しているけど、かくあるべきな実装にはなってないので、必要に応じて適当な資料を参照いただきたい。
プロパティ周りの実装がいまいちイケてない感じがするのはライブラリで吸収するしかなさそうだ。Polymerはこの辺を細かく制御できるようになっている。MyDropdown#_render()
の実装も雑だけど、これもいい感じに書きたければライブラリでという感じになる。今あるものなら他にSkateJSとかlit-htmlとか。
とは言えライブラリを使わなくても、jQueryプラグインのようになんの秩序もないところから思い思いの仕様で実装することに比べれば、まともなものをはるかに作りやすくなる。Reactのような大層なものを持ち込まなくても、標準のAPIだけでうまくやれるようになるというのは大きな希望だ。
サーバーサイドレンダリング
Web Componentsを利用しても、テンプレートのサーバーサイドレンダリング(以下SSR)の問題は依存として存在する。GoogleのSEOとFirst Meaningful Paintまでの待ち時間のことだ。
Googlebotは現在、Chrome 41相当の性能でページの描画を行なっている。そのためページの初期描画はその環境で失敗しないようにしておく必要がある。また、JavaScriptの実行はできるが、SSRすることが推奨されている。実際その方がインデックスされるのが早い気もする(体感で)。しかしGooglebotのベースとなっているChromeは、年来あるいは来年の頭にはアップグレードされるらしい。この辺は待っていれば諸々解決されることを期待したい。
First Meaningful Paintまでの待ち時間に関しては、もちろんSSRした方が速いがやりたくない。Web ComponentsをSSRするためのライブラリもあるけど、結局この辺が複雑になってしまうのはちょっと……という感じ。クライアントサイドでできる範囲の努力をした上で、ブラウザがいろいろ最適化してくれて速くなればいいなーくらいの温度感でいる。。。
BEMの単純化と強制
今のCSSの難しさは、規約を守り続けるということだと思う。まともな規約を作って、それが守り続けられればおかしなことにはならない。しかしBEMのような単純な規約であっても、さまざまな事情によって破られてしまうのが世の常だ。Web Componentsによって、BEMは規約でなく仕組みとして強制できるようになる。
Shadow DOMの中ではセレクタは閉じている。例えば、BEMで言うところのブロックと同じ粒度でカスタム要素を設計すれば、全てのクラス名にブロックをつけるようなことをする必要は無くなる。エレメントとモディファイアだけ意識すればいい。
export default class SiteHeader extends HTMLElement { constructor() { super() this.attachShadow({ mode: 'open' }).innerHTML = ` <style> #root { ... } #heading { ... } #lede { ... } #nav { ... } #navList { ... } .navItem { ... } .navLink { ... } .navLink.-current { ... } </style> <header id="root"> <h1 id="heading">Awesome Web Components</h1> <p id="lede">Web Components is awesome!</p> <nav id="nav"> <ul id="navList"> <li class="navItem"><a class="navLink" href="/ce">Custom Elements</a></li> <li class="navItem"><a class="navLink" href="/sd">Shadow DOM</a></li> <li class="navItem"><a class="navLink" href="/hi">HTML Imports</a></li> <li class="navItem"><a class="navLink" href="/ht">HTML Template</a></li> </ul> </nav> </header> ` const currentLink = [...this.shadowRoot.querySelectorAll('.navLink')].find( (el) => el.href === location.pathname, ) if (currentLink) { currentLink.classList.add('-current') currentLink.setAttribute('aria-current', 'page') } } }
カスタム要素内にスタイルを書くことが強制されることにより、影響範囲が明確になる、宣言ブロックが正しい場所に書かれるようになる、ブロックの境界を意識しやすくなる、などさまざまな利点がある。マークアップが隠蔽されるという利点も見逃せない。
また<slot>
要素に加えて、:host-context(<selector>)
、::slotted(<compound-selector>)
は、コンポーネントの外から影響を受ける、外へ影響を及ぼすということをより明快に書けて素晴らしい。力尽きたので前述の記事とか読んでください。
あと、<style>
要素内でSass書きたいって人はwebpackでそんなに難しくなくできるはず。to-string-loader ← css-loader ← postcss-loader ← sass-loaderという感じで動いた。
気持ち
僕がこれに期待する動機としてはJavaScript周りの理由が大きい。正直全部Reactで組める環境であれば別にいらないかなという気持ちもある。けど仕事としては、いわゆる普通のウェブサイトを堅牢に運用性高く作っていくというのを主軸にしているし、これからもやっていくつもりなので、そういったときにここまで欲しいものは他に見つかってない。
ただ、これが使えるようになるためにいつまで待ち続けるのかという問題がある。IE 11サポートを謳っているポリフィルはあるが、試しに使ってみると厳しすぎて絶対IE無理という感想になった。そこで頑張るのも不毛なので、趣味のアプリでも作って素振りしつつ消滅まで待つつもりだ。どうせそのころにはEdgeやFirefoxにも実装が済んでいるだろうとある意味楽観的に考えている。Shady CSSなんてものは知らない。
早く未来を見たい。
彼がいたジム
腹筋を鍛えることにおいて、BIG3に取り組むことは安全かつ必要十分なトレーニング効果をもたらす最良の方法である。腹筋は背骨を安定させるための筋肉だ。スクワットやデッドリフトで大きな重量を扱うことによって、姿勢を安定させるため腹筋には大きな負荷がかかる。その重量が増えるにつれ、腹筋のための個別種目では得られないだけの効果をもたらすことができる。
反して、世に広く知られるシットアップにおいては、背骨を曲げることが直接的に腰痛を発症・再発させる原因になる可能性がある。また、重量を伸ばすことにも限界があり、BIG3などの種目に比べて取り組む意義が少ない。
あくまで僕はそう学んできたので、腹筋のために別の種目を取り入れようという気分になることはなかった。毎日BIG3のことだけを意識して、ジムで別のトレーニングに取り組む人が目に入ってもこれっぽっちの影響も受けたことがない。周囲からなんと意見されようとも、僕は自分が正しいということがわかっていた。確実に正しい道の上を歩んでいて、よそ見をする意味なんてどこにもない。その自信は一切の不確定性を含んでいなかった。
腹筋ローラーの力を信じろ。その言葉を目にしたとき、僕はまたいつものように無視をすればいいのだと思っていた。もちろん僕はそれを真に受けない。それでも彼はずっとその言葉を繰り返していた。
僕はそれからも以前と同じようにBIG3だけを続けた。仕事が忙しくてもジムへ行く日は確保できるように努力したので、伸び悩んでいた記録も少しずつ右肩上がりになってきていた。学生のころはもっと重いバーベルを引いていたので、まずはそのころまで体組成を逆戻りさせる必要がある。トレーニーとしてのピークを過去にしてしまうには僕はあまりに若すぎる。
その間も彼はずっと主張を曲げなかった。腹筋しろよ。事あるごとにそう言ってみんなを煽り立てた。発破をかけていたと言った方が正しいのかもしれない。気づけば誰もが腹筋ローラーを転がしていた。気づけば誰もが腹筋をしていた。そして彼がいなくても、誰もが腹筋ローラーの力を信じるようになっていた。
いつからか彼はあまり腹筋ローラーの話をしないようになった。今さらになって、あえて自分がその主張をする意味が見出せなくなってしまったのかもしれない。僕自身も彼が腹筋ローラーの話をしていたことを忘れかけていた。そこにはただ過去に何かをやり遂げた男がいて、また別の方向を向いてなぜか焦燥感を抱えた男がいた。
しばらく会えなくなるかもしれないね。彼はあまりに唐突にそういったので、僕はどうせ冗談か何かだろうと思った。数日経つと、彼は元いた場所から綺麗さっぱりいなくなっていた。なぜだか理由はわからない。きっと彼には彼なりの理由があるのだろう。友達になれたと思っていたけど残念だった。
僕は今まで通りジムには行ってるし、種目はBIG3しかしてない。もちろん他の種目に手を出してみようかと気持ちが揺れることもない。それでもジムにある腹筋ローラーが目に入ると、どうしても彼の姿を思い出してしまう。このジムに来たことはないけど、腹筋ローラーがそこにあるだけで、それを転がしている彼の姿が僕の目の前に浮かぶ。しかし彼は去った。いつか戻ってくるかもしれないけど、今ここにはいない。僕にできることは、この現状を理解した上で彼の帰りを待ち続けることだけだ。
参考文献
状態遷移時にアニメーションを伴うUIのアクセシビリティ周りの実装について
ディスクロージャーの開閉時やモーダルダイアログが表示される瞬間など、あらゆるUIは状態遷移のたびにアニメーションを伴う。にも関わらず、アクセシブルなUIを実装するための手法について書かれた文献で、アニメーションを伴う状態遷移時におけるWAI-ARIAの利用方法というようなテーマが取り上げられているものは見たことがない。UIにおけるアニメーションの役割を踏まえて、それをどのようにして実装すべきだと考えているかについて述べたい。
結論としては、セマンティクス上はアニメーションの存在を意識させないように実装すべきである。多くの場合、アニメーションはUIの状態遷移を視覚的に表現するために存在する。例えばディスクロージャーにおいては、閉じた状態と開いた状態の間を擬似的にアニメーションによって表現することで、ユーザーが状態変化前後のビュー(View)の繋がりを理解する手がかりになる。これは視覚を用いてGUIを操作するユーザーのための実装だ。
対して、スクリーンリーダーなどの支援技術を通してウェブサイトを利用するユーザーにとってはこれらのアニメーションに意味はない。UIの状態が変化したときには、音声読み上げなどの方法によって随時通知されるというインターフェイスになっているからだ。そのため、ユーザーの動作を起点とした状態遷移時には、アニメーションを無視して即座に状態変化後のセマンティクスに変更されることが望ましい。でなければ動作と状態変化の関係性が結びつかなくなってしまう可能性があるからだ。
前述の例として取り上げたディスクロージャーを実装に落とし込みながら、より具体的に説明する。次のようなマークアップを基に実装することにする。
<section> <h2> <button type="button" class="trigger" aria-expanded="true">Content</button> </h2> <div class="body"> <p>Lorem ipsum, dolor sit amet consectetur <a href="#">some link</a> adipisicing elit.</p> <p>Magni, <button type="button">some button</button> quod minima? Harum, consequatur esse?</p> </div> </section>
.trigger
をクリックすると.body
が開閉する。初期状態では開いているという仕様。aria-controls
は一部のUAでしか実装されていないことに加えて、IDでの指定が運用上難しいため利用しないことが個人的に多い。
その前提の上で、ディスクロージャーを閉じるときに必要な処理は次のようになる。
.body
をスライドアップするアニメーションを開始する.trigger
にaria-expanded="false"
を指定する.body
にaria-hidden="true"
を指定する.body
の子孫要素でTabbable(Tabキーでフォーカスできる)なものにtabindex="-1"
を指定する
これによってアニメーションが開始すると同時に、セマンティクス上は閉じている状態になる。アニメーションの完了後もその状態は変化しない。ユーザーの動作後に支援技術には閉じていることが即座に通知されるということだ。
また、タブキーによってフォーカスを移動させるユーザー(支援技術及び一般ユーザー)のために、これから閉じようとしているコンテンツにはフォーカスさせないようにする。もちろん、アニメーションが終了して完全に閉じられた後も同様だ(終了時の.body
がdisplay: none
かvisibility: hidden
になる場合は不要)。
ディスクロージャーを開くときは先ほどの逆になる。
.body
をスライドダウンするアニメーションを開始する.trigger
にaria-expanded="true"
を指定する.body
からaria-hidden="true"
を取り除く.body
の子孫要素でTabbableなものからtabindex="-1"
を取り除く
コードとしては次のようになるイメージだ。外部ライブラリとしてVelocity.jsとtabbableを利用している。実際に動くデモも用意した。
import Velocity from 'velocity-animate' import tabbable from 'tabbable' const triggerEl = document.querySelector('.trigger') const bodyEl = document.querySelector('.body') const tabbableEls = tabbable(bodyEl) let isExpanded = true const open = () => { Velocity(bodyEl, 'slideDown') tabbableEls.forEach(el => { el.removeAttribute('tabindex') }) bodyEl.removeAttribute('aria-hidden') triggerEl.setAttribute('aria-expanded', 'true') } const close = () => { Velocity(bodyEl, 'slideUp') tabbableEls.forEach(el => { el.setAttribute('tabindex', -1) }) bodyEl.setAttribute('aria-hidden', 'true') triggerEl.setAttribute('aria-expanded', 'false') } const toggle = () => { if (isExpanded) { close() } else { open() } isExpanded = !isExpanded } triggerEl.addEventListener('click', toggle)
モーダルダイアログなどの実装もこれと同じ考え方になる。間の状態を視覚的に表現するためのアニメーションは、支援技術及びキーボード操作をするユーザーにとっては存在しないように実装する。慣れれば難しくはない。
UIのユーザビリティを向上させるためにアニメーションは重要な要素だ。しかしそれはある特定のユーザーに対する個別最適化であるが故に、一部のユーザーにとっては使えない要因となってしまう危険性も孕んでいる。アクセシブルな実装が担保できた上で個別最適化を考えることはもっともであるが、その間口を広げる工夫をすることで、より多くのウェブサイトをアクセシブルにするということに前向きに取り組んでいけるはずだ。
この記事は『ライブラリなしで実装する定番UI - ドロワーナビの基本』に触発されて書かれました。素晴らしい記事を届けていただき、ありがとうございます。
【お蕎麦】富士そばへ行く!【安い・早い・うまい】
どうもー! フロントエンドエンジニアのゆうへいでーす! 僕実はですね、今日まだ何も食べてなくて、すごくお腹が減ってるんですよ。ということで今回はね、行きつけの近所の富士そばに行ってみたいと思います!
いやー、それにしても冬は寒くてついつい出不精になってしまいますね。富士そばに行こうって思ってから実際に外に出るまで30時間くらいかかってしまいました(笑)。本当はこれ、富士そば Advent Calendar 2017の23日の記事だったんですが、1日遅れてしまいました、すいませんほんとに、ヘヘッ、フヘヘッ。
ということでね、外に出たんですがね、富士そばは最寄駅のすぐ近くにあるんですが、今住んでる場所から駅までが微妙に遠いんですよね。つまりいつも出勤するたびに微妙に遠い距離を歩いてるんですが、これを蓄積していくとすごい人生の時間の無駄だなーと。まあ最寄駅まで歩いて移動するくらいなら運動にもなるしいいかなと思えるんですが、今毎日通勤するのにDoor to Doorで50分くらいかかってるんですよね。都心の割には短い方みたいなんですが、それでも毎日移動で2時間近く使ってるって考えたら大変なことですよね。今住んでる部屋はそんなに気に入ってるわけじゃないんで、会社の近くに引っ越したいなとは思いつつ、引っ越すほどお金もないし、家賃上がっちゃってもなーという感じなんですよねー。東京じゃないとやりたいお仕事するのも難しそうだし世知辛い。
そんなことを考えながら歩いているうちに富士そばに到着です! 期間限定メニューで「あさりそば」とか「合鴨ミニ丼」とかがあるみたいですね! えーっとどれにしようかな……、じゃあ僕このそばと合鴨ミニ丼のセットに決めました!
あさりとも迷ったんですが、肉にとにかく惹きつけられてしまって、そんなもんどうせしょぼいのにと思いつつこれにしてしまいました。よく行くからわかってるんですが、ミニ丼ってだいたいほんとにしょぼいんですよね。でもそんなこと言いつつも選んでしまう僕。正直飯とかだいたい食えればまあなんでもいいんですよね。
ということですぐ僕の食券の番号が呼ばれてこれを食べました、味はいつもの富士そば味って感じです。
それで食べ終わったんですが、なんせ30時間ぶりくらいの食事でまだお腹が空いていたのでマックでチキンナゲット及びその他を買いました。
さっきジムのテレビで見たんですが、今チキンナゲットがお得らしいです #フロントエンドゆるふわ筋トレ部
— 超一流HTMLコーダー(55) (@_yuheiy) 2017年12月12日
このツイートをして以来ずっと久しぶりに食べたいなと思ってたんですが、近所のマックがずっと休業中だったんですね。今日確認してみると営業再開してたので、満を持してというところでした! 味はいつものマック味って感じです。ポテトも買ったのは完全に蛇足でした! 消化試合感! いぇい!
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に渡す。無駄感はあるけど、テンプレートの管理の楽さとかを考えるとまあこれでいいかなという感想。