本文へ移動
myowchick

絞り込み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件見つかった。(空)
300ms100ms後14件見つかった。(空)
354msブログの結果が出た0件見つかった。←鳴る0件見つかった。←鳴る

直す前は、押した201msから結果が出る354msまで、ボタンが「ブログ」を押した形になっているのにリージョンは14件と言っている。読み上げが鳴らないから気づかないだけで、その間に読みにいけば嘘を返す。この窓の長さはほぼデバウンスで決まる。実際の検索は速い。

引き方searchdata取得計
初回95.7ms10.0ms105.7ms
2回目以降0.7〜3.0ms0.0〜2.1ms0.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

  1. tailwindcssパッケージのcompile()にユーティリティの名前を渡すと、そのクラスぶんのCSSが返る。ビルドを回さずに変種の展開だけを見たいときに早い。 ↩

← 一覧へ戻る