diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx
index 5b257b067f5..56e469dfd8d 100644
--- a/packages/@react-spectrum/s2/src/TableView.tsx
+++ b/packages/@react-spectrum/s2/src/TableView.tsx
@@ -690,7 +690,7 @@ function CellFocusRing() {
className={style({
...cellFocus,
position: 'absolute',
- top: 'var(--topFocusRing)',
+ top: 'var(--topFocusRing, 0)',
bottom: 0,
insetStart: 0,
insetEnd: 0,
@@ -1216,12 +1216,8 @@ export const TableHeader = /*#__PURE__*/ (forwardRef as forwardRefType)(function
className={selectAllCheckboxColumn({isQuiet})}>
{({isFocusVisible}) => (
<>
- {selectionMode === 'single' && (
- <>
- {isFocusVisible && }
-
- >
- )}
+ {isFocusVisible && }
+ {selectionMode === 'single' && }
{selectionMode === 'multiple' && (
)}
diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx
index 4573d2d5331..73abdd4bb11 100644
--- a/packages/@react-spectrum/s2/src/TreeView.tsx
+++ b/packages/@react-spectrum/s2/src/TreeView.tsx
@@ -104,6 +104,7 @@ export interface TreeViewProps
| 'selectionBehavior'
| 'onScroll'
| 'onCellAction'
+ | 'keyboardNavigationBehavior'
| keyof GlobalDOMAttributes
>,
UnsafeStyles,
diff --git a/packages/@react-spectrum/s2/stories/CardView.stories.tsx b/packages/@react-spectrum/s2/stories/CardView.stories.tsx
index e5efd0212a4..2f00aba5d14 100644
--- a/packages/@react-spectrum/s2/stories/CardView.stories.tsx
+++ b/packages/@react-spectrum/s2/stories/CardView.stories.tsx
@@ -27,6 +27,7 @@ import {MenuItem} from '../src/Menu';
import type {Meta, StoryObj} from '@storybook/react';
import {SkeletonCollection} from '../src/SkeletonCollection';
import {style} from '../style/spectrum-theme' with {type: 'macro'};
+import {TextField} from '../src/TextField';
import {useAsyncList} from 'react-stately/useAsyncList';
const meta: Meta = {
@@ -72,7 +73,15 @@ const avatarSize = {
XL: 32
} as const;
-export function PhotoCard({item, layout}: {item: Item; layout: string}) {
+export function PhotoCard({
+ item,
+ layout,
+ interactive
+}: {
+ item: Item;
+ layout: string;
+ interactive?: React.ReactNode;
+}) {
return (
{({size}) => (
@@ -112,12 +121,20 @@ export function PhotoCard({item, layout}: {item: Item; layout: string}) {
-
-
{item.user.name}
+
+ {interactive}
>
@@ -126,7 +143,7 @@ export function PhotoCard({item, layout}: {item: Item; layout: string}) {
);
}
-export const ExampleRender = (args: CardViewProps) => {
+export const ExampleRender = (args: CardViewProps & {interactive?: React.ReactNode}) => {
let list = useAsyncList- ({
async load({signal, cursor, items}) {
let page = cursor || 1;
@@ -155,7 +172,9 @@ export const ExampleRender = (args: CardViewProps
) => {
onLoadMore={args.loadingState === 'idle' ? list.loadMore : undefined}
styles={cardViewStyles}>
- {item => }
+ {item => (
+
+ )}
{(loadingState === 'loading' || loadingState === 'loadingMore') && (
@@ -288,3 +307,14 @@ export const CollectionCards: Story = {
onAction: undefined
}
};
+
+export const CardViewWithTextField: Story = {
+ render: args => (
+ } />
+ ),
+ args: {
+ loadingState: 'idle',
+ onAction: undefined,
+ selectionMode: 'multiple'
+ }
+};
diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx
index c9511c40470..74464237e46 100644
--- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx
@@ -672,7 +672,7 @@ function Example(props) {
Use the `layout` and `orientation` props to arrange items in horizontal and vertical stacks and grids. This affects keyboard navigation and drag and drop behavior.
-```tsx render docs={docs.exports.GridList} links={docs.links} props={['layout', 'orientation', 'keyboardNavigationBehavior']} initialProps={{layout: 'grid', orientation: 'horizontal', keyboardNavigationBehavior: 'tab'}} wide
+```tsx render docs={docs.exports.GridList} links={docs.links} props={['layout', 'orientation']} initialProps={{layout: 'grid', orientation: 'horizontal'}} wide
"use client";
import {GridList, GridListItem, Text} from 'vanilla-starter/GridList';
@@ -704,6 +704,56 @@ let photos = [
```
+## Keyboard navigation
+
+By default, GridList uses arrow key navigation to move focus into rows. Set `keyboardNavigationBehavior="tab"` to have Tab move focus in and out of a row.
+Use this when rows contain interactive elements such as text fields, where arrow keys and typing in the field should not trigger grid navigation or selection.
+
+```tsx render
+"use client";
+import {GridList, GridListItem, Text} from 'vanilla-starter/GridList';
+import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox';
+
+///- begin collapse -///
+///- begin collapse -///
+let photos = [
+ {id: 1, title: 'Desert Sunset', description: 'PNG • 2/3/2024', src: 'https://images.unsplash.com/photo-1705034598432-1694e203cdf3?q=80&w=600&auto=format&fit=crop'},
+ {id: 2, title: 'Hiking Trail', description: 'JPEG • 1/10/2022', src: 'https://images.unsplash.com/photo-1722233987129-61dc344db8b6?q=80&w=600&auto=format&fit=crop'},
+ {id: 3, title: 'Lion', description: 'JPEG • 8/28/2021', src: 'https://images.unsplash.com/photo-1629812456605-4a044aa38fbc?q=80&w=600&auto=format&fit=crop'},
+ {id: 4, title: 'Mountain Sunrise', description: 'PNG • 3/15/2015', src: 'https://images.unsplash.com/photo-1722172118908-1a97c312ce8c?q=80&w=600&auto=format&fit=crop'},
+ {id: 5, title: 'Giraffe tongue', description: 'PNG • 11/27/2019', src: 'https://images.unsplash.com/photo-1574870111867-089730e5a72b?q=80&w=600&auto=format&fit=crop'},
+ {id: 6, title: 'Golden Hour', description: 'WEBP • 7/24/2024', src: 'https://images.unsplash.com/photo-1718378037953-ab21bf2cf771?q=80&w=600&auto=format&fit=crop'},
+];
+
+function PermissionPicker({label}) {
+ return (
+
+ Can view
+
+ Can edit
+
+ );
+}
+///- end collapse -///
+
+
+ {item => (
+
+
+ {item.title}
+ {item.description}
+
+
+ )}
+
+```
+
## Drag and drop
GridList supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the list as a whole, on individual items, insert new items between existing ones, or reorder items. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=GridList) to learn more.
diff --git a/packages/dev/s2-docs/pages/react-aria/Table.mdx b/packages/dev/s2-docs/pages/react-aria/Table.mdx
index 465f29684b3..ccf76b4cabf 100644
--- a/packages/dev/s2-docs/pages/react-aria/Table.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/Table.mdx
@@ -217,7 +217,7 @@ function FileTable() {
{column => (
|
- {column.id === 'price'
+ {column.id === 'price'
? item.price.toLocaleString('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0})
: item[column.id]}
|
@@ -725,6 +725,67 @@ function subscribe(fn) {
}
```
+## Keyboard navigation
+
+By default, Table uses arrow key navigation to move focus into cells. Set `keyboardNavigationBehavior="tab"` to have Tab move focus in and out of a cell.
+Use this when cells contain interactive elements such as text fields, where arrow keys and typing in the field should not trigger grid navigation or selection.
+
+```tsx render
+"use client";
+import {Table, TableHeader, Column, Row, TableBody, Cell} from 'vanilla-starter/Table';
+import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox';
+
+function PermissionPicker({label}) {
+ return (
+
+ Can view
+
+ Can edit
+
+ );
+}
+
+
+
+ Name
+ Type
+ Date Modified
+ Permission
+
+
+
+ | Games |
+ Folder |
+ 6/7/2023 |
+ |
+
+
+ | Applications |
+ Folder |
+ 4/7/2025 |
+ |
+
+
+ | 2024 Financial Report |
+ PDF Document |
+ 12/30/2024 |
+ |
+
+
+ | Job Posting |
+ Text Document |
+ 1/18/2025 |
+ |
+
+
+
+```
+
## Drag and drop
Table supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the table as a whole, on individual rows, insert new rows between existing ones, or reorder rows. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=Table) to learn more.
diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx
index 9094137b4c2..2f626756fba 100644
--- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx
@@ -257,7 +257,7 @@ import {Tree, TreeHeader, TreeItem, TreeSection} from 'vanilla-starter/Tree';
-
+
Documents
@@ -322,6 +322,61 @@ function Example(props) {
}
```
+## Keyboard navigation
+
+By default, Tree uses arrow key navigation to move focus into rows. Set `keyboardNavigationBehavior="tab"` to have Option move focus in and out of a row.
+Use this when rows contain interactive elements such as text fields, where arrow keys and typing in the field should not trigger grid navigation or selection.
+
+```tsx render
+"use client";
+import {Tree, TreeItem, TreeItemContent} from 'vanilla-starter/Tree';
+import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox';
+
+///- begin collapse -///
+function PermissionPicker({label}) {
+ return (
+
+ Can view
+
+ Can edit
+
+ );
+}
+///- end collapse -///
+
+
+
+
+
+ Weekly Report.pdf
+
+
+
+
+
+ Budget.xlsx
+
+
+
+
+
+
+
+ Sunset.jpg
+
+
+
+
+
+```
+
## Drag and drop
Tree supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the list as a whole, on individual items, insert new items between existing ones, or reorder items. React Aria supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=Tree) to learn more.
diff --git a/packages/dev/s2-docs/pages/s2/TableView.mdx b/packages/dev/s2-docs/pages/s2/TableView.mdx
index b41064e81b1..ff9ae04d9fb 100644
--- a/packages/dev/s2-docs/pages/s2/TableView.mdx
+++ b/packages/dev/s2-docs/pages/s2/TableView.mdx
@@ -947,6 +947,68 @@ function subscribe(fn) {
}
```
+## Keyboard navigation
+
+By default, TableView uses arrow key navigation to move focus into cells. Set `keyboardNavigationBehavior="tab"` to have Tab move focus in and out of a cell.
+
+```tsx render type="s2"
+"use client";
+import {TableView, TableHeader, Column, TableBody, Row, Cell} from '@react-spectrum/s2/TableView';
+import {ComboBox, ComboBoxItem} from '@react-spectrum/s2/ComboBox';
+import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
+
+function PermissionPicker({label}) {
+ return (
+
+ Can view
+
+ Can edit
+
+ );
+}
+
+
+
+ Name
+ Type
+ Date Modified
+ Permission
+
+
+
+ | Games |
+ Folder |
+ 6/7/2023 |
+ |
+
+
+ | Applications |
+ Folder |
+ 4/7/2025 |
+ |
+
+
+ | 2024 Financial Report |
+ PDF Document |
+ 12/30/2024 |
+ |
+
+
+ | Job Posting |
+ Text Document |
+ 1/18/2025 |
+ |
+
+
+
+```
+
## Drag and drop
Table supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the table as a whole, on individual rows, insert new rows between existing ones, or reorder rows. See the [drag and drop guide](dnd?component=TableView) to learn more.
diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx
index 7c42b76baf4..55adce21e64 100644
--- a/packages/react-aria-components/stories/GridList.stories.tsx
+++ b/packages/react-aria-components/stories/GridList.stories.tsx
@@ -12,9 +12,10 @@
import {action} from 'storybook/actions';
import {Button} from '../src/Button';
-import {Checkbox, CheckboxProps} from '../src/Checkbox';
+import {Checkbox, CheckboxGroup, CheckboxProps} from '../src/Checkbox';
import {classNames} from '@adobe/react-spectrum/private/utils/classNames';
import {Collection} from 'react-aria/Collection';
+import {ComboBox} from '../src/ComboBox';
import {Dialog, DialogTrigger} from '../src/Dialog';
import {DropIndicator, useDragAndDrop} from '../src/useDragAndDrop';
import {GridLayout} from '../src/GridLayout';
@@ -28,10 +29,13 @@ import {
GridListSection
} from '../src/GridList';
import {Heading} from '../src/Heading';
+import {Input} from '../src/Input';
import {Key} from '@react-types/shared';
+import {ListBox} from '../src/ListBox';
import {ListLayout, Size, WaterfallLayout} from 'react-stately/useVirtualizerState';
-import {LoadingSpinner} from './utils';
+import {LoadingSpinner, MyListBoxItem} from './utils';
import {LoadingState} from '@react-types/shared';
+import {Menu, MenuItem, MenuTrigger} from '../src/Menu';
import {Meta, StoryFn, StoryObj} from '@storybook/react';
import {Modal, ModalOverlay, ModalOverlayProps} from '../src/Modal';
import {Popover} from '../src/Popover';
@@ -39,10 +43,13 @@ import React, {JSX, useState} from 'react';
import styles from '../example/index.css';
import {Tag, TagGroup, TagList} from '../src/TagGroup';
import {Text} from '../src/Text';
+import {TextField} from '../src/TextField';
+import {Toolbar} from '../src/Toolbar';
import {useAsyncList} from 'react-stately/useAsyncList';
import {useListData} from 'react-stately/useListData';
import {Virtualizer} from '../src/Virtualizer';
import './styles.css';
+import {Radio, RadioGroup} from '../src/RadioGroup';
export default {
title: 'React Aria Components/GridList',
@@ -312,13 +319,21 @@ GridListSectionExample.story = {
};
export function VirtualizedGridListSection() {
- let sections: {id: string; name: string; children: {id: string; name: string}[]}[] = [];
+ let sections: {
+ id: string;
+ name: string;
+ children: {id: string; name: string}[];
+ }[] = [];
for (let s = 0; s < 10; s++) {
let items: {id: string; name: string}[] = [];
for (let i = 0; i < 3; i++) {
items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`});
}
- sections.push({id: `section_${s}`, name: `Section ${s}`, children: items});
+ sections.push({
+ id: `section_${s}`,
+ name: `Section ${s}`,
+ children: items
+ });
}
return (
@@ -360,7 +375,9 @@ const VirtualizedGridListRender = (args: GridListProps & {isLoading: boolea
let {dragAndDropHooks} = useDragAndDrop({
getItems: keys => {
- return [...keys].map(key => ({'text/plain': list.getItem(key)?.name ?? ''}));
+ return [...keys].map(key => ({
+ 'text/plain': list.getItem(key)?.name ?? ''
+ }));
},
onReorder(e) {
if (e.target.dropPosition === 'before') {
@@ -953,3 +970,174 @@ export const AsyncGridListGridVirtualized: StoryObj {
+ return No results
;
+};
+
+export const GridListWithTextfield: GridListStory = args => {
+ let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid';
+ return (
+
+ );
+};
+
+GridListWithTextfield.story = {
+ args: {
+ layout: 'stack',
+ orientation: 'vertical',
+ escapeKeyBehavior: 'clearSelection'
+ },
+ argTypes: {
+ layout: {
+ control: 'radio',
+ options: ['stack', 'grid']
+ },
+ orientation: {
+ control: 'radio',
+ options: ['vertical', 'horizontal']
+ },
+ keyboardNavigationBehavior: {
+ control: 'radio',
+ options: ['arrow', 'tab']
+ },
+ selectionMode: {
+ control: 'radio',
+ options: ['none', 'single', 'multiple']
+ },
+ selectionBehavior: {
+ control: 'radio',
+ options: ['toggle', 'replace']
+ },
+ escapeKeyBehavior: {
+ control: 'radio',
+ options: ['clearSelection', 'none']
+ }
+ }
+};
diff --git a/packages/react-aria-components/stories/Table.stories.tsx b/packages/react-aria-components/stories/Table.stories.tsx
index e6f5bebd729..c6b2c19c480 100644
--- a/packages/react-aria-components/stories/Table.stories.tsx
+++ b/packages/react-aria-components/stories/Table.stories.tsx
@@ -26,20 +26,26 @@ import {
TableHeader,
TableLoadMoreItem
} from '../src/Table';
-import {Checkbox, CheckboxProps} from '../src/Checkbox';
+import {Checkbox, CheckboxGroup, CheckboxProps} from '../src/Checkbox';
import {Collection} from 'react-aria/Collection';
+import {ComboBox} from '../src/ComboBox';
import {Dialog, DialogTrigger} from '../src/Dialog';
import {DropIndicator, isTextDropItem, useDragAndDrop} from '../exports/useDragAndDrop';
import {Heading} from '../src/Heading';
-import {LoadingSpinner, MyMenuItem} from './utils';
-import {Menu, MenuTrigger} from '../src/Menu';
+import {Input} from '../src/Input';
+import {ListBox} from '../src/ListBox';
+import {LoadingSpinner, MyListBoxItem, MyMenuItem} from './utils';
+import {Menu, MenuItem, MenuTrigger} from '../src/Menu';
import {Meta, StoryFn, StoryObj} from '@storybook/react';
import {Modal, ModalOverlay} from '../src/Modal';
import {Popover} from '../src/Popover';
+import {Radio, RadioGroup} from '../src/RadioGroup';
import React, {JSX, startTransition, Suspense, useState} from 'react';
import {Selection} from '@react-types/shared';
import styles from '../example/index.css';
import {TableLayout} from '../src/TableLayout';
+import {TextField} from '../src/TextField';
+import {Toolbar} from '../src/Toolbar';
import {useAsyncList} from 'react-stately/useAsyncList';
import {useListData} from 'react-stately/useListData';
import {Virtualizer} from '../src/Virtualizer';
@@ -2181,3 +2187,180 @@ export const TableSectionDnd: TableStory = args => {
);
};
+
+let comboboxEmptyState = () => {
+ return No results
;
+};
+
+export const TableWithTextfield: TableStory = args => {
+ return (
+
+ );
+};
+
+TableWithTextfield.argTypes = {
+ keyboardNavigationBehavior: {
+ control: 'radio',
+ options: ['arrow', 'tab']
+ },
+ selectionMode: {
+ control: 'radio',
+ options: ['none', 'single', 'multiple']
+ },
+ selectionBehavior: {
+ control: 'radio',
+ options: ['toggle', 'replace']
+ }
+};
diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx
index 4e305216973..b5920c692f6 100644
--- a/packages/react-aria-components/stories/Tree.stories.tsx
+++ b/packages/react-aria-components/stories/Tree.stories.tsx
@@ -15,18 +15,21 @@ import {Button} from '../src/Button';
import {Checkbox, CheckboxProps} from '../src/Checkbox';
import {classNames} from '@adobe/react-spectrum/private/utils/classNames';
import {Collection} from 'react-aria/Collection';
+import {ComboBox} from '../src/ComboBox';
import {DroppableCollectionReorderEvent, Key} from '@react-types/shared';
+import {Input} from '../src/Input';
import {isTextDropItem, useDragAndDrop} from '../exports/useDragAndDrop';
+import {ListBox} from '../src/ListBox';
import {ListLayout} from 'react-stately/useVirtualizerState';
-import {Menu, MenuTrigger} from '../src/Menu';
-
+import {Menu, MenuItem, MenuTrigger} from '../src/Menu';
import {Meta, StoryFn, StoryObj} from '@storybook/react';
-
-import {MyMenuItem} from './utils';
+import {MyListBoxItem, MyMenuItem} from './utils';
import {Popover} from '../src/Popover';
import React, {JSX, ReactNode, useCallback, useState} from 'react';
import styles from '../example/index.css';
import {Text} from '../src/Text';
+import {TextField} from '../src/TextField';
+import {Toolbar} from '../src/Toolbar';
import {
Tree,
TreeHeader,
@@ -46,7 +49,7 @@ import './styles.css';
export default {
title: 'React Aria Components/Tree',
component: Tree,
- excludeStories: ['TreeExampleStaticRender']
+ excludeStories: ['TreeExampleStaticRender', 'TreeWithTextField']
} as Meta;
export type TreeStory = StoryFn;
@@ -54,6 +57,7 @@ export type TreeStory = StoryFn;
interface StaticTreeItemProps extends TreeItemProps {
title?: string;
children: ReactNode;
+ interactive?: ReactNode;
}
interface MyCheckboxProps extends CheckboxProps {
@@ -117,6 +121,7 @@ const StaticTreeItem = (props: StaticTreeItemProps) => {
)}
{props.title || props.children}
+ {props.interactive}
ⓘ
@@ -1801,3 +1806,155 @@ export const HugeVirtualizedTree: StoryObj = {
},
render: args =>
};
+
+let comboboxEmptyState = () => {
+ return No results
;
+};
+
+export function TreeWithTextField(props: TreeProps) {
+ return (
+
+ );
+}
+
+export const TreeWithTextFieldStory: StoryObj = {
+ render: args => ,
+ args: {
+ selectionMode: 'none',
+ selectionBehavior: 'toggle',
+ disabledBehavior: 'selection'
+ },
+ argTypes: {
+ keyboardNavigationBehavior: {
+ control: 'radio',
+ options: ['arrow', 'tab']
+ },
+ selectionMode: {
+ control: 'radio',
+ options: ['none', 'single', 'multiple']
+ },
+ selectionBehavior: {
+ control: 'radio',
+ options: ['toggle', 'replace']
+ },
+ disabledBehavior: {
+ control: 'radio',
+ options: ['selection', 'all']
+ }
+ },
+ name: 'Tree with Textfield'
+};
diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js
index 5875fe1bd51..7d835f8c94c 100644
--- a/packages/react-aria-components/test/GridList.test.js
+++ b/packages/react-aria-components/test/GridList.test.js
@@ -1833,4 +1833,174 @@ describe('GridList', () => {
}
);
});
+
+ describe('tab navigation and textfields', () => {
+ it.each([
+ ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}],
+ ['layout="grid"', {layout: 'grid'}]
+ ])(
+ 'should not navigate rows when arrow keys are pressed while a text input child has focus (%s)',
+ async (_, listProps) => {
+ let {getByRole} = render(
+
+
+ Apple
+
+
+ Banana
+
+
+ );
+
+ let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')});
+ let rows = gridListTester.getRows();
+ let input = getByRole('textbox');
+
+ await user.tab();
+ expect(document.activeElement).toBe(rows[0]);
+ await user.tab();
+ expect(document.activeElement).toBe(input);
+
+ await user.keyboard('{ArrowDown}');
+ expect(document.activeElement).toBe(input);
+ await user.keyboard('{ArrowUp}');
+ expect(document.activeElement).toBe(input);
+ await user.keyboard('{ArrowRight}');
+ expect(document.activeElement).toBe(input);
+ await user.keyboard('{ArrowLeft}');
+ expect(document.activeElement).toBe(input);
+ }
+ );
+
+ it.each([
+ ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}],
+ ['layout="grid"', {layout: 'grid'}]
+ ])(
+ 'should not trigger typeahead when typing in a text input child (%s)',
+ async (_, listProps) => {
+ let {getByRole} = render(
+
+
+ Apple
+
+
+ Banana
+
+
+ );
+
+ let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')});
+ let rows = gridListTester.getRows();
+ let input = getByRole('textbox');
+
+ await user.tab();
+ expect(document.activeElement).toBe(rows[0]);
+ await user.tab();
+ expect(document.activeElement).toBe(input);
+
+ await user.keyboard('b');
+ expect(document.activeElement).toBe(input);
+ expect(input).toHaveValue('b');
+ }
+ );
+
+ it.each([
+ ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}],
+ ['layout="grid"', {layout: 'grid'}]
+ ])(
+ 'should not trigger selection when pressing Space or Enter in a text input child (%s)',
+ async (_, listProps) => {
+ let onSelectionChange = jest.fn();
+ let {getByRole} = render(
+
+
+ Apple
+
+
+ Banana
+
+
+ );
+
+ let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')});
+ let rows = gridListTester.getRows();
+ let input = getByRole('textbox');
+
+ await user.tab();
+ expect(document.activeElement).toBe(rows[0]);
+ await user.tab();
+ expect(document.activeElement).toBe(input);
+
+ await user.keyboard(' ');
+ expect(input).toHaveValue(' ');
+ expect(onSelectionChange).not.toHaveBeenCalled();
+
+ await user.keyboard('{Enter}');
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ }
+ );
+
+ it.each([
+ ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}],
+ ['layout="grid"', {layout: 'grid'}]
+ ])(
+ 'should not trigger selection when clicking on a tabbable child element (%s)',
+ async (_, listProps) => {
+ let onSelectionChange = jest.fn();
+ let {getByRole} = render(
+
+
+ Apple
+
+
+ Banana
+
+
+ );
+
+ let input = getByRole('textbox');
+ await user.click(input);
+ expect(document.activeElement).toBe(input);
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ }
+ );
+
+ it.each([
+ ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}],
+ ['layout="grid"', {layout: 'grid'}]
+ ])(
+ 'should still trigger selection when clicking on a row with no tabbable children (%s)',
+ async (_, listProps) => {
+ let onSelectionChange = jest.fn();
+ let {getByRole} = render(
+
+
+ Apple
+
+
+ Banana
+
+
+ );
+
+ let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')});
+ let rows = gridListTester.getRows();
+ await user.click(rows[0]);
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['item1']));
+ }
+ );
+ });
});
diff --git a/packages/react-aria-components/test/Table.test.js b/packages/react-aria-components/test/Table.test.js
index 1f83529ca71..de84c1b8a80 100644
--- a/packages/react-aria-components/test/Table.test.js
+++ b/packages/react-aria-components/test/Table.test.js
@@ -232,6 +232,42 @@ let EditableTable = ({
);
+let TabModeTable = props => (
+
+);
+
let DraggableTable = props => {
let {dragAndDropHooks} = useDragAndDrop({
getItems: keys => [...keys].map(key => ({'text/plain': key})),
@@ -3467,6 +3503,153 @@ describe('Table', () => {
expect(tableTester.getFooterRows()).toHaveLength(1);
expect(tableTester.getFooterRows()[0]).toHaveTextContent('Blah');
});
+
+ describe("keyboardNavigationBehavior='tab' and textfields in row", () => {
+ it('Tab from a focused cell moves focus to the first tabbable child', async () => {
+ let {getByRole} = render( );
+ let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')});
+ let row = tableTester.getRows()[0];
+ let cells = tableTester.getCells({element: row});
+ await user.tab();
+ await user.keyboard('{ArrowLeft}');
+ expect(document.activeElement).toBe(cells[cells.length - 1]);
+ await user.tab();
+ expect(document.activeElement).toBe(getByRole('textbox', {name: 'Games notes'}));
+ });
+
+ it('Tab from a cell with no tabbable children or from the last child in a cell exits the table', async () => {
+ let {getAllByRole, getByRole} = render(
+
+ Before
+
+ After
+
+ );
+ let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')});
+ let rowheader = tableTester.getRowHeaders()[0];
+ let row = tableTester.getRows()[0];
+ let cells = tableTester.getCells({element: row});
+ await user.tab();
+ await user.tab();
+ await user.keyboard('{ArrowRight}');
+ expect(document.activeElement).toBe(rowheader);
+ await user.tab();
+ let buttons = getAllByRole('button');
+ expect(document.activeElement).toBe(buttons[2]);
+
+ await user.tab({shift: true});
+ await user.keyboard('{ArrowLeft}');
+ await user.keyboard('{ArrowLeft}');
+ expect(document.activeElement).toBe(cells[cells.length - 1]);
+ await user.tab();
+ expect(document.activeElement).toBe(getByRole('textbox', {name: 'Games notes'}));
+ await user.tab();
+ expect(document.activeElement).toBe(getByRole('button', {name: 'Button next to input'}));
+ await user.tab();
+ expect(document.activeElement).toBe(buttons[2]);
+ });
+
+ it('Shift+Tab from a child returns focus to the cell', async () => {
+ let {getByRole} = render( );
+ let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')});
+ let row = tableTester.getRows()[0];
+ let cells = tableTester.getCells({element: row});
+ await user.tab();
+ await user.keyboard('{ArrowLeft}');
+ expect(document.activeElement).toBe(cells[cells.length - 1]);
+ await user.tab();
+ expect(document.activeElement).toBe(getByRole('textbox', {name: 'Games notes'}));
+ await user.tab({shift: true});
+ expect(document.activeElement).toBe(cells[cells.length - 1]);
+ });
+
+ it('should not navigate to next cell when arrow keys are pressed while a text input child has focus', async () => {
+ let {getByRole} = render( );
+ let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')});
+ let row = tableTester.getRows()[0];
+ let cells = tableTester.getCells({element: row});
+
+ await user.tab();
+ await user.keyboard('{ArrowLeft}');
+ expect(document.activeElement).toBe(cells[cells.length - 1]);
+ await user.tab();
+ let input = getByRole('textbox', {name: 'Games notes'});
+ expect(document.activeElement).toBe(input);
+
+ await user.keyboard('{ArrowDown}');
+ expect(document.activeElement).toBe(input);
+ await user.keyboard('{ArrowUp}');
+ expect(document.activeElement).toBe(input);
+ await user.keyboard('{ArrowRight}');
+ expect(document.activeElement).toBe(input);
+ await user.keyboard('{ArrowLeft}');
+ expect(document.activeElement).toBe(input);
+ });
+
+ it('should not trigger typeahead when typing in a text input child', async () => {
+ let {getByRole} = render( );
+ let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')});
+ let row = tableTester.getRows()[0];
+ let cells = tableTester.getCells({element: row});
+
+ await user.tab();
+ await user.keyboard('{ArrowLeft}');
+ expect(document.activeElement).toBe(cells[cells.length - 1]);
+ await user.tab();
+
+ let input = getByRole('textbox', {name: 'Games notes'});
+ expect(document.activeElement).toBe(input);
+ await user.type(input, 'Games');
+ expect(input).toHaveValue('Games');
+ expect(document.activeElement).toBe(input);
+ });
+
+ it('should not trigger selection when pressing Space or Enter in a text input child', async () => {
+ let {getByRole} = render(
+
+ );
+ let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')});
+ let row = tableTester.getRows()[0];
+ let cells = tableTester.getCells({element: row});
+
+ await user.tab();
+ await user.keyboard('{ArrowLeft}');
+ expect(document.activeElement).toBe(cells[cells.length - 1]);
+ await user.tab();
+ let input = getByRole('textbox', {name: 'Games notes'});
+ expect(document.activeElement).toBe(input);
+
+ await user.keyboard(' ');
+ expect(input).toHaveValue(' ');
+ expect(onSelectionChange).not.toHaveBeenCalled();
+
+ await user.keyboard('{Enter}');
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ });
+
+ it('should not trigger selection when clicking on a tabbable child element', async () => {
+ let {getByRole} = render(
+
+ );
+ let input = getByRole('textbox', {name: 'Games notes'});
+
+ await user.click(input);
+ expect(document.activeElement).toBe(input);
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ });
+
+ it('should still trigger selection when clicking on a row with no tabbable children ', async () => {
+ let {getByRole} = render(
+
+ );
+ let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')});
+ let row = tableTester.getRows()[0];
+
+ await user.click(row);
+ expect(onSelectionChange).toHaveBeenCalledTimes(1);
+ expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['1']));
+ });
+ });
});
function HidingColumnsExample({dynamic = false}) {
diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js
index e94c84b7686..ca686a9bd64 100644
--- a/packages/react-aria-components/test/TagGroup.test.js
+++ b/packages/react-aria-components/test/TagGroup.test.js
@@ -660,6 +660,11 @@ describe('TagGroup', () => {
expect(onRemove).toHaveBeenCalledTimes(2);
expect(onRemove).toHaveBeenLastCalledWith(new Set(['cat']));
+ // TODO: a change in behavior since taggroup is a gridlist with "tab" keyboard navigation behavior
+ // previously you could go to the next tab via arrow keys when you were focused on the close button
+ await user.tab({shift: true});
+ expect(tags[0]).toHaveFocus();
+
await user.keyboard('{ArrowRight}');
expect(tags[1]).toHaveFocus();
diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx
index d9072b35a17..2e0f634ad75 100644
--- a/packages/react-aria-components/test/Tree.test.tsx
+++ b/packages/react-aria-components/test/Tree.test.tsx
@@ -47,7 +47,8 @@ import {Virtualizer} from '../src/Virtualizer';
let {
EmptyTreeStaticStory: EmptyLoadingTree,
LoadingStoryDepOnTopStory: LoadingMoreTree,
- TreeWithDragAndDrop
+ TreeWithDragAndDrop,
+ TreeWithTextFieldStory
} = composeStories(stories);
let onSelectionChange = jest.fn();
@@ -2838,6 +2839,92 @@ describe('Tree', () => {
expect(rows[18]).toHaveAttribute('aria-posinset', '1');
expect(rows[18]).toHaveAttribute('aria-setsize', '1');
});
+
+ describe('tab navigation and textfields', () => {
+ it('should not navigate rows when arrow keys are pressed while a text input child has focus', async () => {
+ let {getByRole} = render( );
+ let treeTester = testUtilUser.createTester('Tree', {root: getByRole('treegrid')});
+ let rows = treeTester.getRows();
+ let input = getByRole('textbox', {name: 'Name'});
+
+ // tab past the before tree input
+ await user.tab();
+ await user.tab();
+ expect(document.activeElement).toBe(rows[0]);
+ await user.tab();
+ expect(document.activeElement).toBe(input);
+
+ await user.keyboard('{ArrowDown}');
+ expect(document.activeElement).toBe(input);
+ await user.keyboard('{ArrowUp}');
+ expect(document.activeElement).toBe(input);
+ await user.keyboard('{ArrowRight}');
+ expect(document.activeElement).toBe(input);
+ await user.keyboard('{ArrowLeft}');
+ expect(document.activeElement).toBe(input);
+ });
+
+ it('should not trigger typeahead when typing in a text input child', async () => {
+ let {getByRole} = render( );
+ let treeTester = testUtilUser.createTester('Tree', {root: getByRole('treegrid')});
+ let rows = treeTester.getRows();
+ let input = getByRole('textbox', {name: 'Name'});
+
+ await user.tab();
+ await user.tab();
+ expect(document.activeElement).toBe(rows[0]);
+ await user.tab();
+ expect(document.activeElement).toBe(input);
+
+ await user.keyboard('row');
+ expect(document.activeElement).toBe(input);
+ expect(input).toHaveValue('row');
+ });
+
+ it('should not trigger selection when pressing Space in a text input child of a leaf row', async () => {
+ let onSelectionChange = jest.fn();
+ let {getByRole} = render(
+
+ );
+ let treeTester = testUtilUser.createTester('Tree', {root: getByRole('treegrid')});
+ let rows = treeTester.getRows();
+ let input = getByRole('textbox', {name: 'Name'});
+
+ await user.tab();
+ await user.tab();
+ expect(document.activeElement).toBe(rows[0]);
+ await user.tab();
+ await user.tab();
+ expect(document.activeElement).toBe(input);
+
+ await user.keyboard(' ');
+ expect(input).toHaveValue(' ');
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ });
+
+ it('should allow typing space in the text input child of a parent row', async () => {
+ let onSelectionChange = jest.fn();
+ let {getByRole} = render(
+
+ );
+ let treeTester = testUtilUser.createTester('Tree', {root: getByRole('treegrid')});
+ let rows = treeTester.getRows();
+
+ await user.tab();
+ await user.tab();
+ expect(document.activeElement).toBe(rows[0]);
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{ArrowDown}');
+ await user.tab();
+ await user.tab();
+ let parentInput = getByRole('textbox', {name: 'row 1 input'});
+ expect(document.activeElement).toBe(parentInput);
+
+ await user.keyboard(' ');
+ expect(parentInput).toHaveValue(' ');
+ expect(onSelectionChange).not.toHaveBeenCalled();
+ });
+ });
});
AriaTreeTests({
diff --git a/packages/react-aria/src/grid/useGrid.ts b/packages/react-aria/src/grid/useGrid.ts
index b9897552a7f..88244b23e98 100644
--- a/packages/react-aria/src/grid/useGrid.ts
+++ b/packages/react-aria/src/grid/useGrid.ts
@@ -82,6 +82,13 @@ export interface GridProps extends DOMProps, AriaLabelingProps {
escapeKeyBehavior?: 'clearSelection' | 'none';
/** Whether selection should occur on press up instead of press down. */
shouldSelectOnPressUp?: boolean;
+ /**
+ * Whether keyboard navigation to focusable elements within grid cells is
+ * via arrow keys or the tab key.
+ *
+ * @default 'arrow'
+ */
+ keyboardNavigationBehavior?: 'arrow' | 'tab';
}
export interface GridAria {
@@ -113,7 +120,8 @@ export function useGrid(
onRowAction,
onCellAction,
escapeKeyBehavior = 'clearSelection',
- shouldSelectOnPressUp
+ shouldSelectOnPressUp,
+ keyboardNavigationBehavior = 'arrow'
} = props;
let {selectionManager: manager} = state;
@@ -164,7 +172,8 @@ export function useGrid(
gridMap.set(state, {
keyboardDelegate: delegate,
actions: {onRowAction, onCellAction},
- shouldSelectOnPressUp
+ shouldSelectOnPressUp,
+ keyboardNavigationBehavior
});
let descriptionProps = useHighlightSelectionDescription({
diff --git a/packages/react-aria/src/grid/useGridCell.ts b/packages/react-aria/src/grid/useGridCell.ts
index a7f366eab2d..ce6b395251f 100644
--- a/packages/react-aria/src/grid/useGridCell.ts
+++ b/packages/react-aria/src/grid/useGridCell.ts
@@ -27,8 +27,14 @@ import {
import {gridMap} from './utils';
import {GridState} from 'react-stately/private/grid/useGridState';
import {isFocusVisible} from '../interactions/useFocusVisible';
+import {isTabbable} from '../utils/isFocusable';
import {mergeProps} from '../utils/mergeProps';
-import {KeyboardEvent as ReactKeyboardEvent, useRef} from 'react';
+import {
+ KeyboardEvent as ReactKeyboardEvent,
+ MouseEvent as ReactMouseEvent,
+ PointerEvent as ReactPointerEvent,
+ useRef
+} from 'react';
import {scrollIntoViewport} from '../utils/scrollIntoView';
import {useLocale} from '../i18n/I18nProvider';
import {useSelectableItem} from '../selection/useSelectableItem';
@@ -82,9 +88,14 @@ export function useGridCell>(
let {direction} = useLocale();
let {
keyboardDelegate,
- actions: {onCellAction}
+ actions: {onCellAction},
+ keyboardNavigationBehavior
} = gridMap.get(state)!;
+ if (keyboardNavigationBehavior === 'tab') {
+ focusMode = 'cell';
+ }
+
// 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.
let keyWhenFocused = useRef(null);
@@ -252,6 +263,44 @@ export function useGridCell>(
}
};
+ let onKeyDown = (e: ReactKeyboardEvent) => {
+ let activeElement = getActiveElement();
+ if (
+ !nodeContains(e.currentTarget, getEventTarget(e) as Element) ||
+ state.isKeyboardNavigationDisabled ||
+ !ref.current ||
+ !activeElement
+ ) {
+ return;
+ }
+
+ if (keyboardNavigationBehavior === 'tab') {
+ if (
+ getEventTarget(e) !== ref.current &&
+ (isArrowKey(e.key) || isCharacterKey(e.key) || e.key === 'Enter')
+ ) {
+ e.stopPropagation();
+ return;
+ }
+ }
+
+ switch (e.key) {
+ case 'Tab': {
+ if (keyboardNavigationBehavior === 'tab') {
+ // If there is another focusable element within this item, stop propagation so the tab key
+ // is handled by the browser and not by useSelectableCollection (which would take us out of the list).
+ let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
+ walker.currentNode = activeElement;
+ let next = e.shiftKey ? walker.previousNode() : walker.nextNode();
+
+ if (next) {
+ e.stopPropagation();
+ }
+ }
+ }
+ }
+ };
+
// Grid cells can have focusable elements inside them. In this case, focus should
// be marshalled to that element rather than focusing the cell itself.
let onFocus = e => {
@@ -280,7 +329,8 @@ export function useGridCell>(
let gridCellProps: DOMAttributes = mergeProps(itemProps, {
role: 'gridcell',
- onKeyDownCapture,
+ onKeyDownCapture: keyboardNavigationBehavior === 'tab' ? undefined : onKeyDownCapture,
+ onKeyDown: keyboardNavigationBehavior === 'tab' ? onKeyDown : undefined,
'aria-colspan': node.colSpan,
'aria-colindex': node.colIndex != null ? node.colIndex + 1 : undefined, // aria-colindex is 1-based
colSpan: isVirtualized ? undefined : node.colSpan,
@@ -291,6 +341,29 @@ export function useGridCell>(
gridCellProps['aria-colindex'] = (node.colIndex ?? node.index) + 1; // aria-colindex is 1-based
}
+ // TODO: same logic as in useGridListItem
+ // doesn't have the keydown handler part since we don't seem to have the same problem where Enter
+ // triggers selection when in a textfield
+ let baseOnPointerDown = gridCellProps.onPointerDown;
+ gridCellProps.onPointerDown = (e: ReactPointerEvent) => {
+ let target = getEventTarget(e) as Element | null;
+ if (target && target !== ref.current && isTabbable(target)) {
+ e.stopPropagation();
+ return;
+ }
+ baseOnPointerDown?.(e);
+ };
+
+ let baseOnMouseDown = gridCellProps.onMouseDown;
+ gridCellProps.onMouseDown = (e: ReactMouseEvent) => {
+ let target = getEventTarget(e) as Element | null;
+ if (target && target !== ref.current && isTabbable(target)) {
+ e.stopPropagation();
+ return;
+ }
+ baseOnMouseDown?.(e);
+ };
+
// When pressing with a pointer and cell selection is not enabled, usePress will be applied to the
// row rather than the cell. However, when the row is draggable, usePress cannot preventDefault
// on pointer down, so the browser will try to focus the cell which has a tabIndex applied.
@@ -329,3 +402,11 @@ function last(walker: TreeWalker) {
} while (last);
return next;
}
+
+function isArrowKey(key: string): boolean {
+ return key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight';
+}
+
+function isCharacterKey(key: string): boolean {
+ return key.length === 1 || !/^[A-Z]/i.test(key);
+}
diff --git a/packages/react-aria/src/grid/utils.ts b/packages/react-aria/src/grid/utils.ts
index 35d756903b2..1d785b96405 100644
--- a/packages/react-aria/src/grid/utils.ts
+++ b/packages/react-aria/src/grid/utils.ts
@@ -22,6 +22,7 @@ interface GridMapShared {
onCellAction?: (key: Key) => void;
};
shouldSelectOnPressUp?: boolean;
+ keyboardNavigationBehavior?: 'arrow' | 'tab';
}
// Used to share:
diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts
index d70ea835356..816463e3c89 100644
--- a/packages/react-aria/src/gridlist/useGridListItem.ts
+++ b/packages/react-aria/src/gridlist/useGridListItem.ts
@@ -30,8 +30,15 @@ import {
import {getFocusableTreeWalker} from '../focus/FocusScope';
import {getRowId, listMap} from './utils';
import {getScrollParent} from '../utils/getScrollParent';
-import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react';
+import {
+ HTMLAttributes,
+ KeyboardEvent as ReactKeyboardEvent,
+ MouseEvent as ReactMouseEvent,
+ PointerEvent as ReactPointerEvent,
+ useRef
+} from 'react';
import {isFocusVisible} from '../interactions/useFocusVisible';
+import {isTabbable} from '../utils/isFocusable';
import type {ListState} from 'react-stately/useListState';
import {mergeProps} from '../utils/mergeProps';
import {scrollIntoViewport} from '../utils/scrollIntoView';
@@ -181,36 +188,10 @@ export function useGridListItem(
let walker = getFocusableTreeWalker(ref.current);
walker.currentNode = activeElement;
- if ('expandedKeys' in state && activeElement === ref.current) {
- if (
- e.key === EXPANSION_KEYS['expand'][direction] &&
- state.selectionManager.focusedKey === node.key &&
- hasChildRows &&
- !state.expandedKeys.has(node.key)
- ) {
- state.toggleKey(node.key);
- e.stopPropagation();
- return;
- } else if (
- e.key === EXPANSION_KEYS['collapse'][direction] &&
- state.selectionManager.focusedKey === node.key
- ) {
- // If item is collapsible, collapse it; else move to parent
- if (hasChildRows && state.expandedKeys.has(node.key)) {
- state.toggleKey(node.key);
- e.stopPropagation();
- return;
- } else if (
- !state.expandedKeys.has(node.key) &&
- node.parentKey &&
- state.collection.getItem(node.parentKey)?.type === 'item'
- ) {
- // Item is a leaf or already collapsed, move focus to parent
- state.selectionManager.setFocusedKey(node.parentKey);
- e.stopPropagation();
- return;
- }
- }
+ if (
+ handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current)
+ ) {
+ return;
}
switch (e.key) {
@@ -310,7 +291,7 @@ export function useGridListItem(
}
};
- let onKeyDown = e => {
+ let onKeyDown = (e: ReactKeyboardEvent) => {
let activeElement = getActiveElement();
if (
!nodeContains(e.currentTarget, getEventTarget(e) as Element) ||
@@ -320,6 +301,28 @@ export function useGridListItem(
return;
}
+ if (keyboardNavigationBehavior === 'tab') {
+ // TODO: Added Rob's useTypeSelect changes, but that only stops if type select is in progress
+ // This will stop arrow key navigation and typeselect from bubbling up
+ // (note that this breaks TagGroup's old behavior of using arrow keys to move from "x" button to next tag and typeselect when inside a card/row)
+ // should it just stop propagation for all events since we can't rely on non-RAC components stopping propagation even they handled the event
+ // Will need to do something similar for click?
+ // TODO: have it stop on all events that bubbled up from the cell (will need to let Tab go through since we need useSelectableCollection to handle that)
+ if (
+ getEventTarget(e) !== ref.current &&
+ (isArrowKey(e.key) || isCharacterKey(e.key) || e.key === 'Enter')
+ ) {
+ e.stopPropagation();
+ return;
+ }
+
+ if (
+ handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current)
+ ) {
+ return;
+ }
+ }
+
switch (e.key) {
case 'Tab': {
if (keyboardNavigationBehavior === 'tab') {
@@ -351,8 +354,7 @@ export function useGridListItem(
let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, {
role: 'row',
- onKeyDownCapture,
- onKeyDown,
+ onKeyDownCapture: keyboardNavigationBehavior === 'arrow' ? onKeyDownCapture : undefined,
onFocus,
// 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '),
'aria-label': node['aria-label'] || node.textValue || undefined,
@@ -367,6 +369,40 @@ export function useGridListItem(
id: getRowId(state, node.key)
});
+ // TODO: guarding against selection when firing space/enter/click on a element in a row is technically not only limited to textfields so I
+ // am not making it specific to keyboardNavigationBehavior = tab, but maybe we should still?
+ // we need to guard against space/enter triggering selection/row link via usePress (from itemProps) so check if propagation
+ // is stopped. this also fixes space not working in a textfield in a tree parent row
+ let baseOnKeyDown = rowProps.onKeyDown;
+ rowProps.onKeyDown = (e: ReactKeyboardEvent) => {
+ onKeyDown(e as ReactKeyboardEvent);
+ if (!e.isPropagationStopped()) {
+ baseOnKeyDown?.(e);
+ }
+ };
+
+ // guard against presses triggering row selecition when they happen on elements within the row
+ // am currently assuming if it is tabbable it is interactive, but maybe can use a different kind of check
+ let baseOnPointerDown = rowProps.onPointerDown;
+ rowProps.onPointerDown = (e: ReactPointerEvent) => {
+ let target = getEventTarget(e) as Element | null;
+ if (target && target !== ref.current && isTabbable(target)) {
+ e.stopPropagation();
+ return;
+ }
+ baseOnPointerDown?.(e);
+ };
+
+ let baseOnMouseDown = rowProps.onMouseDown;
+ rowProps.onMouseDown = (e: ReactMouseEvent) => {
+ let target = getEventTarget(e) as Element | null;
+ if (target && target !== ref.current && isTabbable(target)) {
+ e.stopPropagation();
+ return;
+ }
+ baseOnMouseDown?.(e);
+ };
+
if (isVirtualized) {
let {collection} = state;
let nodes = [...collection];
@@ -394,6 +430,50 @@ export function useGridListItem(
};
}
+function handleTreeExpansionKeys(
+ e: ReactKeyboardEvent,
+ state: ListState | TreeState,
+ node: RSNode,
+ hasChildRows: boolean | undefined,
+ direction: string,
+ activeElement: Element | null,
+ rowRef: FocusableElement | null
+): boolean {
+ if (!('expandedKeys' in state) || activeElement !== rowRef) {
+ return false;
+ }
+ if (
+ e.key === EXPANSION_KEYS['expand'][direction] &&
+ state.selectionManager.focusedKey === node.key &&
+ hasChildRows &&
+ !state.expandedKeys.has(node.key)
+ ) {
+ state.toggleKey(node.key);
+ e.stopPropagation();
+ return true;
+ } else if (
+ e.key === EXPANSION_KEYS['collapse'][direction] &&
+ state.selectionManager.focusedKey === node.key
+ ) {
+ // If item is collapsible, collapse it; else move to parent
+ if (hasChildRows && state.expandedKeys.has(node.key)) {
+ state.toggleKey(node.key);
+ e.stopPropagation();
+ return true;
+ } else if (
+ !state.expandedKeys.has(node.key) &&
+ node.parentKey &&
+ state.collection.getItem(node.parentKey)?.type === 'item'
+ ) {
+ // Item is a leaf or already collapsed, move focus to parent
+ state.selectionManager.setFocusedKey(node.parentKey);
+ e.stopPropagation();
+ return true;
+ }
+ }
+ return false;
+}
+
function last(walker: TreeWalker) {
let next: FocusableElement | null = null;
let last: FocusableElement | null = null;
@@ -419,3 +499,12 @@ function getDirectChildren(parent: RSNode, collection: Collection(
errorMessage: props.errorMessage || validationErrors
});
- typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture;
- delete typeSelectProps.onKeyDownCapture;
if (state.selectionManager.selectionMode === 'multiple') {
typeSelectProps = {};
}
diff --git a/packages/react-aria/src/selection/useTypeSelect.ts b/packages/react-aria/src/selection/useTypeSelect.ts
index eae1215dad4..ad7e00d4304 100644
--- a/packages/react-aria/src/selection/useTypeSelect.ts
+++ b/packages/react-aria/src/selection/useTypeSelect.ts
@@ -12,7 +12,7 @@
import {DOMAttributes, Key, KeyboardDelegate} from '@react-types/shared';
import {getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions';
-import {KeyboardEvent, useRef} from 'react';
+import {KeyboardEvent, useEffect, useRef} from 'react';
import {MultipleSelectionManager} from 'react-stately/useMultipleSelectionState';
/**
@@ -50,41 +50,75 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria {
let state = useRef<{search: string; timeout: ReturnType | undefined}>({
search: '',
timeout: undefined
- }).current;
+ });
+
+ let onKeyDownCapture = (e: KeyboardEvent) => {
+ // if we're in the middle of a search, then a spacebar should be treated as a search and we should not propagate the event
+ // since we handle this one in a capture phase, we should ignore it in the bubble phase
+ if (state.current.search.length > 0 && e.key === ' ') {
+ e.preventDefault();
+ if (
+ !('continuePropagation' in e) ||
+ ('continuePropagation' in e && !e.isPropagationStopped())
+ ) {
+ e.stopPropagation();
+ }
+ state.current.search += ' ';
+
+ if (keyboardDelegate.getKeyForSearch != null) {
+ // Use the delegate to find a key to focus.
+ // Prioritize items after the currently focused item, falling back to searching the whole list.
+ let key = keyboardDelegate.getKeyForSearch(
+ state.current.search,
+ selectionManager.focusedKey
+ );
+
+ // If no key found, search from the top.
+ if (key == null) {
+ key = keyboardDelegate.getKeyForSearch(state.current.search);
+ }
+
+ if (key != null) {
+ selectionManager.setFocusedKey(key);
+ if (onTypeSelect) {
+ onTypeSelect(key);
+ }
+ }
+ }
+
+ clearTimeout(state.current.timeout);
+ state.current.timeout = setTimeout(() => {
+ state.current.search = '';
+ }, TYPEAHEAD_DEBOUNCE_WAIT_MS);
+ }
+ };
let onKeyDown = (e: KeyboardEvent) => {
+ if (e.altKey) {
+ return;
+ }
+
let character = getStringForKey(e.key);
if (
!character ||
e.ctrlKey ||
e.metaKey ||
+ e.altKey ||
!nodeContains(e.currentTarget, getEventTarget(e) as HTMLElement) ||
- (state.search.length === 0 && character === ' ')
+ (state.current.search.length === 0 && character === ' ')
) {
return;
}
- // Do not propagate the Spacebar event if it's meant to be part of the search.
- // When we time out, the search term becomes empty, hence the check on length.
- // Trimming is to account for the case of pressing the Spacebar more than once,
- // which should cycle through the selection/deselection of the focused item.
- if (character === ' ' && state.search.trim().length > 0) {
- e.preventDefault();
- if (!('continuePropagation' in e)) {
- e.stopPropagation();
- }
- }
-
- state.search += character;
+ state.current.search += character;
if (keyboardDelegate.getKeyForSearch != null) {
// Use the delegate to find a key to focus.
// Prioritize items after the currently focused item, falling back to searching the whole list.
- let key = keyboardDelegate.getKeyForSearch(state.search, selectionManager.focusedKey);
+ let key = keyboardDelegate.getKeyForSearch(state.current.search, selectionManager.focusedKey);
- // If no key found, search from the top.
if (key == null) {
- key = keyboardDelegate.getKeyForSearch(state.search);
+ key = keyboardDelegate.getKeyForSearch(state.current.search);
}
if (key != null) {
@@ -92,20 +126,38 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria {
if (onTypeSelect) {
onTypeSelect(key);
}
+ e.preventDefault();
+ if (!('continuePropagation' in e)) {
+ e.stopPropagation();
+ }
+ } else {
+ // if still nothing then the type to select is done and everything is reset
+ state.current.search = '';
+ clearTimeout(state.current.timeout);
+ state.current.timeout = undefined;
+ return;
}
}
- clearTimeout(state.timeout);
- state.timeout = setTimeout(() => {
- state.search = '';
+ clearTimeout(state.current.timeout);
+ state.current.timeout = setTimeout(() => {
+ state.current.search = '';
}, TYPEAHEAD_DEBOUNCE_WAIT_MS);
};
+ useEffect(() => {
+ let timeout = state.current.timeout;
+ return () => {
+ clearTimeout(timeout);
+ };
+ }, [state]);
+
return {
typeSelectProps: {
// Using a capturing listener to catch the keydown event before
// other hooks in order to handle the Spacebar event.
- onKeyDownCapture: keyboardDelegate.getKeyForSearch ? onKeyDown : undefined
+ onKeyDownCapture: keyboardDelegate.getKeyForSearch ? onKeyDownCapture : undefined,
+ onKeyDown: keyboardDelegate.getKeyForSearch ? onKeyDown : undefined
}
};
}
diff --git a/packages/react-aria/src/tree/useTree.ts b/packages/react-aria/src/tree/useTree.ts
index 6a2f38793aa..dd98bd85c9e 100644
--- a/packages/react-aria/src/tree/useTree.ts
+++ b/packages/react-aria/src/tree/useTree.ts
@@ -21,10 +21,7 @@ import {TreeState} from 'react-stately/useTreeState';
export interface TreeProps extends GridListProps {}
-export interface AriaTreeProps extends Omit<
- AriaGridListProps,
- 'keyboardNavigationBehavior'
-> {}
+export interface AriaTreeProps extends AriaGridListProps {}
export interface AriaTreeOptions extends Omit<
AriaGridListOptions,
'children' | 'shouldFocusWrap'
diff --git a/packages/react-stately/src/table/useTableState.ts b/packages/react-stately/src/table/useTableState.ts
index 3445d9c9dbb..9d0fa83f766 100644
--- a/packages/react-stately/src/table/useTableState.ts
+++ b/packages/react-stately/src/table/useTableState.ts
@@ -49,6 +49,13 @@ export interface TableProps extends MultipleSelection, Sortable, Expandable {
shouldSelectOnPressUp?: boolean;
/** The id of the column that displays hierarchical data. */
treeColumn?: Key;
+ /**
+ * Whether keyboard navigation to focusable elements within the cells is
+ * via the left/right arrow keys or the tab key.
+ *
+ * @default 'arrow'
+ */
+ keyboardNavigationBehavior?: 'arrow' | 'tab';
}
export interface TableState extends GridState> {
diff --git a/starters/docs/src/ComboBox.css b/starters/docs/src/ComboBox.css
index 12f8d59edef..6a9f4a33013 100644
--- a/starters/docs/src/ComboBox.css
+++ b/starters/docs/src/ComboBox.css
@@ -2,6 +2,8 @@
@import './TextField.css';
.react-aria-ComboBox {
+ display: flex;
+ flex-direction: column;
color: var(--text-color);
width: calc(var(--spacing) * 50);
diff --git a/starters/docs/src/ComboBox.tsx b/starters/docs/src/ComboBox.tsx
index e0ec8c027a6..80ef180ebb9 100644
--- a/starters/docs/src/ComboBox.tsx
+++ b/starters/docs/src/ComboBox.tsx
@@ -35,7 +35,7 @@ export function ComboBox({
}: ComboBoxProps) {
return (
- {label}
+ {label && {label} }