Skip to content
Merged
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/deprecate-focus-hook-isrtl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] Deprecate the `isRtl` option on `useListFocus` and `useGridFocus`. Right-to-left arrow-key direction is now auto-detected from the container, so the explicit override is redundant and will be removed in an upcoming major — omit it and RTL is handled automatically.
@nynexman4464
6 changes: 6 additions & 0 deletions .changeset/tree-focus-rtl-arrows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@astryxdesign/core': patch
---

[fix] TreeList arrow-key navigation now follows visual direction in RTL: ArrowLeft expands and ArrowRight collapses under `dir="rtl"` (mirrored from LTR). Detected automatically; LTR is unchanged.
@nynexman4464
8 changes: 2 additions & 6 deletions packages/core/src/AvatarGroup/AvatarGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,9 @@ export function AvatarGroup({
);

// The keyboard hint and roving tab stop only make sense once the group has
// interactive children. Detect their presence (and the writing direction for
// RTL-correct arrow navigation) from the rendered DOM after commit, matching
// how the codebase reads direction elsewhere (`getComputedStyle(el).direction`).
// interactive children. Detect their presence from the rendered DOM after
// commit. (Arrow-key direction is auto-detected inside useListFocus.)
const [hasInteractiveItems, setHasInteractiveItems] = useState(false);
const [isRtl, setIsRtl] = useState(false);

// Single tab stop + roving arrow focus over the group's interactive items.
// `itemSelector` targets the shared `[data-avatar-item]` marker stamped on
Expand All @@ -123,7 +121,6 @@ export function AvatarGroup({
itemSelector: '[data-avatar-item]',
orientation: 'horizontal',
hasRovingTabIndex: true,
isRtl,
});

useIsomorphicLayoutEffect(() => {
Expand All @@ -132,7 +129,6 @@ export function AvatarGroup({
return;
}
setHasInteractiveItems(root.querySelector('[data-avatar-item]') != null);
setIsRtl(getComputedStyle(root).direction === 'rtl');
});

const hintId = useId();
Expand Down
14 changes: 8 additions & 6 deletions packages/core/src/hooks/useGridFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,15 @@ export interface UseGridFocusOptions {
onPageDown?: () => void;

/**
* Whether the grid is in a right-to-left context. When true, ArrowLeft and
* ArrowRight are swapped so horizontal navigation follows visual direction.
* @deprecated Direction is auto-detected from the container's computed
* `direction` — omit this. The explicit override is redundant (there's no
* valid reason to force RTL arrows in an LTR context) and will be removed in
* an upcoming major.
*
* When omitted, the direction is auto-detected from the container's computed
* `direction` (read lazily on keydown, only for horizontal arrow keys), so
* grids inside `dir="rtl"` subtrees flip automatically. Pass an explicit
* boolean to override detection.
* When set, forces whether the grid is right-to-left: ArrowLeft/ArrowRight
* are swapped so horizontal navigation follows visual direction. When
* omitted (preferred), the direction is auto-detected from the container's
* computed `direction` (read lazily on keydown, horizontal arrows only).
* @default undefined (auto-detect from the container)
*/
isRtl?: boolean;
Expand Down
15 changes: 8 additions & 7 deletions packages/core/src/hooks/useListFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,15 @@ export interface UseListFocusOptions {
hasHomeEnd?: boolean;

/**
* Whether the list is in a right-to-left context. When true, ArrowLeft and
* ArrowRight are swapped so horizontal navigation follows visual direction.
* Only affects horizontal (`'horizontal'`/`'both'`) navigation.
* @deprecated Direction is auto-detected from the container's computed
* `direction` — omit this. The explicit override is redundant (there's no
* valid reason to force RTL arrows in an LTR context) and will be removed in
* an upcoming major.
*
* When omitted, the direction is auto-detected from the container's computed
* `direction` (read lazily on keydown, only for horizontal arrow keys), so
* lists inside `dir="rtl"` subtrees flip automatically. Pass an explicit
* boolean to override detection.
* When set, forces whether the list is right-to-left: ArrowLeft/ArrowRight
* are swapped so horizontal navigation follows visual direction. When
* omitted (preferred), the direction is auto-detected from the container's
* computed `direction` (read lazily on keydown, horizontal arrows only).
* @default undefined (auto-detect from the container)
*/
isRtl?: boolean;
Expand Down
54 changes: 53 additions & 1 deletion packages/core/src/hooks/useTreeFocus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ function Tree({
collapsed = NO_NODES,
expanded = NO_NODES,
onActivate,
dir,
}: {
collapsed?: Node[];
expanded?: Node[];
onActivate?: (id: string | undefined) => boolean | undefined;
dir?: 'ltr' | 'rtl';
}) {
const [isOpen, setIsOpen] = useState(false);
const nodes = isOpen && expanded.length > 0 ? expanded : collapsed;
Expand All @@ -47,7 +49,7 @@ function Tree({
onActivate: onActivate ? (_item, id) => onActivate(id) : undefined,
});
return (
<ul ref={treeRef} role="tree" onKeyDown={handleKeyDown}>
<ul ref={treeRef} role="tree" dir={dir} onKeyDown={handleKeyDown}>
{nodes.map(n => (
<li
key={n.id}
Expand Down Expand Up @@ -172,6 +174,56 @@ describe('useTreeFocus tree semantics (Arrow Left/Right)', () => {
});
});

describe('useTreeFocus RTL tree semantics (WAI-ARIA Tree View)', () => {
const COLLAPSED: Node[] = [
{id: 'p', label: 'Parent', level: 1, expanded: false},
];
const EXPANDED: Node[] = [
{id: 'p', label: 'Parent', level: 1, expanded: true},
{id: 'c1', label: 'Child 1', level: 2},
{id: 'c2', label: 'Child 2', level: 2},
];

it('auto-detects dir="rtl": ArrowLeft expands a collapsed parent, then enters the first child', () => {
render(<Tree collapsed={COLLAPSED} expanded={EXPANDED} dir="rtl" />);
const tree = screen.getByRole('tree');
screen.getByTestId('p').focus();

expect(screen.queryByTestId('c1')).not.toBeInTheDocument();
fireEvent.keyDown(tree, {key: 'ArrowLeft'}); // RTL: descend → expand
expect(screen.getByTestId('c1')).toBeInTheDocument();
expect(screen.getByTestId('p')).toHaveFocus();

fireEvent.keyDown(tree, {key: 'ArrowLeft'}); // RTL: descend → into first child
expect(screen.getByTestId('c1')).toHaveFocus();
});

it('auto-detects dir="rtl": ArrowRight moves to parent, then collapses', () => {
render(<Tree collapsed={COLLAPSED} expanded={EXPANDED} dir="rtl" />);
const tree = screen.getByRole('tree');
screen.getByTestId('p').focus();
fireEvent.keyDown(tree, {key: 'ArrowLeft'}); // expand
fireEvent.keyDown(tree, {key: 'ArrowLeft'}); // into child 1
expect(screen.getByTestId('c1')).toHaveFocus();

fireEvent.keyDown(tree, {key: 'ArrowRight'}); // RTL: ascend → child leaf → parent
expect(screen.getByTestId('p')).toHaveFocus();

fireEvent.keyDown(tree, {key: 'ArrowRight'}); // RTL: ascend → expanded parent → collapse
expect(screen.queryByTestId('c1')).not.toBeInTheDocument();
});

it('vertical keys (ArrowDown/ArrowUp) are unaffected by RTL', () => {
render(<Tree collapsed={FLAT} dir="rtl" />);
const tree = screen.getByRole('tree');
screen.getByTestId('a').focus();
fireEvent.keyDown(tree, {key: 'ArrowDown'});
expect(screen.getByTestId('b')).toHaveFocus();
fireEvent.keyDown(tree, {key: 'ArrowUp'});
expect(screen.getByTestId('a')).toHaveFocus();
});
});

describe('useTreeFocus activation + typeahead', () => {
it('Enter/Space call onActivate for the focused item', () => {
const onActivate = vi.fn(() => true);
Expand Down
15 changes: 13 additions & 2 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, useIsomorphicLayoutEffect
* @input Uses React useCallback, useRef, useIsomorphicLayoutEffect, isRtlElement
* @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 @@ -16,6 +16,7 @@

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

/** Keys handled by the tree keyboard model (used to gate typeahead). */
const NAVIGATION_KEYS = new Set([
Expand Down Expand Up @@ -429,7 +430,17 @@ export function useTreeFocus<T extends HTMLElement = HTMLElement>(
return;
}

switch (e.key) {
// Under RTL, swap the horizontal arrows to a logical key so the
// expand/collapse case bodies (written LTR-first) stay unchanged.
let key = e.key;
if (key === 'ArrowLeft' || key === 'ArrowRight') {
const rtl = isRtlElement(treeRef.current);
if (rtl) {
key = key === 'ArrowLeft' ? 'ArrowRight' : 'ArrowLeft';
}
}

switch (key) {
case 'ArrowDown': {
e.preventDefault();
focusEnabledFrom(items, currentIndex < 0 ? 0 : currentIndex + 1, 1);
Expand Down
Loading