[RFC] Tab: independent per-tab trailing action, rendered outside the tab button
Problem Statement
Consumers who build a tab strip whose tabs the user can dismiss need a close control on each tab. With TabList today that control has no legal place: it either ends up nested inside the tab's own <button>, or it moves out of the strip and stops belonging to any tab.
In Apache Maka's task workbar, the right panel of apps/desktop, this forced us to ship a hand-written, aria-hidden close affordance painted over the tab from a sibling layer of custom CSS, because endContent cannot hold a control. Pointer users get a control Astryx does not own. Screen-reader and keyboard users get no close control on the tab at all, only a Delete shortcut we announce ourselves. The visible control and the announced control are different things.
Evidence of Demand
- Every editor and browser tab bar puts the close control on the tab itself. MUI's Tabs exposes
onDeletefor the same reason. The pattern comes up whenever tabs are user-dismissible rather than fixed. - Maka's workbar carries Review, Terminal, Work Board, Browser, Files, Inspector and Side chat tabs, all dismissible, opened from a
[+]menu. - The same seam was requested and shipped for a sibling component. facebook/astryx#4987 asked for independent trailing actions without nested controls on
SideNavItem, filed from this consumer, andSideNavItem.actionsnow renders row-level controls as siblings of the primary element: after the expand/collapse toggle, before nested children in DOM and focus order, hidden while the rail is collapsed, sized throughSizeContext.TabandTabListhave no equivalent.
What the workaround costs, measured on our strip. Rendered DOM for the selected tab, class attributes elided:
<button type="button" aria-keyshortcuts="Delete"
aria-description="按 Delete 关闭此标签页"
data-tab-value="workbar:review" role="tab" aria-selected="true"
aria-controls="maka-workbar-panel-workbar:review" tabindex="0">
<span aria-hidden="true">…</span>
<span><svg …/></span>
<span><span>变更</span><span aria-hidden="true">变更</span></span>
<span>…</span>
</button>Chromium accessibility tree for the same strip, via CDP Accessibility.getFullAXTree:
[
{ "role": "tab", "name": "变更", "keyshortcuts": "Delete" },
{ "role": "tab", "name": "浏览器", "keyshortcuts": "Delete" },
{ "role": "tab", "name": "生成文件 0", "keyshortcuts": "Delete" }
]No close button anywhere in the tree. The visible X is aria-hidden, and the only announced affordance is the Delete keyshortcut on the tab itself.
Why Existing Components Don't Cover This
IconButtoninTab'sendContentnests controls.TabrendersendContentinside its own<button>, so a close button there becomes<button><button/></button>and drops out of the accessibility tree.- A close button as a sibling of each
Tab, inside the strip, is rejected byTabListitself: "role=\"tablist\"owns only tabs, but the strip contains a<button>that is not one. Render menus and other controls outside the strip, or drop the role for the navigation pattern." A tablist owning only tabs is the ARIA contract. - A wrapper element per tab hits the same rule. The wrapper is a non-tab child of the strip.
- One close control outside the strip follows that advice and stays valid, but it no longer belongs to any tab. We tried it and design review rejected it: with several tabs open, a single control at the end of the strip reads as "close something" rather than "close this tab".
- Dropping
role=\"tablist\"for the navigation pattern is allowed, and theTabListTabsWithActionsshowcase block does it. It costsrole=\"tab\",aria-selectedandaria-controls, andTabListthen renders an unnamed<nav>because it appliesaria-labelandaria-labelledbyonly under the tablist role. Trading an ARIA pattern and a landmark name for a button is a net accessibility loss, so we did not take it.
Rough Approaches
- Give
Taban actions slot mirroringSideNavItem.actions: rendered as a sibling of the tab button, after it in DOM and focus order. - Give
TabLista per-tab actions column, so the strip keeps geometry and overflow handling in one place. - Extend the split-action path so a tab with a trailing action renders the primary button and the action as siblings inside a wrapper that
TabListcounts as a single stop.
Acceptance properties, borrowed from #4987: the tab and its trailing action are sibling interactive controls and never nested; focus order matches visual order; the action keeps its own accessible name, keyboard behavior and disabled state; an unsized control inherits the tab's control size.
We are Apache Maka (incubating). Drafted with an AI assistant; the consumer case, code locations, DOM and accessibility-tree evidence, and the composition attempts were verified against Maka main on @astryxdesign/[email protected].
Source: facebook/astryx