Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
4 changes: 3 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,17 @@ function Example() {

## API


<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
83 changes: 83 additions & 0 deletions packages/react-aria-components/test/GridList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {Popover} from '../src/Popover';
import React from 'react';
import {RouterProvider} from 'react-aria/private/utils/openLink';
import {Tag, TagGroup, TagList} from '../src/TagGroup';
import {Text} from '../src/Text';
import {Toolbar} from '../src/Toolbar';
import userEvent from '@testing-library/user-event';
import {Virtualizer} from '../src/Virtualizer';
Expand Down Expand Up @@ -162,6 +163,88 @@ describe('GridList', () => {
}
});

it('updates the description relationship when the slot is removed and added', () => {
function DescriptionGridList({hideDescription = false}) {
return (
<GridList aria-label="Files">
<GridListItem id="projects" textValue="Projects">
<Text>Projects</Text>
{!hideDescription && (
<Text slot="description" id="grid-desc">
Shared files
</Text>
)}
</GridListItem>
</GridList>
);
}

let {getByRole, rerender} = render(<DescriptionGridList />);
expect(getByRole('row')).toHaveAccessibleName('Projects Shared files');
rerender(<DescriptionGridList hideDescription />);
expect(getByRole('row')).toHaveAccessibleName('Projects');
expect(getByRole('row')).not.toHaveAttribute('aria-labelledby');
rerender(<DescriptionGridList />);
expect(getByRole('row')).toHaveAccessibleName('Projects Shared files');
});

it.each(['stable children', 'inline children', 'inline render function'])(
'does not temporarily reference a missing description with %s',
childType => {
let children = <Text>Projects</Text>;
function DescriptionGridList({revision}) {
return (
<GridList aria-label="Files">
<GridListItem id="projects" textValue="Projects" data-revision={revision}>
{childType === 'stable children' ? (
children
) : childType === 'inline children' ? (
<Text>Projects</Text>
) : (
() => <Text>Projects</Text>
)}
</GridListItem>
</GridList>
);
}

let {getByRole, rerender} = render(<DescriptionGridList revision={0} />);
let row = getByRole('row');
let observer = new MutationObserver(() => {});
observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']});
rerender(<DescriptionGridList revision={1} />);
let mutations = observer.takeRecords();
observer.disconnect();
expect(mutations).toHaveLength(0);
expect(row).toHaveAccessibleName('Projects');
}
);

it('updates descriptions from a stable render function when selection changes', async () => {
let children = ({isSelected}) => (
<>
<Text>Projects</Text>
{isSelected && <Text slot="description">Shared files</Text>}
</>
);
let {getByRole} = render(
<GridList aria-label="Files" selectionMode="single" selectionBehavior="toggle">
<GridListItem id="projects" textValue="Projects">
{children}
</GridListItem>
</GridList>
);
let row = getByRole('row');
expect(row).toHaveAccessibleName('Projects');
await user.click(row);
expect(row).toHaveAttribute('aria-selected', 'true');
expect(row).toHaveAccessibleName('Projects Shared files');
await user.click(row);
expect(row).toHaveAttribute('aria-selected', 'false');
expect(row).toHaveAccessibleName('Projects');
expect(row).not.toHaveAttribute('aria-labelledby');
});

