feat(tree): roving tabindex and * to expand sibling subtrees

This commit is contained in:
Philipinho
2026-05-13 21:33:19 +01:00
parent 4e8f6b043d
commit 5bcca990fd
6 changed files with 86 additions and 2 deletions
@@ -34,6 +34,7 @@ export interface EmojiPickerInterface {
size?: string;
variant?: string;
c?: string;
tabIndex?: number;
};
}
@@ -121,6 +122,7 @@ function EmojiPicker({
c={actionIconProps?.c || "gray"}
variant={actionIconProps?.variant || "transparent"}
size={actionIconProps?.size}
tabIndex={actionIconProps?.tabIndex}
onClick={handlers.toggle}
aria-label={t("Pick emoji")}
aria-haspopup="dialog"
@@ -36,6 +36,8 @@ type Props<T extends object> = {
isLastSibling: boolean;
openIds: ReadonlySet<string>;
selectedId?: string;
// Roving tabindex: the single row that currently carries tabIndex={0}.
activeId?: string;
renderRow: (props: RenderRowProps<T>) => ReactNode;
indentPerLevel: number;
onMove: (sourceId: string, op: DropOp) => void | Promise<void>;
@@ -62,6 +64,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
isLastSibling,
openIds,
selectedId,
activeId,
renderRow,
indentPerLevel,
onMove,
@@ -326,6 +329,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
isReceivingDrop: receivingDrop,
rowRef,
ariaProps,
tabIndex: activeId === node.id ? 0 : -1,
toggleOpen,
})}
</div>
@@ -373,6 +377,11 @@ function arePropsEqual<T extends object>(
const wasSelected = prev.selectedId === id;
const isSelected = next.selectedId === id;
if (wasSelected !== isSelected) return false;
// activeId: same trick — only the outgoing and incoming active rows
// re-render when the user moves focus through the tree.
const wasActive = prev.activeId === id;
const isActive = next.activeId === id;
if (wasActive !== isActive) return false;
return true;
}
@@ -5,12 +5,14 @@ import {
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
type Ref,
} from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
import type { TreeNode, DropOp } from '../model/tree-model.types';
import { treeModel } from '../model/tree-model';
import { DocTreeRow } from './doc-tree-row';
import styles from '../styles/tree.module.css';
@@ -28,6 +30,10 @@ export type RenderRowProps<T extends object> = {
'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.
tabIndex: 0 | -1;
toggleOpen: () => void;
};
@@ -122,6 +128,10 @@ function DocTreeInner<T extends object>(
// ~500ms of no typing. Refs only — no re-render needed per keystroke.
const typeaheadBufferRef = useRef('');
const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Roving tabindex: the row most-recently focused by the user. Falls back
// to selectedId, then to the first visible row, when the tracked id is
// gone from the flat list (e.g. its branch was collapsed).
const [activeId, setActiveId] = useState<string | undefined>(undefined);
const contextId = useMemo(
() => uniqueContextId ?? Symbol('doc-tree'),
[uniqueContextId],
@@ -157,6 +167,20 @@ function DocTreeInner<T extends object>(
[data, openIds],
);
// Membership lookup for the flat list. Used to validate activeId/selectedId
// before promoting them to the effective active row.
const flatIds = useMemo(() => new Set(flat.map((r) => r.node.id)), [flat]);
// Effective active row for tabindex purposes. Prefers user-focused row,
// then the currently selected page, then the first visible row. The user's
// arrow / Home / End / typeahead navigation updates activeId via the focus
// event delegated on the <ul>; explicit clicks also flow through focus.
const effectiveActiveId = useMemo(() => {
if (activeId && flatIds.has(activeId)) return activeId;
if (selectedId && flatIds.has(selectedId)) return selectedId;
return flat[0]?.node.id;
}, [activeId, selectedId, flatIds, flat]);
const virtualizer = useVirtualizer({
count: flat.length,
getScrollElement: () => scrollRef.current,
@@ -250,10 +274,14 @@ function DocTreeInner<T extends object>(
e.key === 'ArrowRight' ||
e.key === 'Home' ||
e.key === 'End');
// Star expands all sibling subtrees of the focused row (WAI-ARIA tree
// pattern). Allowed with Shift since on most keyboards Shift+8 is how
// "*" is produced. Handled separately from typeahead.
const isStarKey = 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;
if (!isNavKey && !isTypeahead) return;
const isTypeahead = e.key.length === 1 && !isNavKey && !isStarKey;
if (!isNavKey && !isTypeahead && !isStarKey) return;
const target = e.target as HTMLElement;
if (target.matches('input, textarea, [contenteditable="true"]')) return;
@@ -315,6 +343,26 @@ function DocTreeInner<T extends object>(
(row.node as { hasChildren?: boolean }).hasChildren === true;
const isOpen = openIds.has(row.node.id);
// Asterisk: expand every sibling subtree at the focused row's level.
// Walks the authoritative tree (not flat, which only carries visible
// rows) so we also expand siblings whose own subtree is currently
// collapsed. Focus and selection stay put per the WAI-ARIA pattern.
if (isStarKey) {
e.preventDefault();
const info = treeModel.siblingsOf(rootDataRef.current, row.node.id);
if (info) {
for (const sib of info.siblings) {
const sibHasChildren =
(sib.children && sib.children.length > 0) ||
(sib as { hasChildren?: boolean }).hasChildren === true;
if (sibHasChildren && !openIds.has(sib.id)) {
onToggle(sib.id, true);
}
}
}
return;
}
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
@@ -375,6 +423,19 @@ function DocTreeInner<T extends object>(
[],
);
// Event-delegated focus tracking — when any descendant (a row's Link, or an
// inner action button) gains focus, mark the enclosing row as active. Keeps
// tabIndex aligned with the user's current position whether they got there
// by click, arrow nav, or focusByIndex's programmatic .focus() call.
const handleFocusIn = useCallback(
(e: React.FocusEvent<HTMLUListElement>) => {
const rowEl = (e.target as HTMLElement).closest('[data-row-id]');
const id = rowEl?.getAttribute('data-row-id');
if (id) setActiveId(id);
},
[],
);
if (data.length === 0 && emptyState) {
return <div className={styles.treeContainer}>{emptyState}</div>;
}
@@ -387,6 +448,7 @@ function DocTreeInner<T extends object>(
<ul
role="tree"
onKeyDown={handleKeyDown}
onFocus={handleFocusIn}
style={{
position: 'relative',
height: totalSize,
@@ -417,6 +479,7 @@ function DocTreeInner<T extends object>(
isLastSibling={row.isLastSibling}
openIds={openIds}
selectedId={selectedId}
activeId={effectiveActiveId}
renderRow={renderRow}
indentPerLevel={indentPerLevel}
onMove={onMove}
@@ -126,6 +126,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
variant="transparent"
c="gray"
aria-label={t("Page menu")}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -43,6 +43,7 @@ export function SpaceTreeRow({
toggleOpen,
rowRef,
ariaProps,
tabIndex,
readOnly,
}: SpaceTreeRowProps) {
const { t } = useTranslation();
@@ -140,6 +141,7 @@ export function SpaceTreeRow({
ref={rowRef as React.Ref<HTMLAnchorElement>}
to={pageUrl}
className={classes.node}
tabIndex={tabIndex}
{...ariaProps}
onClick={() => {
if (mobileSidebarOpened) {
@@ -163,6 +165,7 @@ export function SpaceTreeRow({
}
readOnly={!canEdit}
removeEmojiAction={handleRemoveEmoji}
actionIconProps={{ tabIndex: -1 }}
/>
</div>
@@ -220,6 +223,7 @@ function PageArrow({ isOpen, hasChildren, onToggle }: PageArrowProps) {
c="gray"
aria-label={isOpen ? t("Collapse") : t("Expand")}
aria-expanded={isOpen}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -271,6 +275,7 @@ function CreateNode({
variant="transparent"
c="gray"
aria-label={t("Create page")}
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -115,6 +115,7 @@ function SharedTreeRow({
isSelected,
rowRef,
ariaProps,
tabIndex,
toggleOpen,
}: RenderRowProps<SharedPageTreeNode>) {
const { shareId } = useParams();
@@ -131,6 +132,7 @@ function SharedTreeRow({
<Box
ref={rowRef as React.Ref<HTMLAnchorElement>}
{...ariaProps}
tabIndex={tabIndex}
data-selected={isSelected || undefined}
className={clsx(classes.node, styles.treeNode)}
component={Link}
@@ -156,6 +158,7 @@ function SharedTreeRow({
}
readOnly={true}
removeEmojiAction={() => {}}
actionIconProps={{ tabIndex: -1 }}
/>
</div>
<span className={classes.text}>{node.name || t("untitled")}</span>
@@ -198,6 +201,7 @@ function SharedPageArrow({
size={20}
variant="subtle"
c="gray"
tabIndex={-1}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();