Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/@react-spectrum/s2/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@ export interface ListViewProps<T>
| 'selectionBehavior'
| 'layout'
| 'render'
| 'keyboardNavigationBehavior'
| 'orientation'
| keyof GlobalDOMAttributes
>,
Expand Down
1 change: 0 additions & 1 deletion packages/@react-spectrum/s2/src/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ export interface TreeViewProps<T>
| 'selectionBehavior'
| 'onScroll'
| 'onCellAction'
| 'keyboardNavigationBehavior'
| keyof GlobalDOMAttributes
>,
UnsafeStyles,
Expand Down
34 changes: 0 additions & 34 deletions packages/@react-spectrum/s2/test/Picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Picker label="Test picker" selectionMode="multiple" items={items}>
{(item: any) => (
<PickerItem id={item.id} textValue={item.name}>
{item.name}
</PickerItem>
)}
</Picker>
);

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;
Expand Down
18 changes: 4 additions & 14 deletions packages/dev/s2-docs/pages/react-aria/GridList.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand All @@ -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 (
<ComboBox style={{paddingInlineStart: 12, width: '80%'}} aria-label={label} defaultSelectedKey="view" placeholder="Permission">
<ComboBoxItem id="view">Can view</ComboBoxItem>
<ComboBoxItem id="comment">Can comment</ComboBoxItem>
<ComboBoxItem id="edit">Can edit</ComboBoxItem>
</ComboBox>
);
}
///- end collapse -///

<GridList
Expand All @@ -746,9 +735,10 @@ function PermissionPicker({label}) {
{item => (
<GridListItem textValue={item.title}>
<img src={item.src} alt="" />
<Text>{item.title}</Text>
<Text>
<TextField style={{paddingTop: 2, paddingBottom: 2}} aria-label="title" defaultValue={item.title} />
</Text>
<Text slot="description">{item.description}</Text>
<PermissionPicker label={`${item.title} permission`} />
</GridListItem>
)}
</GridList>
Expand Down
36 changes: 13 additions & 23 deletions packages/dev/s2-docs/pages/react-aria/Tree.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -330,47 +330,37 @@ 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 (
<ComboBox style={{marginInlineStart: 'auto', flexShrink: 0}} aria-label={label} defaultSelectedKey="view" placeholder="Permission">
<ComboBoxItem id="view">Can view</ComboBoxItem>
<ComboBoxItem id="comment">Can comment</ComboBoxItem>
<ComboBoxItem id="edit">Can edit</ComboBoxItem>
</ComboBox>
);
}
///- end collapse -///
import {TextField} from 'vanilla-starter/TextField';

<Tree
/*- begin highlight -*/
keyboardNavigationBehavior="tab"
/*- end highlight -*/
selectionMode="multiple"
defaultExpandedKeys={['documents', 'photos']}
aria-label="Shared files"
style={{width: 420}}>
<TreeItem id="documents" title="Documents">
aria-label="Shared files">
<TreeItem id="documents" textValue="Documents">
<TreeItemContent>
<TextField aria-label="title" defaultValue="Documents" />
</TreeItemContent>
<TreeItem id="weekly" textValue="Weekly Report.pdf">
<TreeItemContent>
Weekly Report.pdf
<PermissionPicker label="Weekly Report.pdf permission" />
<TextField aria-label="title" defaultValue="Weekly Report.pdf" />
</TreeItemContent>
</TreeItem>
<TreeItem id="budget" textValue="Budget.xlsx">
<TreeItemContent>
Budget.xlsx
<PermissionPicker label="Budget.xlsx permission" />
<TextField aria-label="title" defaultValue="Budget.xlsx" />
</TreeItemContent>
</TreeItem>
</TreeItem>
<TreeItem id="photos" title="Photos">
<TreeItem id="photos">
<TreeItemContent>
<TextField aria-label="title" defaultValue="Photos" />
</TreeItemContent>
<TreeItem id="sunset" textValue="Sunset.jpg">
<TreeItemContent>
Sunset.jpg
<PermissionPicker label="Sunset.jpg permission" />
<TextField aria-label="title" defaultValue="Sunset.jpg" />
</TreeItemContent>
</TreeItem>
</TreeItem>
Expand Down
39 changes: 39 additions & 0 deletions packages/dev/s2-docs/pages/s2/ListView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Keyboard>Tab</Keyboard> 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 -///