it('should render with custom classes', () => {
let {getByRole, getAllByRole} = renderGridList({className: 'gridlist'}, {className: 'item'});
let grid = getByRole('grid');
Expand Down
110 changes: 110 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,116 @@ describe('Tree', () => {
}
});

describe('descriptions', () => {
it.each(['stable children', 'inline children', 'inline render function'])(
'does not temporarily reference a missing description with %s',
childType => {
let children = (
<TreeItemContent>
<Text>Projects</Text>
</TreeItemContent>
);
function DescriptionTree({revision}: {revision: number}) {
return (
<Tree aria-label="Files">
<TreeItem id="projects" textValue="Projects" data-revision={revision}>
{childType === 'stable children' ? (
children
) : (
<TreeItemContent>
{childType === 'inline children' ? (
<Text>Projects</Text>
) : (
() => <Text>Projects</Text>
)}
</TreeItemContent>
)}
</TreeItem>
</Tree>
);
}

let {getByRole, rerender} = render(<DescriptionTree revision={0} />);
let row = getByRole('row');
let observer = new MutationObserver(() => {});
observer.observe(row, {attributes: true, attributeFilter: ['aria-labelledby']});
rerender(<DescriptionTree revision={1} />);
let mutations = observer.takeRecords();
observer.disconnect();
expect(mutations).toHaveLength(0);
expect(row).toHaveAccessibleName('Projects');
}
);

it('updates descriptions from a stable render function when selection changes', async () => {
let children = ({isSelected}: {isSelected: boolean}) => (
<>
<Text>Projects</Text>
{isSelected && <Text slot="description">Shared files</Text>}
</>
);
let {getByRole} = render(
<Tree aria-label="Files" selectionMode="single" selectionBehavior="toggle">
<TreeItem id="projects" textValue="Projects">
<TreeItemContent>{children}</TreeItemContent>
</TreeItem>
</Tree>
);
let row = getByRole('row');
expect(row).toHaveAccessibleName('Projects');
await user.click(row);
expect(row).toHaveAttribute('aria-selected', 'true');
expect(row).toHaveAccessibleName('Projects Shared files');
await user.click(row);
expect(row).toHaveAttribute('aria-selected', 'false');
expect(row).toHaveAccessibleName('Projects');
expect(row).not.toHaveAttribute('aria-labelledby');
});

function DescriptionTree({
hideDescription = false,
itemAriaLabel
}: {
hideDescription?: boolean;
itemAriaLabel?: string;
}) {
return (
<Tree aria-label="Files">
<TreeItem id="projects" textValue="Projects" aria-label={itemAriaLabel}>
<TreeItemContent>
<Text>Projects</Text>
{!hideDescription && (
<Text slot="description" id="tree-desc">
Shared files
</Text>
)}
</TreeItemContent>
</TreeItem>
</Tree>
);
}

it('includes the description in the row name', () => {
let {getByRole} = render(<DescriptionTree />);
expect(getByRole('row')).toHaveAccessibleName('Projects Shared files');
});

it('includes the description with an explicit item aria-label', () => {
let {getByRole} = render(<DescriptionTree itemAriaLabel="Team projects" />);
expect(getByRole('row')).toHaveAccessibleName('Team projects Shared files');
});

it('updates the description relationship when the slot is removed and added', () => {
let {getByRole, rerender} = render(<DescriptionTree />);
expect(getByRole('row')).toHaveAccessibleName('Projects Shared files');
rerender(<DescriptionTree hideDescription />);
expect(getByRole('row')).toHaveAccessibleName('Projects');
expect(getByRole('row')).not.toHaveAttribute('aria-labelledby');
rerender(<DescriptionTree />);
expect(getByRole('row')).toHaveAccessibleName('Projects 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
19 changes: 15 additions & 4 deletions packages/react-aria/src/gridlist/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,16 @@ import {getFocusableTreeWalker} from '../focus/FocusScope';
import {getOwnerDocument} from '../utils/domHelpers';
import {getRowId, listMap} from './utils';
import {getScrollParent} from '../utils/getScrollParent';
import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react';
import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef, useState} from 'react';
import {isFocusVisible} from '../interactions/useFocusVisible';
import type {ListState} from 'react-stately/useListState';
import {mergeProps} from '../utils/mergeProps';
import {scrollIntoViewport} from '../utils/scrollIntoView';
import {SelectableItemStates, useSelectableItem} from '../selection/useSelectableItem';
import type {TreeState} from 'react-stately/useTreeState';
import {useId} from '../utils/useId';
import {useLayoutEffect} from '../utils/useLayoutEffect';
import {useLocale} from '../i18n/I18nProvider';
import {useSlotId} from '../utils/useId';
import {useSyntheticLinkProps} from '../utils/openLink';

export interface AriaGridListItemOptions {
Expand Down Expand Up @@ -107,7 +108,17 @@ export function useGridListItem<T>(
let {direction} = useLocale();
let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} =
listMap.get(state)!;
let descriptionId = useSlotId();
let descriptionId = useId();

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 appreciate that you looked into this, however, it is pre-existing. Unless you have a use case that you need this for though, let's remove these changes.
If you do have a need for it, lets open a new issue and PR to address it so that we don't incur performance hits.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A callback ref in descriptionProps should work. I agree though, this is out of scope. Other hooks likely have the same problem, so this should be fixed together.

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 the shared GridList hook changes and dynamic-slot tests. This now uses the existing GridList behavior and stays focused on the Tree description slot. Full unit, SSR and browser suites pass; the Tree/GridList tests also pass on React 18.

let [hasDescription, setHasDescription] = useState(true);
// A stable render function can change its output, so recheck after every render.
// Avoid no-op state updates, which can interrupt focus restoration in React 18.
// oxlint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
let isRendered = !!getOwnerDocument(ref.current).getElementById(descriptionId);
if (isRendered !== hasDescription) {
setHasDescription(isRendered);
}
});

// 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 Expand Up @@ -427,7 +438,7 @@ export function useGridListItem<T>(
: undefined,
'aria-disabled': state.selectionManager.isDisabled(node.key) || undefined,
'aria-labelledby':
descriptionId && (node['aria-label'] || node.textValue)
hasDescription && (node['aria-label'] || node.textValue)
? `${getRowId(state, node.key)} ${descriptionId}`
: undefined,
id: getRowId(state, node.key)
Expand Down
Loading