diff --git a/packages/@react-spectrum/s2/stories/TableView.stories.tsx b/packages/@react-spectrum/s2/stories/TableView.stories.tsx index 45ce91272e3..f37a8976606 100644 --- a/packages/@react-spectrum/s2/stories/TableView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TableView.stories.tsx @@ -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 = { @@ -63,7 +62,7 @@ const meta: Meta = { 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 @@ -1784,7 +1783,7 @@ export const TableWithNestedRows: StoryObj = { 5/22/1980 - + Applications Folder 4/7/2025 diff --git a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx index b9bebd80f52..d28392e2568 100644 --- a/packages/@react-spectrum/s2/stories/TreeView.stories.tsx +++ b/packages/@react-spectrum/s2/stories/TreeView.stories.tsx @@ -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 = { component: TreeView, @@ -57,7 +57,7 @@ const meta: Meta = { 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 @@ -81,7 +81,7 @@ const TreeExampleStatic = (args: TreeViewProps): ReactElement => (
diff --git a/packages/react-aria-components/src/Table.tsx b/packages/react-aria-components/src/Table.tsx index c6263318ee9..852eb639ed3 100644 --- a/packages/react-aria-components/src/Table.tsx +++ b/packages/react-aria-components/src/Table.tsx @@ -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, diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index 30eb7c111ca..0baa37e0aef 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -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(); + 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(); + 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(); + 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(); diff --git a/packages/react-aria-components/test/Treeble.test.js b/packages/react-aria-components/test/Treeble.test.tsx similarity index 93% rename from packages/react-aria-components/test/Treeble.test.js rename to packages/react-aria-components/test/Treeble.test.tsx index 404e2f6847c..bd87825ea02 100644 --- a/packages/react-aria-components/test/Treeble.test.js +++ b/packages/react-aria-components/test/Treeble.test.tsx @@ -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({ initialItems: [ {id: '1', title: 'Documents', type: 'Directory', date: '10/20/2025', children: [ {id: '2', title: 'Project', type: 'Directory', date: '8/2/2025', children: [ @@ -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') { @@ -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(); let tester = utils.createTester('Table', {root: tree.getByTestId('treeble')}); @@ -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(); + 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(); + 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(); + 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(); let tester = utils.createTester('Table', {root: tree.getByRole('treegrid')}); diff --git a/packages/react-aria/src/grid/useGridRow.ts b/packages/react-aria/src/grid/useGridRow.ts index 1a66e8d0d7e..2489d76f5c7 100644 --- a/packages/react-aria/src/grid/useGridRow.ts +++ b/packages/react-aria/src/grid/useGridRow.ts @@ -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'; @@ -55,6 +55,34 @@ export function useGridRow, S extends GridState 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, diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index c2ab564852c..844dbaafbd2 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -102,7 +102,12 @@ export function useGridListItem(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) { + if ( + onAction == null && + !hasLink && + hasChildRows && + ((state.disabledKeys.has(node.key) || node.props?.isDisabled) || + state.selectionManager.selectionMode === 'none')) { onAction = () => state.toggleKey(node.key); }