絞り込みUIのrole=statusが150msだけ嘘をつく
ツール一覧とサイト内検索に絞り込みを付けた。押下状態はReactのstateからクラス名を組み立てず、aria-pressedだけで見た目を出している。
検索のほうには件数の読み上げも足してある。このrole="status"が、種別の絞り込みを1つ増やしたときに壊れた。切り替えた直後の150msのあいだ、前の種別の件数を今の条件の結果として読む。
見た目を属性で駆動する話と、読み上げを状態で駆動する話。どちらも「状態をどこに持つか」の話だったので、まとめて書く。
押下状態はクラス名ではなく属性に持つ
絞り込みのボタンはこう書いてある。クラス文字列は全ボタンで共有で、状態は入らない。
const buttonClass = cn(
badgeVariants({ variant: 'secondary' }),
'h-11 cursor-pointer px-2.5',
'hover:bg-secondary/70',
'aria-pressed:bg-primary aria-pressed:text-primary-foreground',
)
<button type="button" aria-pressed={category === item.slug} onClick={...} className={buttonClass}>
サーバー描画の出力を見ると、選択中とそうでないボタンでclassが1文字も違わない。違うのは属性だけ。
<button type="button" aria-pressed="true" class="……aria-pressed:bg-primary……">すべて</button>
<button type="button" aria-pressed="false" class="……aria-pressed:bg-primary……">開発</button>
React側で気をつけるのは、aria-*が真偽値をそのまま文字列にすること。hiddenのような素のboolean属性とは扱いが違う。
<button aria-pressed={true} /> // <button aria-pressed="true">
<button aria-pressed={false} /> // <button aria-pressed="false">
<button aria-pressed={undefined} /> // <button> 属性ごと消える
<button hidden={true} /> // <button hidden="">
<button hidden={false} /> // <button> 属性ごと消える
falseが"false"として残ってくれるから、CSSの側で「押されているとき」だけを引っかけられる。
aria-pressed:変種はtrueにしか当たらない
Tailwind v4のaria-*変種が何に展開されるかは、コンパイラを直に呼べば見える1。
.aria-pressed\:bg-primary[aria-pressed="true"] { background-color: var(--color-primary); }
.aria-checked\:bg-primary[aria-checked="true"] { background-color: var(--color-primary); }
.aria-\[pressed\]\:bg-primary[aria-pressed] { background-color: var(--color-primary); }
.aria-\[pressed\=mixed\]\:bg-primary[aria-pressed="mixed"] { background-color: var(--color-primary); }
名前付きの変種は="true"まで込みで書き出される。aria-pressedは3値を取れる属性だけど、aria-pressed:で拾えるのはtrueだけ。mixedを見たければ角括弧の形にする。
属性を付け忘れたボタンは、クラスが付いていても何も起きない。押下状態が黙って消えるので、見た目が検査を兼ねる。ビルド後にまとめて確かめるならHTMLを走査すればいい。
for (const tag of html.match(/<[a-z]+[^>]*>/g) ?? []) {
if (!/class="[^"]*aria-pressed:/.test(tag)) continue
if (!/aria-pressed="/.test(tag)) console.log(`属性なし: ${file}`)
}
このサイトのdistはHTMLが42枚、aria-pressed:を持つ要素が13個(trueが3、falseが10)、属性の抜けは0だった。
CSSの側をgrepするときは注意がいる。ビルド後は最小化で引用符が落ちていて、[aria-pressed=true]になっている。
$ grep -rho 'aria-pressed[^{]*{[^}]*}' dist/_astro/*.css
aria-pressed\:bg-primary[aria-pressed=true]{background-color:var(--primary)}
aria-pressed\:text-primary-foreground[aria-pressed=true]{color:var(--primary-foreground)}
[aria-pressed="true"]で探すと出てこない。属性名だけで引くのが安全。
件数の読み上げは「今の条件の結果か」を持たないと嘘になる
検索結果は入力欄の後ろに黙って差し替わるので、件数だけをsr-onlyの段落に置いて読み上げに渡している。要素は入力前から常設で、中身だけを入れ替える。要素ごと差し替えるとリージョンの挿入になって、鳴るかどうかがブラウザまかせになる。
問題は中身のほう。検索は150msのデバウンスとPagefindの非同期取得を挟むので、hitsはいつでも「1つ前の条件の結果」でありうる。だから件数を出す前に、今の条件とhitsの条件が一致しているかを見る。
if (status !== 'ready' || keyword === '' || searchedQuery !== keyword || searchedType !== type) {
return ''
}
return `${hitCount}件見つかった。`
最初はこの同一性キーがsearchedQueryだけだった。語しか検索条件が無かったときは正しい。そこに種別の絞り込みを足したとき、キーのほうを増やし忘れた。語は変わっていないのでガードは素通りして、前の種別の件数がそのまま今の条件の答えとして残る。
distの索引をnodeから引いた実測値を入れて、effectを書き写した模型で追うとこうなる。「変換」は全体で14件、ブログに絞ると0件だった。live regionが読むのは中身の変化なので、文言が前の行から変わったところに「←鳴る」を付けてある。
| 時刻 | 出来事 | 直す前 | 直した後 |
|---|---|---|---|
| 0ms | 「変換」を打ち終えた | (空) | (空) |
| 153ms | すべての結果が出た | 14件見つかった。←鳴る | 14件見つかった。←鳴る |
| 201ms | ブログを押した直後 | 14件見つかった。 | (空) |
| 300ms | 100ms後 | 14件見つかった。 | (空) |
| 354ms | ブログの結果が出た | 0件見つかった。←鳴る | 0件見つかった。←鳴る |
直す前は、押した201msから結果が出る354msまで、ボタンが「ブログ」を押した形になっているのにリージョンは14件と言っている。読み上げが鳴らないから気づかないだけで、その間に読みにいけば嘘を返す。この窓の長さはほぼデバウンスで決まる。実際の検索は速い。
| 引き方 | search | data取得 | 計 |
|---|---|---|---|
| 初回 | 95.7ms | 10.0ms | 105.7ms |
| 2回目以降 | 0.7〜3.0ms | 0.0〜2.1ms | 0.7〜4.9ms |
初回だけWASMと索引の読み込みが乗るので、そこは150msに100msが足される。
件数が同じときは、直す前は何も鳴らない
同じ模型で、切り替えの前後で件数が変わらない場合も見てみる。「色」は全体で4件、ツールに絞っても4件だった。
| 時刻 | 出来事 | 直す前 | 直した後 |
|---|---|---|---|
| 153ms | すべての結果が出た | 4件見つかった。←鳴る | 4件見つかった。←鳴る |
| 201ms | ツールを押した直後 | 4件見つかった。 | (空) |
| 354ms | ツールの結果が出た | 4件見つかった。 | 4件見つかった。←鳴る |
直す前は文言が最後まで一度も変わらない。live regionが読むのは中身の変化なので、絞り込みを押しても何も鳴らない。押した操作に音の返事が無いまま、たまたま同じ数字が残る。
直した後は、いったん空にしてから入れ直す形になるので、変化が立って読み上げが鳴る。ガードを厳しくしたら鳴る回数が増えた、という向きの直りかたをしている。
同期で終わる絞り込みには要らない
ツール一覧のほうは同じrole="status"でも、こんな配慮をしていない。
const shown = filterTools(tools, { category, query })
<p data-pagefind-ignore role="status" className="sr-only">
{shown.length}本を表示している。
</p>
絞り込みが描画の中で同期に終わるからで、shownは定義上いつでも今のstateの結果になる。ずれる隙間が無い。
念のため時間も測った。今の14本で1回0.020ms、1000本まで増やしても0.621msだった。デバウンスを挟む理由がそもそも無い。
| ツールの数 | 1回あたり |
|---|---|
| 14本 | 0.020ms |
| 100本 | 0.065ms |
| 1000本 | 0.621ms |
同じrole="status"でも、裏が非同期かどうかで必要なものが変わる。非同期を挟むなら、リージョンに出す値は「どの条件の結果か」を持っていないといけない。
見た目を属性で駆動すると、状態の置き場所が1つに減って、付け忘れが画面に出る。読み上げのほうは逆で、状態を1つ持つだけでは足りない。いまの条件と結果の条件が一致しているかまで持たないと、非同期の隙間で前の答えを読む。条件を1つ増やしたら、同一性キーも1つ増やす。
同じrole="status"でも、同期で終わる絞り込みにはこの心配が要らない。要るかどうかを決めているのはリージョンではなく、裏が非同期かどうかのほうだった。
Footnotes
-
tailwindcssパッケージのcompile()にユーティリティの名前を渡すと、そのクラスぶんのCSSが返る。ビルドを回さずに変種の展開だけを見たいときに早い。 ↩