diff --git a/apps/client/src/components/ui/emoji-picker.tsx b/apps/client/src/components/ui/emoji-picker.tsx index 8f87735d6..c360998a3 100644 --- a/apps/client/src/components/ui/emoji-picker.tsx +++ b/apps/client/src/components/ui/emoji-picker.tsx @@ -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" 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 ab2467a6f..657b9182c 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 @@ -36,6 +36,8 @@ type Props = { isLastSibling: boolean; openIds: ReadonlySet; selectedId?: string; + // Roving tabindex: the single row that currently carries tabIndex={0}. + activeId?: string; renderRow: (props: RenderRowProps) => ReactNode; indentPerLevel: number; onMove: (sourceId: string, op: DropOp) => void | Promise; @@ -62,6 +64,7 @@ function DocTreeRowInner(props: Props) { isLastSibling, openIds, selectedId, + activeId, renderRow, indentPerLevel, onMove, @@ -326,6 +329,7 @@ function DocTreeRowInner(props: Props) { isReceivingDrop: receivingDrop, rowRef, ariaProps, + tabIndex: activeId === node.id ? 0 : -1, toggleOpen, })} @@ -373,6 +377,11 @@ function arePropsEqual( 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; } 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 2217c9ca3..4dc11e641 100644 --- a/apps/client/src/features/page/tree/components/doc-tree.tsx +++ b/apps/client/src/features/page/tree/components/doc-tree.tsx @@ -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 = { '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( // ~500ms of no typing. Refs only — no re-render needed per keystroke. const typeaheadBufferRef = useRef(''); const typeaheadTimerRef = useRef | 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(undefined); const contextId = useMemo( () => uniqueContextId ?? Symbol('doc-tree'), [uniqueContextId], @@ -157,6 +167,20 @@ function DocTreeInner( [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
    ; 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( 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( (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( [], ); + // 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) => { + 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
    {emptyState}
    ; } @@ -387,6 +448,7 @@ function DocTreeInner(
      ( isLastSibling={row.isLastSibling} openIds={openIds} selectedId={selectedId} + activeId={effectiveActiveId} renderRow={renderRow} indentPerLevel={indentPerLevel} onMove={onMove} diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index f7cab98bc..91a21b052 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -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(); 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 4adeccb2c..d19bd6e85 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, ariaProps, + tabIndex, readOnly, }: SpaceTreeRowProps) { const { t } = useTranslation(); @@ -140,6 +141,7 @@ export function SpaceTreeRow({ ref={rowRef as React.Ref} 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 }} /> @@ -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(); diff --git a/apps/client/src/features/share/components/shared-tree.tsx b/apps/client/src/features/share/components/shared-tree.tsx index 1fd19e30f..4e4a71127 100644 --- a/apps/client/src/features/share/components/shared-tree.tsx +++ b/apps/client/src/features/share/components/shared-tree.tsx @@ -115,6 +115,7 @@ function SharedTreeRow({ isSelected, rowRef, ariaProps, + tabIndex, toggleOpen, }: RenderRowProps) { const { shareId } = useParams(); @@ -131,6 +132,7 @@ function SharedTreeRow({ } {...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 }} /> {node.name || t("untitled")} @@ -198,6 +201,7 @@ function SharedPageArrow({ size={20} variant="subtle" c="gray" + tabIndex={-1} onClick={(e) => { e.preventDefault(); e.stopPropagation();