Skip to content

Commit bef549d

Browse files
feat: default expand action for tableview and treeview (#9875)
* feat: default expand action for tableview and treeview * fix lint * fix more lint * Update packages/react-aria/src/gridlist/useGridListItem.ts Co-authored-by: Reid Barber <reid@reidbarber.com> --------- Co-authored-by: Reid Barber <reid@reidbarber.com>
1 parent 139fe90 commit bef549d

File tree

7 files changed

+116
-13
lines changed

7 files changed

+116
-13
lines changed

packages/@react-spectrum/s2/stories/TableView.stories.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,8 @@ import User from '../s2wf-icons/S2_Icon_User_20_N.svg';
4949
import {useTreeData} from 'react-stately/useTreeData';
5050

5151
let onActionFunc = action('onAction');
52-
let noOnAction = null;
52+
let noOnAction = undefined;
5353
const onActionOptions = {onActionFunc, noOnAction};
54-
5554
const events = ['onResizeStart', 'onResize', 'onResizeEnd', 'onSelectionChange', 'onSortChange'];
5655

5756
const meta: Meta<typeof TableView> = {
@@ -63,7 +62,7 @@ const meta: Meta<typeof TableView> = {
6362
tags: ['autodocs'],
6463
args: {...getActionArgs(events)},
6564
argTypes: {
66-
...categorizeArgTypes('Events', ['onAction', 'onLoadMore', 'onResizeStart', 'onResize', 'onResizeEnd', 'onSelectionChange', 'onSortChange']),
65+
...categorizeArgTypes('Events', ['onAction', 'onLoadMore', ...events]),
6766
children: {table: {disable: true}},
6867
onAction: {
6968
options: Object.keys(onActionOptions), // An array of serializable values
@@ -1784,7 +1783,7 @@ export const TableWithNestedRows: StoryObj<typeof TableView> = {
17841783
<Cell>5/22/1980</Cell>
17851784
</Row>
17861785
</Row>
1787-
<Row id="apps">
1786+
<Row id="apps" isDisabled>
17881787
<Cell>Applications</Cell>
17891788
<Cell>Folder</Cell>
17901789
<Cell>4/7/2025</Cell>

packages/@react-spectrum/s2/stories/TreeView.stories.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ import {useAsyncList} from 'react-stately/useAsyncList';
4545
import {useListData} from 'react-stately/useListData';
4646

4747
let onActionFunc = action('onAction');
48-
let noOnAction = null;
48+
let noOnAction = undefined;
4949
const onActionOptions = {onActionFunc, noOnAction};
50-
const events = ['onSelectionChange', 'onAction'];
50+
const events = ['onSelectionChange'];
5151

5252
const meta: Meta<typeof TreeView> = {
5353
component: TreeView,
@@ -57,7 +57,7 @@ const meta: Meta<typeof TreeView> = {
5757
tags: ['autodocs'],
5858
args: {...getActionArgs(events)},
5959
argTypes: {
60-
...categorizeArgTypes('Events', events),
60+
...categorizeArgTypes('Events', ['onAction', ...events]),
6161
children: {table: {disable: true}},
6262
onAction: {
6363
options: Object.keys(onActionOptions), // An array of serializable values
@@ -81,7 +81,7 @@ const TreeExampleStatic = (args: TreeViewProps<any>): ReactElement => (
8181
<div style={{width: '300px', resize: 'both', height: '320px', overflow: 'auto'}}>
8282
<TreeView
8383
{...args}
84-
disabledKeys={['projects-1']}
84+
disabledKeys={['projects']}
8585
aria-label="test static tree"
8686
onExpandedChange={action('onExpandedChange')}
8787
onSelectionChange={action('onSelectionChange')}>

packages/react-aria-components/src/Table.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1377,6 +1377,7 @@ export const Row = /*#__PURE__*/ createBranchComponent(
13771377
isFocusVisible: isFocusVisibleWithin,
13781378
focusProps: focusWithinProps
13791379
} = useFocusRing({within: true});
1380+
13801381
let {hoverProps, isHovered} = useHover({
13811382
isDisabled: !states.allowsSelection && !states.hasAction,
13821383
onHoverStart: props.onHoverStart,

packages/react-aria-components/test/Tree.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,36 @@ describe('Tree', () => {
751751
expect(onSelectionChange).toHaveBeenCalledTimes(0);
752752
});
753753

754+
it('multi select should expand the row if anywhere on the row is clicked and there is no onAction provided', async () => {
755+
let {getAllByRole} = render(<StaticTree treeProps={{defaultExpandedKeys: new Set([]), selectionMode: 'multiple', disabledBehavior: 'selection', disabledKeys: ['projects']}} />);
756+
let row = getAllByRole('row')[1];
757+
await user.hover(row);
758+
expect(row).toHaveAttribute('data-hovered', 'true');
759+
760+
await user.click(row);
761+
expect(row).toHaveAttribute('aria-expanded', 'true');
762+
});
763+
764+
it('single select should expand the row if anywhere on the row is clicked and there is no onAction provided', async () => {
765+
let {getAllByRole} = render(<StaticTree treeProps={{defaultExpandedKeys: new Set([]), selectionMode: 'single', disabledBehavior: 'selection', disabledKeys: ['projects']}} />);
766+
let row = getAllByRole('row')[1];
767+
await user.hover(row);
768+
expect(row).toHaveAttribute('data-hovered', 'true');
769+
770+
await user.click(row);
771+
expect(row).toHaveAttribute('aria-expanded', 'true');
772+
});
773+
774+
it('no selection should expand the row if anywhere on the row is clicked and there is no onAction provided', async () => {
775+
let {getAllByRole} = render(<StaticTree treeProps={{defaultExpandedKeys: new Set([]), selectionMode: 'none', disabledBehavior: 'selection', disabledKeys: ['projects']}} />);
776+
let row = getAllByRole('row')[1];
777+
await user.hover(row);
778+
expect(row).toHaveAttribute('data-hovered', 'true');
779+
780+
await user.click(row);
781+
expect(row).toHaveAttribute('aria-expanded', 'true');
782+
});
783+
754784
it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => {
755785
let {getAllByRole} = render(<StaticTree treeProps={{selectionMode: 'multiple', escapeKeyBehavior: 'none'}} />);
756786

packages/react-aria-components/test/Treeble.test.js renamed to packages/react-aria-components/test/Treeble.test.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,15 @@ function Example(props) {
9898
);
9999
}
100100

101+
interface ReorderableTreebleItem {
102+
id: string,
103+
title: string,
104+
type: string,
105+
date: string,
106+
children?: ReorderableTreebleItem[]
107+
}
101108
function ReorderableTreeble(props) {
102-
let tree = useTreeData({
109+
let tree = useTreeData<ReorderableTreebleItem>({
103110
initialItems: [
104111
{id: '1', title: 'Documents', type: 'Directory', date: '10/20/2025', children: [
105112
{id: '2', title: 'Project', type: 'Directory', date: '8/2/2025', children: [
@@ -114,7 +121,7 @@ function ReorderableTreeble(props) {
114121
]
115122
});
116123

117-
let {dragAndDropHooks} = useDragAndDrop({
124+
let {dragAndDropHooks} = useDragAndDrop<{value: ReorderableTreebleItem}>({
118125
getItems: (keys, items) => items.map(item => ({'text/plain': item.value.title})),
119126
onMove(e) {
120127
if (e.target.dropPosition === 'before') {
@@ -239,7 +246,7 @@ describe('Treeble', () => {
239246
expect(tester.rowHeaders[3]).toHaveTextContent('Job Posting');
240247
});
241248

242-
it.each(['mouse', 'touch', 'keyboard'])('should expand a row with %s', async (interactionType) => {
249+
it.each(['mouse', 'touch', 'keyboard'] as const)('should expand a row with %s', async (interactionType) => {
243250
let tree = render(<Example />);
244251
let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')});
245252

@@ -529,6 +536,39 @@ describe('Treeble', () => {
529536
expect(onSelectionChange).toHaveBeenLastCalledWith(new Set(['games', 'mario', 'tetris']));
530537
});
531538

539+
it('supports expansion on disabled items with no action in disabledBehavior="selection" multiple selection', async () => {
540+
let tree = render(<Example disabledKeys={['apps']} selectionMode="multiple" disabledBehavior="selection" />);
541+
let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')});
542+
543+
await user.hover(tester.rows[1]);
544+
expect(tester.rows[1]).toHaveAttribute('data-hovered', 'true');
545+
546+
await user.click(tester.rows[1]);
547+
expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'true');
548+
});
549+
550+
it('supports expansion on disabled items with no action in disabledBehavior="selection" single selection', async () => {
551+
let tree = render(<Example disabledKeys={['apps']} selectionMode="single" disabledBehavior="selection" />);
552+
let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')});
553+
554+
await user.hover(tester.rows[1]);
555+
expect(tester.rows[1]).toHaveAttribute('data-hovered', 'true');
556+
557+
await user.click(tester.rows[1]);
558+
expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'true');
559+
});
560+
561+
it('supports expansion on disabled items with no action in disabledBehavior="selection" no selection', async () => {
562+
let tree = render(<Example disabledKeys={['apps']} selectionMode="none" disabledBehavior="selection" />);
563+
let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')});
564+
565+
await user.hover(tester.rows[1]);
566+
expect(tester.rows[1]).toHaveAttribute('data-hovered', 'true');
567+
568+
await user.click(tester.rows[1]);
569+
expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'true');
570+
});
571+
532572
it('should support drag and drop', async () => {
533573
let tree = render(<ReorderableTreeble />);
534574
let tester = utils.createTester('Table', {root: tree.getByRole('treegrid')});

packages/react-aria/src/grid/useGridRow.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import {chain} from '../utils/chain';
1414

15-
import {DOMAttributes, FocusableElement, RefObject} from '@react-types/shared';
15+
import {DOMAttributes, FocusableElement, Key, RefObject} from '@react-types/shared';
1616
import {IGridCollection as GridCollection, GridNode} from 'react-stately/private/grid/GridCollection';
1717
import {gridMap} from './utils';
1818
import {GridState} from 'react-stately/private/grid/useGridState';
@@ -55,6 +55,34 @@ export function useGridRow<T, C extends GridCollection<T>, S extends GridState<T
5555

5656
let {actions, shouldSelectOnPressUp: gridShouldSelectOnPressUp} = gridMap.get(state)!;
5757
let onRowAction = actions.onRowAction ? () => actions.onRowAction?.(node.key) : onAction;
58+
59+
// Mirror useGridListItem: when no row action is provided, expandable tree-table rows use toggle as the
60+
// primary action if selection is off or the row is selection-disabled (disabledKeys / selection behavior).
61+
if (
62+
node != null &&
63+
'treeColumn' in state &&
64+
state.treeColumn != null &&
65+
// I'd prefer if this was up in useTableRow, but onAction is a deprecated prop
66+
// and maybe we'll move the expandable rows down into useGridRow eventually
67+
'toggleKey' in state &&
68+
typeof state.toggleKey === 'function' &&
69+
actions.onRowAction == null &&
70+
onAction == null
71+
) {
72+
// adds the toggleKey type so it's not unknown below
73+
let tableState = state as typeof state & {toggleKey: (key: Key) => void};
74+
let children = tableState.collection.getChildren?.(node.key);
75+
let hasChildRows = [...(children ?? [])].length > 1;
76+
let hasLink = state.selectionManager.isLink(node.key);
77+
if (
78+
!hasLink &&
79+
hasChildRows &&
80+
((state.disabledKeys.has(node.key) || node.props?.isDisabled) ||
81+
state.selectionManager.selectionMode === 'none')) {
82+
onRowAction = () => tableState.toggleKey(node.key);
83+
}
84+
}
85+
5886
let {itemProps, ...states} = useSelectableItem({
5987
selectionManager: state.selectionManager,
6088
key: node.key,

packages/react-aria/src/gridlist/useGridListItem.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,12 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
102102
let children = state.collection.getChildren?.(node.key);
103103
hasChildRows = hasChildRows || [...(children ?? [])].length > 1;
104104

105-
if (onAction == null && !hasLink && state.selectionManager.selectionMode === 'none' && hasChildRows) {
105+
if (
106+
onAction == null &&
107+
!hasLink &&
108+
hasChildRows &&
109+
((state.disabledKeys.has(node.key) || node.props?.isDisabled) ||
110+
state.selectionManager.selectionMode === 'none')) {
106111
onAction = () => state.toggleKey(node.key);
107112
}
108113

0 commit comments

Comments
 (0)