<ListView
/*- begin highlight -*/
keyboardNavigationBehavior="tab"
/*- end highlight -*/
items={documents}
selectionMode="multiple"
aria-label="Shared files"
styles={style({width: 'full', maxWidth: 400, height: 320})}>
{item => (
<ListViewItem textValue={item.name}>
<File />
<Text>
<TextField styles={style({margin: 4})} aria-label="title" defaultValue={item.name} />
</Text>
</ListViewItem>
)}
</ListView>
```

## Drag and drop

ListView supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the <TypeLink links={docs.links} type={docs.exports.useDragAndDrop} /> 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.
Expand Down
59 changes: 59 additions & 0 deletions packages/dev/s2-docs/pages/s2/TreeView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Keyboard>Tab</Keyboard> 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'};


<TreeView
/*- begin highlight -*/
keyboardNavigationBehavior="tab"
/*- end highlight -*/
selectionMode="multiple"
defaultExpandedKeys={['documents', 'photos']}
aria-label="Shared files"
styles={style({height: 250, width: 'full', maxWidth: 400})}>
<TreeViewItem id="documents" textValue="Documents">
<TreeViewItemContent>
<Text>
<TextField styles={style({margin: 4})} aria-label="title" defaultValue="Documents" />
</Text>
</TreeViewItemContent>
<TreeViewItem id="weekly" textValue="Weekly Report.pdf">
<TreeViewItemContent>
<Text>
<TextField styles={style({margin: 4})} aria-label="title" defaultValue="Weekly Report.pdf" />
</Text>
</TreeViewItemContent>
</TreeViewItem>
<TreeViewItem id="budget" textValue="Budget.xlsx">
<TreeViewItemContent>
<Text>
<TextField styles={style({margin: 4})} aria-label="title" defaultValue="Budget.xlsx" />
</Text>
</TreeViewItemContent>
</TreeViewItem>
</TreeViewItem>
<TreeViewItem id="photos" textValue="Photos">
<TreeViewItemContent>
<Text>
<TextField styles={style({margin: 4})} aria-label="title" defaultValue="Photos" />
</Text>
</TreeViewItemContent>
<TreeViewItem id="sunset" textValue="Sunset.jpg">
<TreeViewItemContent>
<Text>
<TextField styles={style({margin: 4})} aria-label="title" defaultValue="Sunset.jpg" />
</Text>
</TreeViewItemContent>
</TreeViewItem>
</TreeViewItem>
</TreeView>
```

## Drag and drop

TreeView supports drag and drop interactions when the `dragAndDropHooks` prop is provided using the <TypeLink links={docs.links} type={docs.exports.useDragAndDrop} /> 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.
Expand Down
44 changes: 0 additions & 44 deletions packages/react-aria-components/test/Select.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TestSelect selectionMode="multiple" />);
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(<TestSelect selectionMode="multiple" />);
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);
Expand Down
4 changes: 0 additions & 4 deletions packages/react-aria/src/selection/useTypeSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
34 changes: 2 additions & 32 deletions packages/react-stately/src/select/useSelectState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 SelectionMode> = M extends 'single' ? Key | null : readonly Key[];
Expand Down Expand Up @@ -196,27 +196,12 @@ export function useSelectState<T, M extends SelectionMode = 'single'>(
}
};

// 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<Set<Key> | 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') {
Expand All @@ -227,9 +212,6 @@ export function useSelectState<T, M extends SelectionMode = 'single'>(
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) {
Expand Down Expand Up @@ -296,15 +278,3 @@ function convertValue(value: Key | Key[] | null | undefined) {
}
return Array.isArray(value) ? value : [value];
}

function isSameSelection(selection: Set<Key>, keys: Key[]): boolean {
if (selection.size !== keys.length) {
return false;
}
for (let key of keys) {
if (!selection.has(key)) {
return false;
}
}
return true;
}
Loading
Loading