Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/usetreefocus-roving-tabindex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[refactor] useTreeFocus gains `hasRovingTabIndex` + `handleFocus` for internal tab-stop management; TreeList drops its inline `activeId` state and lets the hook own the roving tab stop (#3488).
@cixzhang
45 changes: 17 additions & 28 deletions packages/core/src/TreeList/TreeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,13 @@ function collectExpandedKeys(items: TreeListItemData[]): string[] {
}

/**
* Compute the initial roving-tabindex owner: the first selected enabled item
* in document order, else the first enabled item, else the first item.
* Only the top-level candidates are considered for the default so the tab stop
* is reachable even when the tree is fully collapsed.
* Compute the initial roving-tabindex seed: the first selected enabled item in
* document order, else the first enabled item, else the first item. The hook
* (useTreeFocus with hasRovingTabIndex) takes ownership after mount — it
* preserves this seeded `tabindex="0"` on its repair pass and moves the stop
* with keyboard navigation.
*/
function findDefaultActiveId(items: TreeListItemData[]): string | undefined {
function findInitialTabbableId(items: TreeListItemData[]): string | undefined {
let firstEnabled: string | undefined;
const walk = (list: TreeListItemData[]): string | undefined => {
for (const item of list) {
Expand Down Expand Up @@ -193,28 +194,15 @@ export function TreeList({
// Roving tabindex + APG tree keyboard model (via useTreeFocus)
// ---------------------------------------------------------------------------

// The treeitem that currently owns the tree's single tab stop. The hook moves
// it around via onActiveChange; a data-driven default seeds it (selected item
// or first enabled).
const [activeId, setActiveId] = useState<string | undefined>(() =>
findDefaultActiveId(items),
// The hook (hasRovingTabIndex) owns the tree's single tab stop: it repairs
// the stop on mount and moves it with keyboard navigation. We only seed the
// initially-tabbable treeitem in the render (selected item or first enabled);
// the hook's repair pass preserves that seeded `tabindex="0"`.
const initialTabbableId = useMemo(
() => findInitialTabbableId(items),
[items],
);

// If the seed id is no longer present (items changed), fall back to a valid
// default so the tree always has exactly one reachable tab stop.
const resolvedActiveId = useMemo(() => {
if (activeId == null) {
return findDefaultActiveId(items);
}
const exists = (list: TreeListItemData[]): boolean =>
list.some(
item =>
item.id === activeId ||
(item.children != null && exists(item.children)),
);
return exists(items) ? activeId : findDefaultActiveId(items);
}, [activeId, items]);

// Enter/Space activation: prefer the treeitem's own inner action (link or
// button); return true when handled so the hook does not also toggle. Scoped
// to this treeitem's own row — never a descendant treeitem's action inside an
Expand All @@ -232,10 +220,10 @@ export function TreeList({
return false;
}, []);

const {treeRef, handleKeyDown} = useTreeFocus<HTMLUListElement>({
const {treeRef, handleKeyDown, handleFocus} = useTreeFocus<HTMLUListElement>({
onToggleExpand: handleToggle,
onActivate: activateItem,
onActiveChange: setActiveId,
hasRovingTabIndex: true,
});

function renderItems(
Expand Down Expand Up @@ -286,7 +274,7 @@ export function TreeList({
renderedChildren={renderedChildren}
posInSet={index + 1}
setSize={items.length}
isTabbable={item.id === resolvedActiveId}
isTabbable={item.id === initialTabbableId}
/>
);
});
Expand All @@ -312,6 +300,7 @@ export function TreeList({
role="tree"
aria-labelledby={header != null ? headerId : undefined}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
{...stylex.props(styles.list)}>
{renderItems(items, 0, [])}
</ul>
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/TreeList/TreeListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,9 @@ export interface TreeListItemInternalProps {
/** Number of siblings at this level (aria-setsize). */
setSize: number;
/**
* Whether this treeitem currently owns the tree's single tab stop
* (roving tabindex). Exactly one visible treeitem is tabbable at a time.
* Whether this treeitem is the initial roving-tabindex seed. Exactly one
* treeitem is seeded tabbable at mount; useTreeFocus (hasRovingTabIndex)
* then owns the tab stop dynamically.
*/
isTabbable: boolean;
}
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/hooks/useTreeFocus.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export const docs = {
description: 'Notified when the hook moves focus to a treeitem. Consumers use this to move a single roving tab stop.',
required: false,
},
{
name: 'options.hasRovingTabIndex',
type: 'boolean',
description: 'When true, the hook owns a single roving tab stop across the visible treeitems (stamps tabindex 0/-1, repairs on mount, moves with navigation). Preserves an existing tabindex="0" seed on mount. Attach the returned `handleFocus` to keep the stop in sync after clicks.',
default: 'false',
required: false,
},
{
name: 'options.typeahead',
type: 'boolean',
Expand All @@ -68,6 +75,11 @@ export const docs = {
type: '(e: React.KeyboardEvent) => void',
description: 'Key down handler to attach to the tree container.',
},
{
name: 'handleFocus',
type: '(e: React.FocusEvent) => void',
description: 'Focus handler to attach to the container\'s onFocus. Keeps the roving tab stop in sync when hasRovingTabIndex is enabled; a no-op otherwise, so always safe to attach.',
},
{
name: 'focusFirst',
type: '() => void',
Expand Down Expand Up @@ -106,11 +118,13 @@ export const docsDense = {
'options.onToggleExpand': 'expand/collapse treeitem by id (ArrowRight collapsed parent, ArrowLeft expanded parent, Enter/Space parent w/o own action).',
'options.onActivate': 'called on Enter/Space activation. Return true when handled; else hook falls back to toggling expansion.',
'options.onActiveChange': 'notified when focus moves to a treeitem. Use to move a single roving tab stop.',
'options.hasRovingTabIndex': 'hook owns a single roving tab stop (stamps tabindex 0/-1, repairs on mount, moves w/ nav). Preserves an existing tabindex="0" seed. Attach handleFocus to sync after clicks.',
'options.typeahead': 'enable typeahead (jump to next item whose text starts with typed chars).',
},
returnDescriptions: {
treeRef: 'ref to attach to tree container (role="tree").',
handleKeyDown: 'key down handler for tree container.',
handleFocus: 'onFocus handler; keeps roving tab stop in sync when hasRovingTabIndex on (no-op otherwise).',
focusFirst: 'focus first enabled visible treeitem.',
focusLast: 'focus last enabled visible treeitem.',
},
Expand Down
107 changes: 101 additions & 6 deletions packages/core/src/hooks/useTreeFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

/**
* @file useTreeFocus.ts
* @input Uses React useCallback, useRef
* @input Uses React useCallback, useRef, useIsomorphicLayoutEffect
* @output Exports useTreeFocus hook for WAI-ARIA tree keyboard navigation
* @position Core hook; used by TreeList for roving tabindex + APG tree keyboard model
*
Expand All @@ -15,6 +15,7 @@
*/

import {useCallback, useRef} from 'react';
import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect';

/** Keys handled by the tree keyboard model (used to gate typeahead). */
const NAVIGATION_KEYS = new Set([
Expand Down Expand Up @@ -109,7 +110,10 @@ export interface UseTreeFocusOptions {
* @param item The focused treeitem element.
* @param id The treeitem's id (per `getItemId`), if any.
*/
onActivate?: (item: HTMLElement, id: string | undefined) => boolean | undefined;
onActivate?: (
item: HTMLElement,
id: string | undefined,
) => boolean | undefined;

/**
* Whether typeahead (jump to next item whose text starts with the typed
Expand All @@ -126,6 +130,25 @@ export interface UseTreeFocusOptions {
* TreeList uses this to move its single roving tab stop.
*/
onActiveChange?: (id: string | undefined) => void;

/**
* Roving-tabindex ownership. When true, the hook manages a single tab stop
* across the visible treeitems: exactly one enabled treeitem carries
* `tabindex="0"` and the rest `tabindex="-1"`. The tab stop is repaired on
* mount and whenever items mount/unmount or toggle disabled, and moves with
* keyboard navigation. Attach the returned {@link UseTreeFocusReturn.handleFocus}
* to the container's `onFocus` to keep the stop in sync after clicks or
* programmatic focus.
*
* On mount the hook preserves an existing `tabindex="0"` treeitem (so a
* consumer can seed the active item in its render); if none exists it
* promotes the first enabled treeitem.
*
* When false (the default), the hook only *moves* focus (`.focus()`) and
* never touches `tabindex` — the caller owns tab-stop management.
* @default false
*/
hasRovingTabIndex?: boolean;
}

/**
Expand All @@ -138,6 +161,13 @@ export interface UseTreeFocusReturn<T extends HTMLElement = HTMLElement> {
/** Key down handler to attach to the tree container. */
handleKeyDown: (e: React.KeyboardEvent) => void;

/**
* Focus handler to attach to the container's `onFocus`. Keeps the roving tab
* stop in sync when `hasRovingTabIndex` is enabled; a no-op otherwise, so it
* is always safe to attach.
*/
handleFocus: (e: React.FocusEvent) => void;

/** Focus the first enabled visible treeitem. */
focusFirst: () => void;

Expand Down Expand Up @@ -167,12 +197,12 @@ export interface UseTreeFocusReturn<T extends HTMLElement = HTMLElement> {
*
* @example
* ```
* const {treeRef, handleKeyDown} = useTreeFocus<HTMLUListElement>({
* const {treeRef, handleKeyDown, handleFocus} = useTreeFocus<HTMLUListElement>({
* onToggleExpand: id => toggle(id),
* onActiveChange: id => setActiveId(id),
* hasRovingTabIndex: true,
* });
*
* <ul ref={treeRef} role="tree" onKeyDown={handleKeyDown}>
* <ul ref={treeRef} role="tree" onKeyDown={handleKeyDown} onFocus={handleFocus}>
* {items.map(item => <li role="treeitem" tabIndex={-1}>{item.label}</li>)}
* </ul>
* ```
Expand All @@ -192,6 +222,7 @@ export function useTreeFocus<T extends HTMLElement = HTMLElement>(
typeahead = true,
typeaheadResetMs = DEFAULT_TYPEAHEAD_RESET_MS,
onActiveChange,
hasRovingTabIndex = false,
} = options;

const treeRef = useRef<T>(null);
Expand Down Expand Up @@ -245,16 +276,68 @@ export function useTreeFocus<T extends HTMLElement = HTMLElement>(
[getItemId],
);

// --- Roving tabindex ownership (opt-in via `hasRovingTabIndex`) -------------

/**
* Set `tabindex` on a treeitem, but only when it differs (avoids redundant
* DOM writes).
*/
const setTabIndex = useCallback((el: HTMLElement, value: 0 | -1) => {
if (el.getAttribute('tabindex') !== String(value)) {
el.setAttribute('tabindex', String(value));
}
}, []);

/**
* Make `target` the sole tabbable treeitem: 0 on it, -1 on every other
* visible treeitem.
*/
const moveTabStop = useCallback(
(items: HTMLElement[], target: HTMLElement) => {
for (const el of items) {
setTabIndex(el, el === target ? 0 : -1);
}
},
[setTabIndex],
);

/**
* Repair the roving tab stop: exactly one enabled treeitem is tabbable (0),
* the rest are -1. Prefer an existing `tabindex="0"` treeitem (so a consumer
* can seed the active item in its render); otherwise promote the first
* enabled treeitem.
*/
const syncTabStops = useCallback(() => {
const items = getItems();
const enabled = items.filter(el => !itemDisabled(el));
if (enabled.length === 0) {
return;
}
const current = enabled.find(el => el.getAttribute('tabindex') === '0');
moveTabStop(items, current ?? enabled[0]);
}, [getItems, itemDisabled, moveTabStop]);

// Keep the tab stop valid across renders (items added/removed, disabled
// toggled). Runs after every commit but only when roving tabindex is on.
useIsomorphicLayoutEffect(() => {
if (hasRovingTabIndex) {
syncTabStops();
}
});

/** Move focus to a treeitem and notify the active-change listener. */
const focusItem = useCallback(
(el: HTMLElement | undefined) => {
if (el == null) {
return;
}
if (hasRovingTabIndex) {
moveTabStop(getItems(), el);
}
onActiveChange?.(idOf(el));
el.focus();
},
[idOf, onActiveChange],
[idOf, onActiveChange, hasRovingTabIndex, moveTabStop, getItems],
);

/**
Expand Down Expand Up @@ -455,9 +538,21 @@ export function useTreeFocus<T extends HTMLElement = HTMLElement>(
],
);

/**
* Keep the roving stop pointing at whatever ended up focused (e.g. a click
* or programmatic focus) so the next Tab behaves correctly. No-op unless
* roving tabindex is enabled.
*/
const handleFocus = useCallback(() => {
if (hasRovingTabIndex) {
syncTabStops();
}
}, [hasRovingTabIndex, syncTabStops]);

return {
treeRef,
handleKeyDown,
handleFocus,
focusFirst,
focusLast,
};
Expand Down
Loading