Skip to content
Open
5 changes: 4 additions & 1 deletion packages/dev/s2-docs/pages/react-aria/Tree.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -448,15 +448,18 @@ function Example() {

## API

Place a `<Text slot="description">` inside `TreeItemContent` to provide an accessible description for a row. It is associated with the row using `aria-describedby`, keeping the row's accessible name separate from its description.

<Anatomy role="img" aria-label="Anatomy diagram of a Tree, consisting of multiple items. Each item in the tree contains a selection checkbox, an expand toggle button, and the name of the item." />

```tsx links={{Tree: '#tree', TreeItem: '#treeitem', TreeItemContent: '#treeitemcontent', TreeSection: '#treesection', TreeHeader: '#treeheader', Button: 'Button', Checkbox: 'Checkbox', TreeLoadMoreItem: '#treeloadmoreitem', SelectionIndicator: 'selection#animated-selectionindicator'}}
```tsx links={{Tree: '#tree', TreeItem: '#treeitem', TreeItemContent: '#treeitemcontent', TreeSection: '#treesection', TreeHeader: '#treeheader', Button: 'Button', Checkbox: 'Checkbox', TreeLoadMoreItem: '#treeloadmoreitem', Text: 'Text', SelectionIndicator: 'selection#animated-selectionindicator'}}
<Tree>
<TreeItem>
<TreeItemContent>
<Button slot="chevron" />
<Checkbox slot="selection" /> or <SelectionIndicator />
<Button slot="drag" />
<Text slot="description" />
</TreeItemContent>
<TreeItem>
{/* ... */}
Expand Down
5 changes: 2 additions & 3 deletions packages/react-aria-components/src/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ import React, {
} from 'react';
import {SelectionIndicatorContext} from './SelectionIndicator';
import {SharedElementTransition} from './SharedElementTransition';
import {TextContext} from './Text';
import {TreeDropTargetDelegate} from './TreeDropTargetDelegate';
import {TreeState, useTreeState} from 'react-stately/useTreeState';
import {useCachedChildren} from 'react-aria/private/collections/useCachedChildren';
Expand Down Expand Up @@ -782,8 +783,6 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
let isDraggable =
dragState && !(dragState.isDisabled || dragState.selectionManager.isDisabled(item.key));

// TODO: remove this when we support description in tree row
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let {rowProps, gridCellProps, expandButtonProps, descriptionProps, ...states} = useTreeItem(
{
node: item,
Expand Down Expand Up @@ -1020,7 +1019,7 @@ export const TreeItem = /*#__PURE__*/ createBranchComponent(
}
}
],
// TODO: support description in the tree row
[TextContext, {slots: {[DEFAULT_SLOT]: {}, description: descriptionProps}}],
// TODO: don't think I need to pass isExpanded to the button here since it can be sourced from the renderProps? Might be worthwhile passing it down?
[
ButtonContext,
Expand Down
30 changes: 30 additions & 0 deletions packages/react-aria-components/stories/Tree.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,36 @@ const TreeExampleStaticNoActionsRender = <T extends any>(args: TreeProps<T>): JS
</Tree>
);

export const WithDescriptions: TreeStory = args => (
<Tree {...args} aria-label="Files" className={styles.tree} selectionMode="single">
<TreeItem id="projects" textValue="Projects" className={styles['tree-item']}>
<TreeItemContent>
{({isExpanded}) => (
<>
<Button slot="chevron">{isExpanded ? '⏷' : '⏵'}</Button>
<div>
<Text>Projects</Text>
<Text slot="description" style={{display: 'block'}}>
Shared project files
</Text>
</div>
</>
)}
</TreeItemContent>
<TreeItem id="report" textValue="Report" className={styles['tree-item']}>
<TreeItemContent>
<div>
<Text>Report</Text>
<Text slot="description" style={{display: 'block'}}>
Last updated today
</Text>
</div>
</TreeItemContent>
</TreeItem>
</TreeItem>
</Tree>
);

export const TreeExampleStatic: StoryObj<typeof TreeExampleStaticRender> = {
render: args => <TreeExampleStaticRender {...args} />,
args: {
Expand Down
84 changes: 84 additions & 0 deletions packages/react-aria-components/test/Tree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,90 @@ describe('Tree', () => {
}
});

describe('descriptions', () => {
function DescriptionTree({
showDescription = true,
Comment thread
snowystinger marked this conversation as resolved.
Outdated
label
}: {
showDescription?: boolean;
label?: string;
Comment thread
snowystinger marked this conversation as resolved.
Outdated
}) {
return (
<Tree aria-label="Files" selectionMode="single">
<TreeItem id="projects" textValue="Projects" aria-label={label}>
<TreeItemContent>
<Button slot="chevron">Expand</Button>
<Text>Projects</Text>
{showDescription && (
<Text slot="description" id="tree-desc">
Shared files
</Text>
)}
</TreeItemContent>
<TreeItem id="report" textValue="Report">
<TreeItemContent>Report</TreeItemContent>
</TreeItem>
</TreeItem>
</Tree>
);
}

it.each([undefined, 'Team projects'])(
'keeps the row name separate from its description (%s)',
label => {
let {getByRole, getByText} = render(<DescriptionTree label={label} />);
let row = getByRole('row', {name: label || 'Projects'});
expect(row).toHaveAccessibleDescription('Shared files');
expect(row).toHaveAttribute('aria-describedby', getByText('Shared files').id);
expect(row).not.toHaveAttribute('aria-labelledby');
expect(getByRole('button')).toHaveAccessibleName(`Expand ${label || 'Projects'}`);
}
);

it('updates the description relationship when the slot is removed and added', () => {
let {getByRole, rerender} = render(<DescriptionTree />);
expect(getByRole('row')).toHaveAccessibleDescription('Shared files');
rerender(<DescriptionTree showDescription={false} />);
expect(getByRole('row')).not.toHaveAttribute('aria-describedby');
rerender(<DescriptionTree />);
expect(getByRole('row')).toHaveAccessibleDescription('Shared files');
});

it('preserves keyboard selection and expansion with a description', async () => {
Comment thread
snowystinger marked this conversation as resolved.
Outdated
let {getByRole} = render(<DescriptionTree />);
await user.tab();
let row = getByRole('row', {name: 'Projects'});
expect(row).toHaveFocus();
await user.keyboard(' ');
expect(row).toHaveAttribute('aria-selected', 'true');
await user.keyboard('{ArrowRight}{ArrowDown}');
expect(row).toHaveAttribute('aria-expanded', 'true');
expect(getByRole('row', {name: 'Report'})).toHaveFocus();
});

it('preserves explicit labels and additional descriptions', () => {
let {getByRole} = render(
<>
<span id="row-description">Read only</span>
<Tree aria-label="Files">
<TreeItem
id="projects"
textValue="Projects"
aria-label="Team projects"
aria-describedby="row-description">
<TreeItemContent>
Projects <Text slot="description">Shared files</Text>
</TreeItemContent>
</TreeItem>
</Tree>
</>
);
expect(getByRole('row', {name: 'Team projects'})).toHaveAccessibleDescription(
'Read only Shared files'
);
});
});

it('should render a Tree with custom classes', () => {
let {getByRole, getAllByRole} = render(
<StaticTree treeProps={{className: 'test-tree'}} rowProps={{className: 'test-row'}} />
Expand Down
2 changes: 1 addition & 1 deletion packages/react-aria/src/gridlist/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export function useGridListItem<T>(
let {direction} = useLocale();
let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} =
listMap.get(state)!;
let descriptionId = useSlotId();
let descriptionId = useSlotId([node]);

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 assume this is related to this test?:
updates the description relationship when the slot is removed and added

If so, was this an existing bug in GridList? and can you add a test specifically for GridList. It can be basically the same as the Tree one. That way we can verify before and after

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.

Would node.key (or id? i'm going off memory for this comment) be better?

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.

don't forget to answer in comment thread

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.

Yes, GridList had the same stale aria-labelledby reference; the new test covers removing and re-adding a description with an explicit ID. node.key still fails because the item's key stays the same when the description changes.

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.

but won't the node change on pretty much every render? I think they are immutable if i recall correctly, so this could be a lot of re-renders?

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.

You were right: with no description, changing an unrelated item prop caused two extra aria-labelledby updates. I narrowed the dependency to node.props.children, so those updates no longer refresh the slot. Description removal and reinsertion still work.

Added regressions for Tree and GridList. Full unit, SSR and browser suites pass; the browser run needed retries for intermittent WebKit failures in other components.

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.

If someone is using the renderProps children as a function won't that cause new children every render as well?

@minwookshin minwookshin Oct 21, 2026 •

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.

You're right. Inline functions caused extra updates, and stable functions missed descriptions added on selection. The slot now keeps its ID and updates presence only when it changes, which also preserves React 18 focus restoration. Added both render-function cases for Tree and GridList; the full unit suites on React 18 and 19, SSR and browser checks pass.


// We need to track the key of the item at the time it was last focused so that we force
// focus to go to the item when the DOM node is reused for a different item in a virtualizer.
Expand Down
8 changes: 8 additions & 0 deletions packages/react-aria/src/tree/useTreeItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ export function useTreeItem<T>(
// TODO: should it return a state specifically for isExpanded? Or is aria attribute sufficient?
return {
...gridListAria,
rowProps: {

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 assume you don't need this override. We can just inherit the behavior from useGridListItem?

...gridListAria.rowProps,
'aria-labelledby': undefined,
'aria-describedby':
[node.props['aria-describedby'], gridListAria.descriptionProps.id]
.filter(Boolean)
.join(' ') || undefined
},
expandButtonProps
};
}
Loading