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/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/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 7c42b76baf4..9710c97e31f 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 + + + {' '} + + + + ComboBox + +
+ + +
+ + + Foo + Bar + Baz + Google + + +
+
+ + 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 + + + +
+ +
+ ); +}; + +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/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..c5df18a2ff8 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -22,6 +22,7 @@ import { import {Checkbox as AriaCheckbox, CheckboxButton, CheckboxField} from '../src/Checkbox'; import {Button} from '../src/Button'; import {Collection} from 'react-aria/Collection'; +import {ComboBox} from '../src/ComboBox'; import {Dialog, DialogTrigger} from '../src/Dialog'; import {DropIndicator, useDragAndDrop} from '../src/useDragAndDrop'; import {getFocusableTreeWalker} from 'react-aria/private/focus/FocusScope'; @@ -33,13 +34,17 @@ import { GridListSection } from '../src/GridList'; import {GridListLoadMoreItem} from '../src/GridList'; +import {Input} from '../src/Input'; import {installPointerEvent, User} from '@react-aria/test-utils'; import {Label} from '../src/Label'; +import {ListBox, ListBoxItem} from '../src/ListBox'; import {ListLayout} from 'react-stately/useVirtualizerState'; import {Modal} from '../src/Modal'; +import {Popover} from '../src/Popover'; import React from 'react'; import {RouterProvider} from 'react-aria/private/utils/openLink'; import {Tag, TagGroup, TagList} from '../src/TagGroup'; +import {Toolbar} from '../src/Toolbar'; import userEvent from '@testing-library/user-event'; import {Virtualizer} from '../src/Virtualizer'; @@ -1833,4 +1838,248 @@ 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'])); + } + ); + + it.each([ + ['keyboardNavigationBehavior="tab"', {keyboardNavigationBehavior: 'tab'}], + ['layout="grid"', {layout: 'grid'}] + ])( + 'should exit the grid when tabbing from a combobox or toolbar, not focus the next row (%s)', + async (_, listProps) => { + let {getByRole} = render( +
+ + + + + + + + + Foo + Bar + + + + + + + + + + + + + + + +
+ ); + + let combobox = getByRole('combobox'); + let afterInput = getByRole('textbox', {name: 'after'}); + let boldButton = getByRole('button', {name: 'Bold'}); + let row2Input = getByRole('textbox', {name: 'row 2 input'}); + + let gridListTester = testUtilUser.createTester('GridList', {root: getByRole('grid')}); + let rows = gridListTester.getRows(); + + await user.tab(); + await user.tab(); + await user.tab(); + expect(document.activeElement).toBe(combobox); + + await user.tab(); + expect(document.activeElement).toBe(afterInput); + + // note that shift tabbing move focus back to gridlist item not the combobox itself, + // will need to look into this later + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[0]); + await user.keyboard(listProps.layout === 'grid' ? '{ArrowRight}' : '{ArrowDown}'); + expect(document.activeElement).toBe(rows[1]); + await user.tab(); + expect(document.activeElement).toBe(boldButton); + await user.tab(); + expect(document.activeElement).toBe(afterInput); + + await user.tab({shift: true}); + expect(document.activeElement).toBe(rows[1]); + await user.keyboard(listProps.layout === 'grid' ? '{ArrowRight}' : '{ArrowDown}'); + expect(document.activeElement).toBe(rows[2]); + await user.tab(); + expect(document.activeElement).toBe(row2Input); + await user.tab(); + expect(document.activeElement).toBe(afterInput); + } + ); + }); }); diff --git a/packages/react-aria-components/test/TagGroup.test.js b/packages/react-aria-components/test/TagGroup.test.js index e94c84b7686..88c057d0abd 100644 --- a/packages/react-aria-components/test/TagGroup.test.js +++ b/packages/react-aria-components/test/TagGroup.test.js @@ -660,6 +660,9 @@ describe('TagGroup', () => { expect(onRemove).toHaveBeenCalledTimes(2); expect(onRemove).toHaveBeenLastCalledWith(new Set(['cat'])); + 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/combobox/useComboBox.ts b/packages/react-aria/src/combobox/useComboBox.ts index 7763da45591..8ea8a9e3121 100644 --- a/packages/react-aria/src/combobox/useComboBox.ts +++ b/packages/react-aria/src/combobox/useComboBox.ts @@ -215,6 +215,10 @@ export function useComboBox( if (e.key === 'Enter' || state.isOpen) { state.commit(); } + if (e.key === 'Tab') { + e.continuePropagation(); + } + break; case 'Escape': if (!state.selectionManager.isEmpty || state.inputValue === '' || props.allowsCustomValue) { diff --git a/packages/react-aria/src/gridlist/useGridListItem.ts b/packages/react-aria/src/gridlist/useGridListItem.ts index d70ea835356..f352fc824f4 100644 --- a/packages/react-aria/src/gridlist/useGridListItem.ts +++ b/packages/react-aria/src/gridlist/useGridListItem.ts @@ -11,7 +11,6 @@ */ import {chain} from '../utils/chain'; - import { Collection, DOMAttributes, @@ -30,8 +29,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 +187,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 +290,7 @@ export function useGridListItem( } }; - let onKeyDown = e => { + let onKeyDown = (e: ReactKeyboardEvent) => { let activeElement = getActiveElement(); if ( !nodeContains(e.currentTarget, getEventTarget(e) as Element) || @@ -320,6 +300,22 @@ export function useGridListItem( return; } + if (keyboardNavigationBehavior === 'tab') { + // Stop propagation for all events that originate from the children of the gridlist item since we don't want to trigger + // grid level interactions (row navigation/typeselect/etc) + // exception made for Tab since that needs to propagate to useSelectableCollection to tab out of the gridlist, might be others? + if (getEventTarget(e) !== ref.current && e.key !== 'Tab') { + e.stopPropagation(); + return; + } + + if ( + handleTreeExpansionKeys(e, state, node, hasChildRows, direction, activeElement, ref.current) + ) { + return; + } + } + switch (e.key) { case 'Tab': { if (keyboardNavigationBehavior === 'tab') { @@ -351,8 +347,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 +362,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 +423,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; diff --git a/packages/react-aria/src/interactions/createEventHandler.ts b/packages/react-aria/src/interactions/createEventHandler.ts index 88831ab4d9f..18e04c3b7dc 100644 --- a/packages/react-aria/src/interactions/createEventHandler.ts +++ b/packages/react-aria/src/interactions/createEventHandler.ts @@ -45,6 +45,11 @@ export function createEventHandler( }, continuePropagation() { shouldStopPropagation = false; + // nested createEventHandler might have set continue propagation so we should continue + // propagation on wrappers + if (typeof (e as any).continuePropagation === 'function') { + (e as any).continuePropagation(); + } }, isPropagationStopped() { return shouldStopPropagation; diff --git a/packages/react-aria/src/select/useSelect.ts b/packages/react-aria/src/select/useSelect.ts index 470a72555d3..d1aee0733b2 100644 --- a/packages/react-aria/src/select/useSelect.ts +++ b/packages/react-aria/src/select/useSelect.ts @@ -187,8 +187,6 @@ export function useSelect( 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/toolbar/useToolbar.ts b/packages/react-aria/src/toolbar/useToolbar.ts index 00b0b54b28c..bedd41b674a 100644 --- a/packages/react-aria/src/toolbar/useToolbar.ts +++ b/packages/react-aria/src/toolbar/useToolbar.ts @@ -89,7 +89,6 @@ export function useToolbar( // out of the entire toolbar. To do this, move focus // to the first or last focusable child, and let the // browser handle the Tab key as usual from there. - e.stopPropagation(); lastFocused.current = getActiveElement() as HTMLElement; if (e.shiftKey) { focusManager.focusFirst(); 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/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 + )} ); }