diff --git a/packages/@react-spectrum/s2/src/ListView.tsx b/packages/@react-spectrum/s2/src/ListView.tsx index 1e04f0bf2cc..41d69a077b5 100644 --- a/packages/@react-spectrum/s2/src/ListView.tsx +++ b/packages/@react-spectrum/s2/src/ListView.tsx @@ -92,7 +92,6 @@ export interface ListViewProps | 'selectionBehavior' | 'layout' | 'render' - | 'keyboardNavigationBehavior' | 'orientation' | keyof GlobalDOMAttributes >, diff --git a/packages/@react-spectrum/s2/src/TreeView.tsx b/packages/@react-spectrum/s2/src/TreeView.tsx index 73abdd4bb11..4573d2d5331 100644 --- a/packages/@react-spectrum/s2/src/TreeView.tsx +++ b/packages/@react-spectrum/s2/src/TreeView.tsx @@ -104,7 +104,6 @@ export interface TreeViewProps | 'selectionBehavior' | 'onScroll' | 'onCellAction' - | 'keyboardNavigationBehavior' | keyof GlobalDOMAttributes >, UnsafeStyles, diff --git a/packages/@react-spectrum/s2/test/Picker.test.tsx b/packages/@react-spectrum/s2/test/Picker.test.tsx index 20192fd6b39..05d59184f1e 100644 --- a/packages/@react-spectrum/s2/test/Picker.test.tsx +++ b/packages/@react-spectrum/s2/test/Picker.test.tsx @@ -180,40 +180,6 @@ describe('Picker', () => { expect(tree.getByTestId('custom-value')).toHaveTextContent('Chocolate, Vanilla'); }); - it('supports shift+click to select a range in multi-selection', async () => { - let user = userEvent.setup({delay: null, pointerMap}); - let items = [ - {id: 'chocolate', name: 'Chocolate'}, - {id: 'strawberry', name: 'Strawberry'}, - {id: 'vanilla', name: 'Vanilla'} - ]; - let tree = render( - - {(item: any) => ( - - {item.name} - - )} - - ); - - let selectTester = testUtilUser.createTester('Select', { - root: tree.container, - interactionType: 'mouse' - }); - await selectTester.open(); - let options = selectTester.getOptions(); - - await user.click(options[0]); - await user.keyboard('{Shift>}'); - await user.click(options[2]); - await user.keyboard('{/Shift}'); - - expect(options[0]).toHaveAttribute('aria-selected', 'true'); - expect(options[1]).toHaveAttribute('aria-selected', 'true'); - expect(options[2]).toHaveAttribute('aria-selected', 'true'); - }); - it('should warn if the custom render value output has a interactive child', async () => { using spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) as jest.SpyInstance & Disposable; diff --git a/packages/dev/s2-docs/pages/react-aria/GridList.mdx b/packages/dev/s2-docs/pages/react-aria/GridList.mdx index 74464237e46..8f5e302a932 100644 --- a/packages/dev/s2-docs/pages/react-aria/GridList.mdx +++ b/packages/dev/s2-docs/pages/react-aria/GridList.mdx @@ -712,9 +712,8 @@ Use this when rows contain interactive elements such as text fields, where arrow ```tsx render "use client"; import {GridList, GridListItem, Text} from 'vanilla-starter/GridList'; -import {ComboBox, ComboBoxItem} from 'vanilla-starter/ComboBox'; +import {TextField} from 'vanilla-starter/TextField'; -///- 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'}, @@ -724,16 +723,6 @@ let photos = [ {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.title} + + + {item.description} - )} diff --git a/packages/dev/s2-docs/pages/react-aria/Tree.mdx b/packages/dev/s2-docs/pages/react-aria/Tree.mdx index fd54f04e616..8ee8667aee3 100644 --- a/packages/dev/s2-docs/pages/react-aria/Tree.mdx +++ b/packages/dev/s2-docs/pages/react-aria/Tree.mdx @@ -330,19 +330,7 @@ Use this when rows contain interactive elements such as text fields, where arrow ```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 -/// +import {TextField} from 'vanilla-starter/TextField'; - + aria-label="Shared files"> + + + + - Weekly Report.pdf - + - Budget.xlsx - + - + + + + - Sunset.jpg - + diff --git a/packages/dev/s2-docs/pages/s2/ListView.mdx b/packages/dev/s2-docs/pages/s2/ListView.mdx index e28f1c36f2f..53afbb9bbcb 100644 --- a/packages/dev/s2-docs/pages/s2/ListView.mdx +++ b/packages/dev/s2-docs/pages/s2/ListView.mdx @@ -380,6 +380,45 @@ function NavigationExample() { } ``` +## Keyboard navigation + +By default, ListView 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 {ListView, ListViewItem, Text} from '@react-spectrum/s2/ListView'; +import {TextField} from '@react-spectrum/s2/TextField'; +import File from '@react-spectrum/s2/icons/File'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + +///- begin collapse -/// +let documents = [ + {id: 'project-brief', name: 'Project brief.pdf'}, + {id: 'quarterly-report', name: 'Quarterly report.docx'}, + {id: 'budget', name: 'Budget.xlsx'} +]; +///- end collapse -/// + + + {item => ( + + + + + + + )} + +``` + ## Drag and drop ListView 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. See the [drag and drop guide](dnd?component=ListView) to learn more. diff --git a/packages/dev/s2-docs/pages/s2/TreeView.mdx b/packages/dev/s2-docs/pages/s2/TreeView.mdx index 55b5f2a0a26..eb93e63c91a 100644 --- a/packages/dev/s2-docs/pages/s2/TreeView.mdx +++ b/packages/dev/s2-docs/pages/s2/TreeView.mdx @@ -386,6 +386,65 @@ function Example(props) { } ``` +## Keyboard navigation + +By default, TreeView 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 {Text} from '@react-spectrum/s2'; +import {TreeView, TreeViewItem, TreeViewItemContent} from '@react-spectrum/s2/TreeView'; +import {TextField} from '@react-spectrum/s2/TextField'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + ## Drag and drop TreeView supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the hook. Users can drop data on the tree as a whole, on individual items, insert new items between existing ones, or reorder items. React Spectrum supports drag and drop via mouse, touch, keyboard, and screen reader interactions. See the [drag and drop guide](dnd?component=TreeView) to learn more. diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js index 56e6ac0a251..df4906037d2 100644 --- a/packages/react-aria-components/test/Select.test.js +++ b/packages/react-aria-components/test/Select.test.js @@ -846,50 +846,6 @@ describe('Select', () => { expect(trigger).toHaveTextContent('2 selected items'); }); - it('supports shift+click to select a range in multi-selection', async () => { - let {getByTestId} = render(); - let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')}); - - await selectTester.open(); - let options = selectTester.getOptions(); - - await user.click(options[0]); - expect(options[0]).toHaveAttribute('aria-selected', 'true'); - - await user.keyboard('{Shift>}'); - await user.click(options[2]); - await user.keyboard('{/Shift}'); - - expect(options[0]).toHaveAttribute('aria-selected', 'true'); - expect(options[1]).toHaveAttribute('aria-selected', 'true'); - expect(options[2]).toHaveAttribute('aria-selected', 'true'); - }); - - it('keeps a stable anchor across consecutive shift+clicks', async () => { - let {getByTestId} = render(); - let selectTester = testUtilUser.createTester('Select', {root: getByTestId('select')}); - - await selectTester.open(); - let options = selectTester.getOptions(); - - await user.click(options[0]); - - await user.keyboard('{Shift>}'); - await user.click(options[2]); - expect(options[0]).toHaveAttribute('aria-selected', 'true'); - expect(options[1]).toHaveAttribute('aria-selected', 'true'); - expect(options[2]).toHaveAttribute('aria-selected', 'true'); - - // Shift+click again from the same anchor: the range shrinks rather than the - // anchor jumping to the previous target. - await user.click(options[1]); - await user.keyboard('{/Shift}'); - - expect(options[0]).toHaveAttribute('aria-selected', 'true'); - expect(options[1]).toHaveAttribute('aria-selected', 'true'); - expect(options[2]).toHaveAttribute('aria-selected', 'false'); - }); - it('has a value immediately after rendering', async () => { function Example() { const ref = useRef(null); diff --git a/packages/react-aria/src/selection/useTypeSelect.ts b/packages/react-aria/src/selection/useTypeSelect.ts index ad7e00d4304..a1ef7c36e9f 100644 --- a/packages/react-aria/src/selection/useTypeSelect.ts +++ b/packages/react-aria/src/selection/useTypeSelect.ts @@ -94,10 +94,6 @@ export function useTypeSelect(options: AriaTypeSelectOptions): TypeSelectAria { }; let onKeyDown = (e: KeyboardEvent) => { - if (e.altKey) { - return; - } - let character = getStringForKey(e.key); if ( !character || diff --git a/packages/react-stately/src/select/useSelectState.ts b/packages/react-stately/src/select/useSelectState.ts index adb6264a413..beb206ddc2c 100644 --- a/packages/react-stately/src/select/useSelectState.ts +++ b/packages/react-stately/src/select/useSelectState.ts @@ -29,7 +29,7 @@ import {FormValidationState, useFormValidationState} from '../form/useFormValida import {ListState, useListState} from '../list/useListState'; import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState'; import {useControlledState} from '../utils/useControlledState'; -import {useMemo, useRef, useState} from 'react'; +import {useMemo, useState} from 'react'; export type SelectionMode = 'single' | 'multiple'; export type ValueType = M extends 'single' ? Key | null : readonly Key[]; @@ -196,27 +196,12 @@ export function useSelectState( } }; - // Preserve the selection's anchor (anchorKey/currentKey) across renders. The - // multiple-selection `value` is a plain Key[], so without this the listbox - // would rebuild an anchorless Selection on every render and range selection - // (shift+click / shift+arrow) would collapse to just the clicked item. We keep - // the last Selection produced internally and feed it back while its membership - // still matches `value`. - let lastSelection = useRef | null>(null); - let listState = useListState({ ...props, selectionMode, disallowEmptySelection: selectionMode === 'single', allowDuplicateSelectionEvents: true, - selectedKeys: useMemo(() => { - let selectedKeys = convertValue(displayValue); - let last = lastSelection.current; - if (last != null && Array.isArray(selectedKeys) && isSameSelection(last, selectedKeys)) { - return last; - } - return selectedKeys; - }, [displayValue]), + selectedKeys: useMemo(() => convertValue(displayValue), [displayValue]), onSelectionChange: (keys: Selection) => { // impossible, but TS doesn't know that if (keys === 'all') { @@ -227,9 +212,6 @@ export function useSelectState( let key = keys.values().next().value ?? null; setValue(key); } else { - // Remember the Selection (with its anchor) so it survives the round-trip - // through the plain `value` array on the next render. - lastSelection.current = keys; setValue([...keys]); } if (shouldCloseOnSelect) { @@ -296,15 +278,3 @@ function convertValue(value: Key | Key[] | null | undefined) { } return Array.isArray(value) ? value : [value]; } - -function isSameSelection(selection: Set, keys: Key[]): boolean { - if (selection.size !== keys.length) { - return false; - } - for (let key of keys) { - if (!selection.has(key)) { - return false; - } - } - return true; -} diff --git a/starters/docs/src/GridList.css b/starters/docs/src/GridList.css index edea5a55d80..7fea30b5493 100644 --- a/starters/docs/src/GridList.css +++ b/starters/docs/src/GridList.css @@ -22,8 +22,11 @@ &[data-layout='grid']:not(:has(.react-aria-GridListSection)):not([data-empty]) { display: grid; - grid-template-columns: repeat(auto-fit, minmax(100px, var(--grid-item-size))); grid-auto-rows: min-content; + + &:not([data-orientation='horizontal']) { + grid-template-columns: repeat(auto-fit, minmax(100px, var(--grid-item-size))); + } } &[data-layout='grid'] > .react-aria-GridListSection { diff --git a/starters/docs/src/TextField.tsx b/starters/docs/src/TextField.tsx index 2eda0e4c13a..44680949b07 100644 --- a/starters/docs/src/TextField.tsx +++ b/starters/docs/src/TextField.tsx @@ -28,7 +28,7 @@ export function TextField({ }: TextFieldProps) { return ( - + {label && } {description && {description}} {errorMessage}