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} +
+ + {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 comment + 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 comment + 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 comment + 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 comment + 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 ( +
+ + + + RAC TextField + + + + + + Raw input + + + TextField + Button + + + {' '} + + + + Toolbar + + + + + + + + Menu + {/* TODO: hitting escape to close the menu, returns focus to the row. + Tabbing back from the external input also focuses the trggerbutton rather than the row. Tabbing back into the textfield row focuses the row */} + + + + + Cut + Copy + Paste + + + + + + RadioGroup + + + Dog + + + Cat + + + Dragon + + + + + CheckboxGroup + + + + Soccer + + + + Baseball + + + + Basketball + + + + + ComboBox + +
+ + +
+ + + Foo + Bar + Baz + Google + + +
+
+
+ +
+ ); +}; + +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 ( +
+ + + + + + + Col 1 + Col 2 + Col 3 + Col 4 + + + + + + + RAC Textfield + + + + + + Raw input + + + + + + + + + TextField + Button + + + + + + + Toolbar + + + + + + + + + + + + + + Menu + + + + + + Cut + Copy + Paste + + + + + RadioGroup + + + + Dog + + + Cat + + + Dragon + + + + + + + + + CheckboxGroup + + + + + Soccer + + + + Baseball + + + + Basketball + + + + ComboBox + + +
+ + +
+ + + Foo + Bar + Baz + Google + + +
+
+
+
+
+ +
+ ); +}; + +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 ( +
+ + + + + + }> + RAC TextField + + }> + Raw input + + + + + }> + + + + + + + }> + TextField + Button + + + + + + + }> + Toolbar + + }> + +
+ + +
+ + + Foo + Bar + Baz + Google + + + + }> + Nested child 1 +
+ + + + + Cut + Copy + Paste + + + + }> + Nested child 2 + +
+
+
+ +
+ ); +} + +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 => ( + + + + Name + + Type + Notes + + + + Games + File folder + + + + + + + Program Files + File folder + + + + + + bootmgr + System file + + + + + +
+); + 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( +
+ + + +
+ ); + 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 && }
diff --git a/starters/docs/src/Tree.tsx b/starters/docs/src/Tree.tsx index 3928dfd5a4e..425e7ea2481 100644 --- a/starters/docs/src/Tree.tsx +++ b/starters/docs/src/Tree.tsx @@ -48,15 +48,21 @@ export function TreeItemContent( } export interface TreeItemProps extends Partial { - title: React.ReactNode; + title?: React.ReactNode; } export function TreeItem(props: TreeItemProps) { let textValue = typeof props.title === 'string' ? props.title : ''; return ( - {props.title} - {props.children} + {props.title != null ? ( + <> + {props.title} + {props.children} + + ) : ( + props.children + )} ); }