Reactのnested interactive要素はsibling buttonsへ分解する
nested interactive elementsは、多くの場合keyboard handlerの問題ではなく、HTML構造の問題です。
React componentは最初、1つのclickable chipやcardとして作られます。後からremove、menu、secondary actionが増えると、既存のbuttonの中に別のinteractive elementを入れがちです。これはinvalid HTMLで、accessibility上も混乱を生みます。
多くの場合、containerを非interactiveにし、内部に兄弟buttonを置くのが正解です。
症状: 1つのcomponentに2つのclick targetがある
例です。
- recent project chip: selectとremove
- file tab: activateとclose
- card: openとfavorite
- list row: selectとmenu
壊れた形はこうなりがちです。
export function RecentChip({ path, onSelect, onRemove }) {
return (
<button onClick={onSelect}>
<span
role="button"
tabIndex={0}
onClick={onRemove}
>
x
</span>
{path}
</button>
);
}
interactive ancestorの中にinteractive descendantがあります。
非interactive wrapperに分ける
li や div をwrapperにし、2つのnative buttonを並べます。
export function RecentChip({ path, onSelect, onRemove }) {
return (
<li className="recent-chip">
<button className="chip-main" onClick={onSelect}>
{path}
</button>
<button
className="chip-remove"
aria-label={`Remove ${path}`}
onClick={onRemove}
>
x
</button>
</li>
);
}
これで各actionが独立したsemantics、focus target、keyboard behaviorを持ちます。
spanにhandlerを足して直そうとしない
これは根本解決ではありません。
<span
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") onRemove();
}}
>
x
</span>
一部の症状は良くなりますが、nested interactive structureは残ります。native buttonならEnter、Space、disabled、focus、roleを最初から扱えます。
見た目はCSSで維持する
sibling構造でも、見た目は1つのchipにできます。
.recent-chip {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.chip-main,
.chip-remove {
border: 0;
background: transparent;
}
視覚的な一体感のために、semantic nestingを壊す必要はありません。
検証チェック
buttonの中に別のbutton、link、input、role="button"がない- 両方のactionへTabで到達できる
- 両方にfocus visibleがある
- custom keyboard codeなしでEnter/Spaceが動く
- remove buttonに意味のあるaccessible nameがある
- axeやAccessibility Insightsでnested interactiveが消える