Skip to content

fix: TreeView inadvertent focus trap #8277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 3, 2025
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
5 changes: 3 additions & 2 deletions packages/@react-aria/tree/src/useTreeItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {AriaGridListItemOptions, GridListItemAria, useGridListItem} from '@react
import {DOMAttributes, FocusableElement, Node, RefObject} from '@react-types/shared';
// @ts-ignore
import intlMessages from '../intl/*.json';
import {isAndroid, useLabels} from '@react-aria/utils';
import {TreeState} from '@react-stately/tree';
import {useLabels} from '@react-aria/utils';
import {useLocalizedStringFormatter} from '@react-aria/i18n';

export interface AriaTreeItemOptions extends Omit<AriaGridListItemOptions, 'isVirtualized'> {
Expand Down Expand Up @@ -59,7 +59,8 @@ export function useTreeItem<T>(props: AriaTreeItemOptions, state: TreeState<T>,
state.selectionManager.setFocusedKey(node.key);
}
},
tabIndex: isAndroid() ? -1 : null,
excludeFromTabOrder: true,
preventFocusOnPress: true,
'data-react-aria-prevent-focus': true,
...labelProps
};
Expand Down
7 changes: 1 addition & 6 deletions packages/@react-spectrum/s2/src/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import {colorMix, focusRing, fontRelative, lightDark, style} from '../style' wit
import {DOMRef, Key} from '@react-types/shared';
import {getAllowedOverrides, StylesPropWithHeight, UnsafeStyles} from './style-utils' with {type: 'macro'};
import {IconContext} from './Icon';
import {isAndroid} from '@react-aria/utils';
import {raw} from '../style/style-macro' with {type: 'macro'};
import React, {createContext, forwardRef, JSXElementConstructor, ReactElement, ReactNode, useContext, useRef} from 'react';
import {TextContext} from './Content';
Expand Down Expand Up @@ -413,18 +412,14 @@ const expandButton = style<ExpandableRowChevronProps>({
function ExpandableRowChevron(props: ExpandableRowChevronProps) {
let expandButtonRef = useRef<HTMLButtonElement>(null);
let [fullProps, ref] = useContextProps({...props, slot: 'chevron'}, expandButtonRef, ButtonContext);
let {isExpanded, isDisabled, scale, isHidden} = fullProps;
let {isExpanded, scale, isHidden} = fullProps;
let {direction} = useLocale();
isDisabled = isDisabled || isHidden;

return (
<Button
{...props}
ref={ref}
slot="chevron"
// Override tabindex so that grid keyboard nav skips over it. Needs -1 so android talkback can actually "focus" it
excludeFromTabOrder={isAndroid() && !isDisabled}
preventFocusOnPress
className={renderProps => expandButton({...renderProps, isExpanded, isRTL: direction === 'rtl', scale, isHidden})}>
<Chevron
className={style({
Expand Down
56 changes: 56 additions & 0 deletions packages/@react-spectrum/s2/stories/TreeView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,62 @@ export const Example = {
}
};

const TreeExampleStaticNoActions = (args) => (
<div style={{width: '300px', resize: 'both', height: '320px', overflow: 'auto'}}>
<TreeView
{...args}
disabledKeys={['projects-1']}
aria-label="test static tree"
onExpandedChange={action('onExpandedChange')}
onSelectionChange={action('onSelectionChange')}>
<TreeViewItem id="Photos" textValue="Photos">
<TreeViewItemContent>
<Text>Photos</Text>
<Folder />
</TreeViewItemContent>
</TreeViewItem>
<TreeViewItem id="projects" textValue="Projects">
<TreeViewItemContent>
<Text>Projects</Text>
<Folder />
</TreeViewItemContent>
<TreeViewItem id="projects-1" textValue="Projects-1">
<TreeViewItemContent>
<Text>Projects-1</Text>
<Folder />
</TreeViewItemContent>
<TreeViewItem id="projects-1A" textValue="Projects-1A">
<TreeViewItemContent>
<Text>Projects-1A</Text>
<FileTxt />
</TreeViewItemContent>
</TreeViewItem>
</TreeViewItem>
<TreeViewItem id="projects-2" textValue="Projects-2">
<TreeViewItemContent>
<Text>Projects-2</Text>
<FileTxt />
</TreeViewItemContent>
</TreeViewItem>
<TreeViewItem id="projects-3" textValue="Projects-3">
<TreeViewItemContent>
<Text>Projects-3</Text>
<FileTxt />
</TreeViewItemContent>
</TreeViewItem>
</TreeViewItem>
</TreeView>
</div>
);

export const ExampleNoActions = {
render: TreeExampleStaticNoActions,
args: {
selectionMode: 'multiple'
}
};


let rows = [
{id: 'projects', name: 'Projects', icon: <Folder />, childItems: [
{id: 'project-1', name: 'Project 1 Level 1', icon: <FileTxt />},
Expand Down
71 changes: 71 additions & 0 deletions packages/@react-spectrum/tree/stories/TreeView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,77 @@ TreeExampleStatic.story = {
}
};

export const TreeExampleStaticNoActions = (args: SpectrumTreeViewProps<unknown>) => (
<div style={{width: '300px', resize: 'both', height: '90vh', overflow: 'auto'}}>
<TreeView {...args} disabledKeys={['projects-1']} aria-label="test static tree" onExpandedChange={action('onExpandedChange')} onSelectionChange={action('onSelectionChange')}>
<TreeViewItem id="Photos" textValue="Photos">
<TreeViewItemContent>
<Text>Photos</Text>
<Folder />
</TreeViewItemContent>
</TreeViewItem>
<TreeViewItem id="projects" textValue="Projects">
<TreeViewItemContent>
<Text>Projects</Text>
<Folder />
</TreeViewItemContent>
<TreeViewItem id="projects-1" textValue="Projects-1">
<TreeViewItemContent>
<Text>Projects-1</Text>
<Folder />
</TreeViewItemContent>
<TreeViewItem id="projects-1A" textValue="Projects-1A">
<TreeViewItemContent>
<Text>Projects-1A</Text>
<FileTxt />
</TreeViewItemContent>
</TreeViewItem>
</TreeViewItem>
<TreeViewItem id="projects-2" textValue="Projects-2">
<TreeViewItemContent>
<Text>Projects-2</Text>
<FileTxt />
</TreeViewItemContent>
</TreeViewItem>
<TreeViewItem id="projects-3" textValue="Projects-3">
<TreeViewItemContent>
<Text>Projects-3</Text>
<FileTxt />
</TreeViewItemContent>
</TreeViewItem>
</TreeViewItem>
</TreeView>
</div>
);

export const ExampleNoActions = {
render: TreeExampleStaticNoActions,
args: {
selectionMode: 'none',
selectionStyle: 'checkbox',
disabledBehavior: 'selection'
},
argTypes: {
selectionMode: {
control: 'radio',
options: ['none', 'single', 'multiple']
},
selectionStyle: {
control: 'radio',
options: ['checkbox', 'highlight']
},
disabledBehavior: {
control: 'radio',
options: ['selection', 'all']
},
disallowEmptySelection: {
control: {
type: 'boolean'
}
}
}
};

let rows = [
{id: 'projects', name: 'Projects', icon: <Folder />, childItems: [
{id: 'project-1', name: 'Project 1', icon: <FileTxt />},
Expand Down
126 changes: 120 additions & 6 deletions packages/react-aria-components/stories/Tree.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,44 @@ const StaticTreeItem = (props: StaticTreeItemProps) => {
);
};

const StaticTreeItemNoActions = (props: StaticTreeItemProps) => {
return (
<TreeItem
{...props}
className={({isFocused, isSelected, isHovered, isFocusVisible}) => classNames(styles, 'tree-item', {
focused: isFocused,
'focus-visible': isFocusVisible,
selected: isSelected,
hovered: isHovered
})}>
<TreeItemContent>
{({isExpanded, hasChildItems, level, selectionMode, selectionBehavior}) => (
<>
{selectionMode !== 'none' && selectionBehavior === 'toggle' && (
<MyCheckbox slot="selection" />
)}
<div
className={classNames(styles, 'content-wrapper')}
style={{marginInlineStart: `${(!hasChildItems ? 20 : 0) + (level - 1) * 15}px`}}>
{hasChildItems && (
<Button className={styles.chevron} slot="chevron">
<div style={{transform: `rotate(${isExpanded ? 90 : 0}deg)`, width: '16px', height: '16px'}}>
<svg viewBox="0 0 24 24" style={{width: '16px', height: '16px'}}>
<path d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</div>
</Button>
)}
<Text className={styles.title}>{props.title || props.children}</Text>
</div>
</>
)}
</TreeItemContent>
{props.title && props.children}
</TreeItem>
);
};

const TreeExampleStaticRender = (args) => (
<Tree className={styles.tree} {...args} disabledKeys={['projects']} aria-label="test static tree" onExpandedChange={action('onExpandedChange')} onSelectionChange={action('onSelectionChange')}>
<StaticTreeItem id="Photos" textValue="Photos">Photos</StaticTreeItem>
Expand Down Expand Up @@ -147,6 +185,53 @@ const TreeExampleStaticRender = (args) => (
</Tree>
);

const TreeExampleStaticNoActionsRender = (args) => (
<Tree className={styles.tree} {...args} disabledKeys={['projects']} aria-label="test static tree" onExpandedChange={action('onExpandedChange')} onSelectionChange={action('onSelectionChange')}>
<StaticTreeItemNoActions id="Photos" textValue="Photos">Photos</StaticTreeItemNoActions>
<StaticTreeItemNoActions id="projects" textValue="Projects" title="Projects">
<StaticTreeItemNoActions id="projects-1" textValue="Projects-1" title="Projects-1">
<StaticTreeItemNoActions id="projects-1A" textValue="Projects-1A">
Projects-1A
</StaticTreeItemNoActions>
</StaticTreeItemNoActions>
<StaticTreeItemNoActions id="projects-2" textValue="Projects-2">
Projects-2
</StaticTreeItemNoActions>
<StaticTreeItemNoActions id="projects-3" textValue="Projects-3">
Projects-3
</StaticTreeItemNoActions>
</StaticTreeItemNoActions>
<StaticTreeItemNoActions
id="reports"
textValue="Reports"
className={({isFocused, isSelected, isHovered, isFocusVisible}) => classNames(styles, 'tree-item', {
focused: isFocused,
'focus-visible': isFocusVisible,
selected: isSelected,
hovered: isHovered
})}>
<TreeItemContent>
Reports
</TreeItemContent>
</StaticTreeItemNoActions>
<StaticTreeItemNoActions
id="Tests"
textValue="Tests"
className={({isFocused, isSelected, isHovered, isFocusVisible}) => classNames(styles, 'tree-item', {
focused: isFocused,
'focus-visible': isFocusVisible,
selected: isSelected,
hovered: isHovered
})}>
<TreeItemContent>
{({isFocused}) => (
<Text>{`${isFocused} Tests`}</Text>
)}
</TreeItemContent>
</StaticTreeItemNoActions>
</Tree>
);

export const TreeExampleStatic = {
render: TreeExampleStaticRender,
args: {
Expand Down Expand Up @@ -176,6 +261,35 @@ export const TreeExampleStatic = {
}
};

export const TreeExampleStaticNoActions = {
render: TreeExampleStaticNoActionsRender,
args: {
selectionMode: 'none',
selectionBehavior: 'toggle',
disabledBehavior: 'selection',
disallowClearAll: false
},
argTypes: {
selectionMode: {
control: 'radio',
options: ['none', 'single', 'multiple']
},
selectionBehavior: {
control: 'radio',
options: ['toggle', 'replace']
},
disabledBehavior: {
control: 'radio',
options: ['selection', 'all']
}
},
parameters: {
description: {
data: 'Note that the last two items are just to test bare minimum TreeItem and thus dont have the checkbox or any of the other contents that the other items have. The last item tests the isFocused renderProp. This story specifically tests tab behaviour when there are no additional actions in the tree.'
}
}
};

let rows = [
{id: 'projects', name: 'Projects', childItems: [
{id: 'project-1', name: 'Project 1'},
Expand Down Expand Up @@ -396,7 +510,7 @@ function LoadingStoryDepOnCollection(args) {
getKey: item => item.id,
getChildren: item => item.childItems
});

return (
<Tree {...args} defaultExpandedKeys={defaultExpandedKeys} disabledKeys={['reports-1AB']} className={styles.tree} aria-label="test dynamic tree" onExpandedChange={action('onExpandedChange')} onSelectionChange={action('onSelectionChange')}>
<Collection items={treeData.items} dependencies={[args.isLoading]}>
Expand Down Expand Up @@ -663,11 +777,11 @@ function SecondTree(args) {
});

return (
<Tree
dragAndDropHooks={dragAndDropHooks}
{...args}
className={styles.tree}
aria-label="Tree with drag and drop"
<Tree
dragAndDropHooks={dragAndDropHooks}
{...args}
className={styles.tree}
aria-label="Tree with drag and drop"
items={treeData.items}
renderEmptyState={() => 'Drop items here'}>
{(item: any) => (
Expand Down