feat(tree): Space activation and ARIA refinements

This commit is contained in:
Philipinho
2026-05-13 21:55:42 +01:00
parent 5bcca990fd
commit 21899fddb6
5 changed files with 44 additions and 18 deletions
@@ -294,14 +294,10 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
return null;
})();
// aria-expanded on the consumer's interactive element is enough; we drop
// aria-controls since the virtualized children no longer live inside a
// dedicated <ul role="group"> with a stable id (children are siblings in the
// flat virtualized list, not a DOM subtree).
const ariaProps = hasChildren ? { 'aria-expanded': isOpen } : {};
// The <li role="treeitem"> wrapper and recursion are owned by DocTree's
// virtualizer now; this component renders only the row's body.
// 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.
return (
<div
className={styles.rowWrapper}
@@ -328,7 +324,6 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
isDragging,
isReceivingDrop: receivingDrop,
rowRef,
ariaProps,
tabIndex: activeId === node.id ? 0 : -1,
toggleOpen,
})}
@@ -26,10 +26,6 @@ export type RenderRowProps<T extends object> = {
isReceivingDrop: 'before' | 'after' | 'make-child' | null;
rowRef: Ref<HTMLElement>;
ariaProps: {
'aria-expanded'?: boolean;
'aria-controls'?: string;
};
// Roving tabindex: exactly one row in the tree carries tabIndex={0} (the
// active row); every other row gets tabIndex={-1}. Consumers must spread
// this onto the same element they wire rowRef to.
@@ -57,6 +53,11 @@ export type DocTreeProps<T extends object> = {
getDragLabel: (node: TreeNode<T>) => string;
uniqueContextId?: symbol;
// Accessible name for the tree itself (e.g. "Pages"). Rendered as
// aria-label on the <ul role="tree"> so screen readers announce what
// collection of items the user has entered.
'aria-label'?: string;
};
export type DocTreeApi = {
@@ -117,6 +118,7 @@ function DocTreeInner<T extends object>(
getDragLabel,
uniqueContextId,
emptyState,
'aria-label': ariaLabel,
} = props;
const scrollRef = useRef<HTMLDivElement>(null);
@@ -278,10 +280,15 @@ function DocTreeInner<T extends object>(
// pattern). Allowed with Shift since on most keyboards Shift+8 is how
// "*" is produced. Handled separately from typeahead.
const isStarKey = e.key === '*';
// Space activates the focused row — same effect as clicking it. Native
// <a> doesn't get this for free (only <button> does), so we wire it up
// explicitly to satisfy the WAI-ARIA tree pattern.
const isActivateKey = e.key === ' ';
// Single printable character → typeahead. e.key.length === 1 excludes
// multi-char names like "ArrowDown", "Enter", "Tab", etc.
const isTypeahead = e.key.length === 1 && !isNavKey && !isStarKey;
if (!isNavKey && !isTypeahead && !isStarKey) return;
const isTypeahead =
e.key.length === 1 && !isNavKey && !isStarKey && !isActivateKey;
if (!isNavKey && !isTypeahead && !isStarKey && !isActivateKey) return;
const target = e.target as HTMLElement;
if (target.matches('input, textarea, [contenteditable="true"]')) return;
@@ -305,6 +312,19 @@ function DocTreeInner<T extends object>(
}
};
// Space activates the focused row by synthesizing a click on the
// registered row element (its <a> Link). Skip if focus is on an inner
// button (chevron, +, menu) — those handle Space via native button
// semantics, and intercepting here would block their default behavior.
if (isActivateKey) {
const registered = rowElementsRef.current.get(id);
if (target === registered) {
e.preventDefault();
registered.click();
}
return;
}
// Typeahead: accumulate printable chars, jump to next row whose label
// starts with the buffer. Same-letter presses cycle through matches; a
// multi-char buffer searches from the current row so the user can
@@ -447,6 +467,7 @@ function DocTreeInner<T extends object>(
<div ref={scrollRef} className={styles.treeContainer}>
<ul
role="tree"
aria-label={ariaLabel}
onKeyDown={handleKeyDown}
onFocus={handleFocusIn}
style={{
@@ -459,11 +480,22 @@ 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}
style={{
position: 'absolute',
@@ -42,7 +42,6 @@ export function SpaceTreeRow({
hasChildren,
toggleOpen,
rowRef,
ariaProps,
tabIndex,
readOnly,
}: SpaceTreeRowProps) {
@@ -142,7 +141,6 @@ export function SpaceTreeRow({
to={pageUrl}
className={classes.node}
tabIndex={tabIndex}
{...ariaProps}
onClick={() => {
if (mobileSidebarOpened) {
toggleMobileSidebar();
@@ -223,6 +223,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
disableDrag={disableDragDrop}
disableDrop={disableDragDrop}
getDragLabel={getDragLabel}
aria-label={t("Pages")}
/>
)}
</div>
@@ -33,6 +33,7 @@ interface SharedTreeProps {
}
export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
const { t } = useTranslation();
const treeRef = useRef<DocTreeApi | null>(null);
const { pageSlug } = useParams();
const [openTreeNodes, setOpenTreeNodes] = useAtom(openSharedTreeNodesAtom);
@@ -100,6 +101,7 @@ export default function SharedTree({ sharedPageTree }: SharedTreeProps) {
onMove={noopMove}
onToggle={handleToggle}
getDragLabel={getDragLabel}
aria-label={t("Pages")}
/>
</div>
);
@@ -114,7 +116,6 @@ function SharedTreeRow({
hasChildren,
isSelected,
rowRef,
ariaProps,
tabIndex,
toggleOpen,
}: RenderRowProps<SharedPageTreeNode>) {
@@ -131,7 +132,6 @@ function SharedTreeRow({
return (
<Box
ref={rowRef as React.Ref<HTMLAnchorElement>}
{...ariaProps}
tabIndex={tabIndex}
data-selected={isSelected || undefined}
className={clsx(classes.node, styles.treeNode)}