Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
7 changes: 3 additions & 4 deletions packages/@react-spectrum/s2/stories/TableView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,8 @@ import User from '../s2wf-icons/S2_Icon_User_20_N.svg';
import {useTreeData} from 'react-stately/useTreeData';

let onActionFunc = action('onAction');
let noOnAction = null;
let noOnAction = undefined;
const onActionOptions = {onActionFunc, noOnAction};

const events = ['onResizeStart', 'onResize', 'onResizeEnd', 'onSelectionChange', 'onSortChange'];

const meta: Meta<typeof TableView> = {
Expand All @@ -63,7 +62,7 @@ const meta: Meta<typeof TableView> = {
tags: ['autodocs'],
args: {...getActionArgs(events)},
argTypes: {
...categorizeArgTypes('Events', ['onAction', 'onLoadMore', 'onResizeStart', 'onResize', 'onResizeEnd', 'onSelectionChange', 'onSortChange']),
...categorizeArgTypes('Events', ['onAction', 'onLoadMore', ...events]),
children: {table: {disable: true}},
onAction: {
options: Object.keys(onActionOptions), // An array of serializable values
Expand Down Expand Up @@ -1784,7 +1783,7 @@ export const TableWithNestedRows: StoryObj<typeof TableView> = {
<Cell>5/22/1980</Cell>
</Row>
</Row>
<Row id="apps">
<Row id="apps" isDisabled>
<Cell>Applications</Cell>
<Cell>Folder</Cell>
<Cell>4/7/2025</Cell>
Expand Down
8 changes: 4 additions & 4 deletions packages/@react-spectrum/s2/stories/TreeView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ import {useAsyncList} from 'react-stately/useAsyncList';
import {useListData} from 'react-stately/useListData';

let onActionFunc = action('onAction');
let noOnAction = null;
let noOnAction = undefined;
const onActionOptions = {onActionFunc, noOnAction};
const events = ['onSelectionChange', 'onAction'];
const events = ['onSelectionChange'];

const meta: Meta<typeof TreeView> = {
component: TreeView,
Expand All @@ -57,7 +57,7 @@ const meta: Meta<typeof TreeView> = {
tags: ['autodocs'],
args: {...getActionArgs(events)},
argTypes: {
...categorizeArgTypes('Events', events),
...categorizeArgTypes('Events', ['onAction', ...events]),
children: {table: {disable: true}},
onAction: {
options: Object.keys(onActionOptions), // An array of serializable values
Expand All @@ -81,7 +81,7 @@ const TreeExampleStatic = (args: TreeViewProps<any>): ReactElement => (
<div style={{width: '300px', resize: 'both', height: '320px', overflow: 'auto'}}>
<TreeView
{...args}
disabledKeys={['projects-1']}
disabledKeys={['projects']}
aria-label="test static tree"
onExpandedChange={action('onExpandedChange')}
onSelectionChange={action('onSelectionChange')}>
Expand Down
1 change: 1 addition & 0 deletions packages/react-aria-components/src/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1375,6 +1375,7 @@ export const Row = /*#__PURE__*/ createBranchComponent(
isFocusVisible: isFocusVisibleWithin,
focusProps: focusWithinProps
} = useFocusRing({within: true});

let {hoverProps, isHovered} = useHover({
isDisabled: !states.allowsSelection && !states.hasAction,
onHoverStart: props.onHoverStart,
Expand Down
30 changes: 30 additions & 0 deletions packages/react-aria-components/test/Tree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,36 @@ describe('Tree', () => {
expect(onSelectionChange).toHaveBeenCalledTimes(0);
});

it('multi select should expand the row if anywhere on the row is clicked and there is no onAction provided', async () => {
let {getAllByRole} = render(<StaticTree treeProps={{defaultExpandedKeys: new Set([]), selectionMode: 'multiple', disabledBehavior: 'selection', disabledKeys: ['projects']}} />);
let row = getAllByRole('row')[1];
await user.hover(row);
expect(row).toHaveAttribute('data-hovered', 'true');

await user.click(row);
expect(row).toHaveAttribute('aria-expanded', 'true');
});

it('single select should expand the row if anywhere on the row is clicked and there is no onAction provided', async () => {
let {getAllByRole} = render(<StaticTree treeProps={{defaultExpandedKeys: new Set([]), selectionMode: 'single', disabledBehavior: 'selection', disabledKeys: ['projects']}} />);
let row = getAllByRole('row')[1];
await user.hover(row);
expect(row).toHaveAttribute('data-hovered', 'true');

await user.click(row);
expect(row).toHaveAttribute('aria-expanded', 'true');
});

it('no selection should expand the row if anywhere on the row is clicked and there is no onAction provided', async () => {
let {getAllByRole} = render(<StaticTree treeProps={{defaultExpandedKeys: new Set([]), selectionMode: 'none', disabledBehavior: 'selection', disabledKeys: ['projects']}} />);
let row = getAllByRole('row')[1];
await user.hover(row);
expect(row).toHaveAttribute('data-hovered', 'true');

await user.click(row);
expect(row).toHaveAttribute('aria-expanded', 'true');
});

it('should prevent Esc from clearing selection if escapeKeyBehavior is "none"', async () => {
let {getAllByRole} = render(<StaticTree treeProps={{selectionMode: 'multiple', escapeKeyBehavior: 'none'}} />);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,15 @@ function Example(props) {
);
}

interface ReorderableTreebleItem {
id: string,
title: string,
type: string,
date: string,
children?: ReorderableTreebleItem[]
}
function ReorderableTreeble(props) {
let tree = useTreeData({
let tree = useTreeData<ReorderableTreebleItem>({
initialItems: [
{id: '1', title: 'Documents', type: 'Directory', date: '10/20/2025', children: [
{id: '2', title: 'Project', type: 'Directory', date: '8/2/2025', children: [
Expand All @@ -114,7 +121,7 @@ function ReorderableTreeble(props) {
]
});

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

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

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

it('supports expansion on disabled items with no action in disabledBehavior="selection" multiple selection', async () => {
let tree = render(<Example disabledKeys={['apps']} selectionMode="multiple" disabledBehavior="selection" />);
let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')});

await user.hover(tester.rows[1]);
expect(tester.rows[1]).toHaveAttribute('data-hovered', 'true');

await user.click(tester.rows[1]);
expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'true');
});

it('supports expansion on disabled items with no action in disabledBehavior="selection" single selection', async () => {
let tree = render(<Example disabledKeys={['apps']} selectionMode="single" disabledBehavior="selection" />);
let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')});

await user.hover(tester.rows[1]);
expect(tester.rows[1]).toHaveAttribute('data-hovered', 'true');

await user.click(tester.rows[1]);
expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'true');
});

it('supports expansion on disabled items with no action in disabledBehavior="selection" no selection', async () => {
let tree = render(<Example disabledKeys={['apps']} selectionMode="none" disabledBehavior="selection" />);
let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')});

await user.hover(tester.rows[1]);
expect(tester.rows[1]).toHaveAttribute('data-hovered', 'true');

await user.click(tester.rows[1]);
expect(tester.rows[1]).toHaveAttribute('aria-expanded', 'true');
});

it('should support drag and drop', async () => {
let tree = render(<ReorderableTreeble />);
let tester = utils.createTester('Table', {root: tree.getByRole('treegrid')});
Expand Down
30 changes: 29 additions & 1 deletion packages/react-aria/src/grid/useGridRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

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

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

let {actions, shouldSelectOnPressUp: gridShouldSelectOnPressUp} = gridMap.get(state)!;
let onRowAction = actions.onRowAction ? () => actions.onRowAction?.(node.key) : onAction;

// Mirror useGridListItem: when no row action is provided, expandable tree-table rows use toggle as the
// primary action if selection is off or the row is selection-disabled (disabledKeys / selection behavior).
if (
node != null &&
'treeColumn' in state &&
state.treeColumn != null &&
// I'd prefer if this was up in useTableRow, but onAction is a deprecated prop
// and maybe we'll move the expandable rows down into useGridRow eventually
'toggleKey' in state &&
typeof state.toggleKey === 'function' &&
actions.onRowAction == null &&
onAction == null
) {
// adds the toggleKey type so it's not unknown below
let tableState = state as typeof state & {toggleKey: (key: Key) => void};
let children = tableState.collection.getChildren?.(node.key);
let hasChildRows = [...(children ?? [])].length > 1;
let hasLink = state.selectionManager.isLink(node.key);
if (
!hasLink &&
hasChildRows &&
((state.disabledKeys.has(node.key) || node.props?.isDisabled) ||
state.selectionManager.selectionMode === 'none')) {
onRowAction = () => tableState.toggleKey(node.key);
}
}

let {itemProps, ...states} = useSelectableItem({
selectionManager: state.selectionManager,
key: node.key,
Expand Down
8 changes: 7 additions & 1 deletion packages/react-aria/src/gridlist/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
let children = state.collection.getChildren?.(node.key);
hasChildRows = hasChildRows || [...(children ?? [])].length > 1;

if (onAction == null && !hasLink && state.selectionManager.selectionMode === 'none' && hasChildRows) {
// console.log('checks', onAction == null, !hasLink, hasChildRows, state.disabledKeys, state.selectionManager.selectionMode === 'none');
if (
onAction == null &&
!hasLink &&
hasChildRows &&
((state.disabledKeys.has(node.key) || node.props?.isDisabled) ||
state.selectionManager.selectionMode === 'none')) {
onAction = () => state.toggleKey(node.key);
}

Expand Down
Loading