Skip to content

fix: preserve tree focus when items arrive - #10689

Merged
LFDanLu merged 4 commits into
adobe:mainfrom
minwookshin:codex/tree-empty-focus
Oct 12, 2026
Merged

LFDanLu merged 4 commits into
adobe:mainfrom
minwookshin:codex/tree-empty-focus

Conversation

@minwookshin

@minwookshin minwookshin commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #10660

A focused empty Tree should remain keyboard-navigable when its rows arrive. Keep the collection's focus handlers attached while empty, while allowing normal Tab navigation and text selection in empty-state content. This keeps focus tracking intact without moving focus back after the user has left the collection; ListBox behavior is unchanged.

Added Tree and GridList regressions and a PopulateFocusedEmptyTree story that adds rows without moving focus. AI-assisted; repository guidance was followed and the diff was reviewed.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component). No public API or documented behavior changes; added a reproduction story.
  • Looked at the Accessibility Practices for this feature - Treegrid pattern.
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at AGENTS.md.

📝 Test Instructions:

Open the new Tree story, Tab into the empty tree, press Enter to populate it, and use ArrowDown to navigate both rows. In a loading example, moving focus out before items arrive should leave focus outside the tree. Empty-state links should work in both Tab directions, and text inputs should retain their native select-all shortcut.

  • Full unit suite: 383 suites, 8,376 tests and 262 snapshots passed (16 tests skipped), using one worker with a memory limit.
  • Full SSR suite: 60 suites / 74 tests passed.
  • Removed the duplicate browser regression following review; the Tree unit test covers the reported bug.
  • Formatting, types, oxlint, package checks and constraints passed, run sequentially.
  • Previously verified the new Storybook example: Tab into the empty tree, Enter to add rows, then ArrowDown to One and Two.

Local runner notes: SSR used BROWSERSLIST_IGNORE_OLD_DATA=true for an outdated data warning. Storybook needed a temporary PostCSS path-decoding workaround for a non-ASCII checkout path; it was restored after verification and is not part of this PR.

🧢 Your Project:

Independent open-source contribution.

Comment on lines +197 to +200
listProps,
// Keep the collection's focus handlers when empty so arriving items can receive keyboard focus.
// Make the grid tabbable unless the empty state contains a tabbable element.
state.collection.size === 0 ? {tabIndex: hasTabbableChild ? -1 : 0} : undefined,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is ok, for others following the logic

listProps consists of:

data-collection: "react-aria3579033781-_r_2am_"
onBlur: (e)=> {…}
onFocus: (e)=> {…}
onKeyDown: (...args)=> {…}
onKeyDownCapture: (e)=> {…}
onKeyUp: (...args)=> {…}
onMouseDown: ƒ onMouseDown(e)
tabIndex: 0

So now all of those event handlers will be there all the time.

In addition, we'll always have the collection id, which I think we were missing and was a bug as well.

And finally, the tabIndex. It's the same logic as before, just looks different.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One problem with this is for our renderEmptyState since that could have tabbable content, which now can't be fully tabbed through since useSelectableCollection will force the gridlist/tree to be a single tab stop unfortunately.

@minwookshin #3399 details some of the cases that prompted the original change, maybe we could change useSelectableCollection to no longer do that?

@snowystinger snowystinger Oct 8, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That use case isn't supported in ListBox right now, not sure which one has the right of it though. ListBox might be limited by aria roles. If the change to useSelectableCollection fixes it for GridList, we'll need to double check on ListBox to make sure that matches expectations too.

Here's two tests to compare.

ListBox test
  it('should support empty state', async () => {
    let {getByRole} = render(
      <ListBox
        aria-label="Test"
        renderEmptyState={() => (
          <div>
            <a href="/#">No results</a>
            <a href="/#">Another link</a>
          </div>
        )}>
        {[]}
      </ListBox>
    );

    let listbox = getByRole('listbox');
    expect(listbox).toHaveAttribute('data-empty', 'true');

    await user.tab();
    expect(document.activeElement).toBe(getByRole('link', {name: 'No results'}));
    await user.tab();
    expect(document.activeElement).toBe(getByRole('link', {name: 'Another link'}));
  });
GridList test
  it('can tab to all interactive elements in an empty state with focusable content', async () => {
    let {getByRole} = render(
      <GridList
        aria-label="Test"
        renderEmptyState={() => (
          <div>
            <a href="/#">No results</a>
            <a href="/#">Another link</a>
          </div>
        )}>
        {[]}
      </GridList>
    );

    let gridList = document.querySelector('.react-aria-GridList');
    expect(gridList).toHaveAttribute('data-empty', 'true');

    await user.tab();
    expect(document.activeElement).toBe(getByRole('link', {name: 'No results'}));
    await user.tab();
    expect(document.activeElement).toBe(getByRole('link', {name: 'Another link'}));
  });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept Tab and text selection working in empty GridList/Tree content using the existing collection options. Added regressions for both Tab directions and input editing, and verified that ListBox behavior is unchanged.

const CONTAINER_HEIGHT = 300;
const items = Array.from({length: 50}, (_, i) => ({id: `item-${i}`, name: `Item ${i}`}));

it('navigates to asynchronously added rows after focusing an empty tree', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove this one, browser tests are expensive and you showed that the unit tests can prove this.
Or did you add it for a particular reason?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed this in 6074352; it duplicated the empty-to-populated keyboard path already covered by the Tree unit test. There wasn't a separate browser-specific case.

});

it.each([false, true])(
'should preserve focus when items arrive after an empty list (blurred: %s)',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's preserving focus? did you run into a bug that necessitated these conditions? or is there a code branch that this is covering?

@snowystinger snowystinger Oct 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be good if you answered these questions (all comments in review) in addition to making changes, otherwise it's hard to follow intent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The focused case covers #10660 through the shared GridList hook; the tabbed-away case checks that loading items doesn't pull focus back. That second case is a regression guard, not a separate reported bug, and I split the two into named tests in 6074352.

Comment thread packages/react-aria-components/test/GridList.test.js Outdated
@minwookshin

Copy link
Copy Markdown
Contributor Author

Removed the duplicate browser test and split the focused and blurred flows into separate unit tests. The second case checks that items arriving after Tab don't move focus back into the list.

snowystinger
snowystinger previously approved these changes Oct 6, 2026

@snowystinger snowystinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I think this looks good

@LFDanLu LFDanLu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the fix!

@LFDanLu
LFDanLu added this pull request to the merge queue Oct 12, 2026
Merged via the queue into adobe:main with commit 821c011 Oct 12, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An empty Tree that holds focus does not move focus to its rows when they arrive

3 participants