From e14aa0ebd96fda607086ec89c6651fc71c8a87a2 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 13 May 2026 22:13:58 +0100 Subject: [PATCH] fix(tree): move treeitem role to focusable row + aria-current --- .../page/tree/components/doc-tree-row.tsx | 19 ++++++++--- .../page/tree/components/doc-tree.tsx | 32 +++++++++++-------- .../page/tree/components/space-tree-row.tsx | 2 ++ .../features/share/components/shared-tree.tsx | 2 ++ 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/apps/client/src/features/page/tree/components/doc-tree-row.tsx b/apps/client/src/features/page/tree/components/doc-tree-row.tsx index 0a41d00d1..347f1f3e7 100644 --- a/apps/client/src/features/page/tree/components/doc-tree-row.tsx +++ b/apps/client/src/features/page/tree/components/doc-tree-row.tsx @@ -294,10 +294,20 @@ function DocTreeRowInner(props: Props) { return null; })(); - // The
  • 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
  • itself — - // the inner interactive element doesn't carry it. + // Treeitem semantics ride on the row's focusable element (the consumer's + // ). The outer
  • 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 (
    (props: Props) { isReceivingDrop: receivingDrop, rowRef, tabIndex: activeId === node.id ? 0 : -1, + treeItemProps, toggleOpen, })}
    diff --git a/apps/client/src/features/page/tree/components/doc-tree.tsx b/apps/client/src/features/page/tree/components/doc-tree.tsx index 3b84a24aa..3dbbfda16 100644 --- a/apps/client/src/features/page/tree/components/doc-tree.tsx +++ b/apps/client/src/features/page/tree/components/doc-tree.tsx @@ -30,6 +30,20 @@ export type RenderRowProps = { // 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( > {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 (
  • ), so screen readers announce "treeitem" on + // navigation. The
  • is just layout glue. + role="none" style={{ position: 'absolute', top: 0, diff --git a/apps/client/src/features/page/tree/components/space-tree-row.tsx b/apps/client/src/features/page/tree/components/space-tree-row.tsx index 793a911d6..1ed148350 100644 --- a/apps/client/src/features/page/tree/components/space-tree-row.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-row.tsx @@ -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(); diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx index cc9eced30..370c59e70 100644 --- a/apps/client/src/features/share/components/shared-tree.tsx +++ b/apps/client/src/features/share/components/shared-tree.tsx @@ -117,6 +117,7 @@ function SharedTreeRow({ isSelected, rowRef, tabIndex, + treeItemProps, toggleOpen, }: RenderRowProps) { const { shareId } = useParams(); @@ -133,6 +134,7 @@ function SharedTreeRow({ } tabIndex={tabIndex} + {...treeItemProps} data-selected={isSelected || undefined} className={clsx(classes.node, styles.treeNode)} component={Link}