fix(tree): move treeitem role to focusable row + aria-current

This commit is contained in:
Philipinho
2026-05-13 22:13:58 +01:00
parent 21899fddb6
commit e14aa0ebd9
4 changed files with 37 additions and 18 deletions
@@ -294,10 +294,20 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
return null;
})();
// The <li role="treeitem"> wrapper and recursion are owned by DocTree's
// virtualizer now; this component renders only the row's body. ARIA state
// (aria-expanded, aria-selected, aria-level) is set on the <li> itself —
// the inner interactive element doesn't carry it.
// Treeitem semantics ride on the row's focusable element (the consumer's
// <a>). The outer <li> is presentational layout. aria-label uses the row's
// label so the SR's accessible name is just the page title, not the
// concatenation of inner action-button aria-labels.
const treeItemProps = {
role: 'treeitem' as const,
'aria-level': level + 1,
'aria-expanded': hasChildren ? isOpen : undefined,
'aria-selected': isSelected ? (true as const) : undefined,
'aria-current': isSelected ? ('page' as const) : undefined,
'aria-label': getDragLabel(node),
'data-row-id': node.id,
};
return (
<div
className={styles.rowWrapper}
@@ -325,6 +335,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
isReceivingDrop: receivingDrop,
rowRef,
tabIndex: activeId === node.id ? 0 : -1,
treeItemProps,
toggleOpen,
})}
</div>
@@ -30,6 +30,20 @@ export type RenderRowProps<T extends object> = {
// active row); every other row gets tabIndex={-1}. Consumers must spread
// this onto the same element they wire rowRef to.
tabIndex: 0 | -1;
// Treeitem semantics for the row's focusable element. Consumers MUST spread
// these onto the same element rowRef points at, so the focused element IS
// the treeitem. This makes screen readers announce "treeitem" (not "link")
// and replaces the descendant-text accname with the row's label, so action
// button labels inside the row don't get concatenated.
treeItemProps: {
role: 'treeitem';
'aria-level': number;
'aria-expanded'?: boolean;
'aria-selected'?: true;
'aria-current'?: 'page';
'aria-label': string;
'data-row-id': string;
};
toggleOpen: () => void;
};
@@ -480,23 +494,13 @@ function DocTreeInner<T extends object>(
>
{virtualItems.map((virtualItem) => {
const row = flat[virtualItem.index];
// aria-expanded belongs on the treeitem itself per the WAI-ARIA
// pattern. Omitted entirely for leaf rows so screen readers don't
// announce expand state for nodes that have no children.
const nodeHasChildren =
(row.node.children && row.node.children.length > 0) ||
(row.node as { hasChildren?: boolean }).hasChildren === true;
const ariaExpanded = nodeHasChildren
? openIds.has(row.node.id)
: undefined;
return (
<li
key={row.node.id}
role="treeitem"
aria-level={row.level + 1}
aria-expanded={ariaExpanded}
aria-selected={row.node.id === selectedId ? true : undefined}
data-row-id={row.node.id}
// role="none" — the treeitem role lives on the focusable child
// (the row's <a>), so screen readers announce "treeitem" on
// navigation. The <li> is just layout glue.
role="none"
style={{
position: 'absolute',
top: 0,
@@ -43,6 +43,7 @@ export function SpaceTreeRow({
toggleOpen,
rowRef,
tabIndex,
treeItemProps,
readOnly,
}: SpaceTreeRowProps) {
const { t } = useTranslation();
@@ -141,6 +142,7 @@ export function SpaceTreeRow({
to={pageUrl}
className={classes.node}
tabIndex={tabIndex}
{...treeItemProps}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
@@ -117,6 +117,7 @@ function SharedTreeRow({
isSelected,
rowRef,
tabIndex,
treeItemProps,
toggleOpen,
}: RenderRowProps<SharedPageTreeNode>) {
const { shareId } = useParams();
@@ -133,6 +134,7 @@ function SharedTreeRow({
<Box
ref={rowRef as React.Ref<HTMLAnchorElement>}
tabIndex={tabIndex}
{...treeItemProps}
data-selected={isSelected || undefined}
className={clsx(classes.node, styles.treeNode)}
component={Link}