Skip to content

Commit a857ec7

Browse files
authored
Support grid layout and tab navigation in GridList (#6486)
1 parent b462b99 commit a857ec7

File tree

9 files changed

+216
-55
lines changed

9 files changed

+216
-55
lines changed

packages/@react-aria/gridlist/src/useGridList.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@ export interface GridListProps<T> extends CollectionBase<T>, MultipleSelection {
3838
disabledBehavior?: DisabledBehavior
3939
}
4040

41-
export interface AriaGridListProps<T> extends GridListProps<T>, DOMProps, AriaLabelingProps {}
41+
export interface AriaGridListProps<T> extends GridListProps<T>, DOMProps, AriaLabelingProps {
42+
/**
43+
* Whether keyboard navigation to focusable elements within grid list items is
44+
* via the left/right arrow keys or the tab key.
45+
* @default 'arrow'
46+
*/
47+
keyboardNavigationBehavior?: 'arrow' | 'tab'
48+
}
4249

4350
export interface AriaGridListOptions<T> extends Omit<AriaGridListProps<T>, 'children'> {
4451
/** Whether the list uses virtual scrolling. */
@@ -80,7 +87,8 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
8087
isVirtualized,
8188
keyboardDelegate,
8289
onAction,
83-
linkBehavior = 'action'
90+
linkBehavior = 'action',
91+
keyboardNavigationBehavior = 'arrow'
8492
} = props;
8593

8694
if (!props['aria-label'] && !props['aria-labelledby']) {
@@ -100,7 +108,7 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
100108
});
101109

102110
let id = useId(props.id);
103-
listMap.set(state, {id, onAction, linkBehavior});
111+
listMap.set(state, {id, onAction, linkBehavior, keyboardNavigationBehavior});
104112

105113
let descriptionProps = useHighlightSelectionDescription({
106114
selectionManager: state.selectionManager,

packages/@react-aria/gridlist/src/useGridListItem.ts

Lines changed: 57 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
6767

6868
// let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/gridlist');
6969
let {direction} = useLocale();
70-
let {onAction, linkBehavior} = listMap.get(state);
70+
let {onAction, linkBehavior, keyboardNavigationBehavior} = listMap.get(state);
7171
let descriptionId = useSlotId();
7272

7373
// We need to track the key of the item at the time it was last focused so that we force
@@ -139,56 +139,60 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
139139

140140
switch (e.key) {
141141
case 'ArrowLeft': {
142-
// Find the next focusable element within the row.
143-
let focusable = direction === 'rtl'
144-
? walker.nextNode() as FocusableElement
145-
: walker.previousNode() as FocusableElement;
142+
if (keyboardNavigationBehavior === 'arrow') {
143+
// Find the next focusable element within the row.
144+
let focusable = direction === 'rtl'
145+
? walker.nextNode() as FocusableElement
146+
: walker.previousNode() as FocusableElement;
146147

147-
if (focusable) {
148-
e.preventDefault();
149-
e.stopPropagation();
150-
focusSafely(focusable);
151-
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
152-
} else {
153-
// If there is no next focusable child, then return focus back to the row
154-
e.preventDefault();
155-
e.stopPropagation();
156-
if (direction === 'rtl') {
157-
focusSafely(ref.current);
158-
scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)});
148+
if (focusable) {
149+
e.preventDefault();
150+
e.stopPropagation();
151+
focusSafely(focusable);
152+
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
159153
} else {
160-
walker.currentNode = ref.current;
161-
let lastElement = last(walker);
162-
if (lastElement) {
163-
focusSafely(lastElement);
164-
scrollIntoViewport(lastElement, {containingElement: getScrollParent(ref.current)});
154+
// If there is no next focusable child, then return focus back to the row
155+
e.preventDefault();
156+
e.stopPropagation();
157+
if (direction === 'rtl') {
158+
focusSafely(ref.current);
159+
scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)});
160+
} else {
161+
walker.currentNode = ref.current;
162+
let lastElement = last(walker);
163+
if (lastElement) {
164+
focusSafely(lastElement);
165+
scrollIntoViewport(lastElement, {containingElement: getScrollParent(ref.current)});
166+
}
165167
}
166168
}
167169
}
168170
break;
169171
}
170172
case 'ArrowRight': {
171-
let focusable = direction === 'rtl'
172-
? walker.previousNode() as FocusableElement
173-
: walker.nextNode() as FocusableElement;
173+
if (keyboardNavigationBehavior === 'arrow') {
174+
let focusable = direction === 'rtl'
175+
? walker.previousNode() as FocusableElement
176+
: walker.nextNode() as FocusableElement;
174177

175-
if (focusable) {
176-
e.preventDefault();
177-
e.stopPropagation();
178-
focusSafely(focusable);
179-
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
180-
} else {
181-
e.preventDefault();
182-
e.stopPropagation();
183-
if (direction === 'ltr') {
184-
focusSafely(ref.current);
185-
scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)});
178+
if (focusable) {
179+
e.preventDefault();
180+
e.stopPropagation();
181+
focusSafely(focusable);
182+
scrollIntoViewport(focusable, {containingElement: getScrollParent(ref.current)});
186183
} else {
187-
walker.currentNode = ref.current;
188-
let lastElement = last(walker);
189-
if (lastElement) {
190-
focusSafely(lastElement);
191-
scrollIntoViewport(lastElement, {containingElement: getScrollParent(ref.current)});
184+
e.preventDefault();
185+
e.stopPropagation();
186+
if (direction === 'ltr') {
187+
focusSafely(ref.current);
188+
scrollIntoViewport(ref.current, {containingElement: getScrollParent(ref.current)});
189+
} else {
190+
walker.currentNode = ref.current;
191+
let lastElement = last(walker);
192+
if (lastElement) {
193+
focusSafely(lastElement);
194+
scrollIntoViewport(lastElement, {containingElement: getScrollParent(ref.current)});
195+
}
192196
}
193197
}
194198
}
@@ -207,6 +211,18 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
207211
);
208212
}
209213
break;
214+
case 'Tab': {
215+
if (keyboardNavigationBehavior === 'tab') {
216+
// If there is another focusable element within this item, stop propagation so the tab key
217+
// is handled by the browser and not by useSelectableCollection (which would take us out of the list).
218+
let walker = getFocusableTreeWalker(ref.current, {tabbable: true});
219+
walker.currentNode = document.activeElement;
220+
let next = e.shiftKey ? walker.previousNode() : walker.nextNode();
221+
if (next) {
222+
e.stopPropagation();
223+
}
224+
}
225+
}
210226
}
211227
};
212228

packages/@react-aria/gridlist/src/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import type {ListState} from '@react-stately/list';
1616
interface ListMapShared {
1717
id: string,
1818
onAction: (key: Key) => void,
19-
linkBehavior?: 'action' | 'selection' | 'override'
19+
linkBehavior?: 'action' | 'selection' | 'override',
20+
keyboardNavigationBehavior: 'arrow' | 'tab'
2021
}
2122

2223
// Used to share:

packages/@react-aria/selection/src/ListKeyboardDelegate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
4545
this.collator = opts.collator;
4646
this.disabledKeys = opts.disabledKeys || new Set();
4747
this.disabledBehavior = opts.disabledBehavior || 'all';
48-
this.orientation = opts.orientation;
48+
this.orientation = opts.orientation || 'vertical';
4949
this.direction = opts.direction;
5050
this.layout = opts.layout || 'stack';
5151
} else {

packages/@react-aria/tree/src/useTreeGridList.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {TreeState} from '@react-stately/tree';
2020

2121
export interface TreeGridListProps<T> extends GridListProps<T> {}
2222

23-
export interface AriaTreeGridListProps<T> extends AriaGridListProps<T> {}
23+
export interface AriaTreeGridListProps<T> extends Omit<AriaGridListProps<T>, 'keyboardNavigationBehavior'> {}
2424
export interface AriaTreeGridListOptions<T> extends Omit<AriaGridListOptions<T>, 'children' | 'isVirtualized' | 'shouldFocusWrap'> {
2525
/**
2626
* An optional keyboard delegate implementation for type to select,

packages/@react-spectrum/list/src/ListView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {useCollator, useLocalizedStringFormatter} from '@react-aria/i18n';
3333
import {useProvider} from '@react-spectrum/provider';
3434
import {Virtualizer} from '@react-aria/virtualizer';
3535

36-
export interface SpectrumListViewProps<T> extends AriaGridListProps<T>, StyleProps, SpectrumSelectionProps, Omit<AsyncLoadable, 'isLoading'> {
36+
export interface SpectrumListViewProps<T> extends Omit<AriaGridListProps<T>, 'keyboardNavigationBehavior'>, StyleProps, SpectrumSelectionProps, Omit<AsyncLoadable, 'isLoading'> {
3737
/**
3838
* Sets the amount of vertical padding within each cell.
3939
* @default 'regular'

packages/react-aria-components/src/GridList.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12-
import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useVisuallyHidden} from 'react-aria';
12+
import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria';
1313
import {ButtonContext} from './Button';
1414
import {CheckboxContext} from './RSPContexts';
1515
import {Collection, DraggableCollectionState, DroppableCollectionState, ListState, Node, SelectionBehavior, useListState} from 'react-stately';
@@ -19,7 +19,7 @@ import {DragAndDropContext, DragAndDropHooks, DropIndicator, DropIndicatorContex
1919
import {filterDOMProps, useObjectRef} from '@react-aria/utils';
2020
import {HoverEvents, Key, LinkDOMProps} from '@react-types/shared';
2121
import {ListStateContext} from './ListBox';
22-
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, RefObject, useContext, useEffect, useRef} from 'react';
22+
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, RefObject, useContext, useEffect, useMemo, useRef} from 'react';
2323
import {TextContext} from './Text';
2424

2525
export interface GridListRenderProps {
@@ -43,6 +43,11 @@ export interface GridListRenderProps {
4343
* @selector [data-drop-target]
4444
*/
4545
isDropTarget: boolean,
46+
/**
47+
* Whether the items are arranged in a stack or grid.
48+
* @selector [data-layout="stack | grid"]
49+
*/
50+
layout: 'stack' | 'grid',
4651
/**
4752
* State of the grid list.
4853
*/
@@ -55,7 +60,12 @@ export interface GridListProps<T> extends Omit<AriaGridListProps<T>, 'children'>
5560
/** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the GridList. */
5661
dragAndDropHooks?: DragAndDropHooks,
5762
/** Provides content to display when there are no items in the list. */
58-
renderEmptyState?: (props: GridListRenderProps) => ReactNode
63+
renderEmptyState?: (props: GridListRenderProps) => ReactNode,
64+
/**
65+
* Whether the items are arranged in a stack or grid.
66+
* @default 'stack'
67+
*/
68+
layout?: 'stack' | 'grid'
5969
}
6070

6171

@@ -80,14 +90,34 @@ interface GridListInnerProps<T extends object> {
8090
}
8191

8292
function GridListInner<T extends object>({props, collection, gridListRef: ref}: GridListInnerProps<T>) {
83-
let {dragAndDropHooks} = props;
93+
let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props;
8494
let state = useListState({
8595
...props,
8696
collection,
8797
children: undefined
8898
});
8999

90-
let {gridProps} = useGridList(props, state, ref);
100+
let collator = useCollator({usage: 'search', sensitivity: 'base'});
101+
let {disabledBehavior, disabledKeys} = state.selectionManager;
102+
let {direction} = useLocale();
103+
let keyboardDelegate = useMemo(() => (
104+
new ListKeyboardDelegate({
105+
collection,
106+
collator,
107+
ref,
108+
disabledKeys,
109+
disabledBehavior,
110+
layout,
111+
direction
112+
})
113+
), [collection, ref, layout, disabledKeys, disabledBehavior, collator, direction]);
114+
115+
let {gridProps} = useGridList({
116+
...props,
117+
keyboardDelegate,
118+
// Only tab navigation is supported in grid layout.
119+
keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior
120+
}, state, ref);
91121

92122
let selectionManager = state.selectionManager;
93123
let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState;
@@ -136,7 +166,7 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
136166
disabledBehavior: selectionManager.disabledBehavior,
137167
ref
138168
});
139-
let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref);
169+
let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref, {layout, direction});
140170
droppableCollection = dragAndDropHooks.useDroppableCollection!({
141171
keyboardDelegate,
142172
dropTargetDelegate
@@ -151,6 +181,7 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
151181
isEmpty: state.collection.size === 0,
152182
isFocused,
153183
isFocusVisible,
184+
layout,
154185
state
155186
};
156187
let renderProps = useRenderProps({
@@ -185,7 +216,8 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
185216
data-drop-target={isRootDropTarget || undefined}
186217
data-empty={state.collection.size === 0 || undefined}
187218
data-focused={isFocused || undefined}
188-
data-focus-visible={isFocusVisible || undefined}>
219+
data-focus-visible={isFocusVisible || undefined}
220+
data-layout={layout}>
189221
<Provider
190222
values={[
191223
[ListStateContext, state],
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2022 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {Button, GridList, GridListItem, GridListItemProps} from 'react-aria-components';
14+
import {classNames} from '@react-spectrum/utils';
15+
import React from 'react';
16+
import styles from '../example/index.css';
17+
18+
export default {
19+
title: 'React Aria Components'
20+
};
21+
22+
export const GridListExample = (args) => (
23+
<GridList
24+
{...args}
25+
className={styles.menu}
26+
aria-label="test gridlist"
27+
style={{
28+
width: 300,
29+
height: 300,
30+
display: 'grid',
31+
gridTemplate: args.layout === 'grid' ? 'repeat(3, 1fr) / repeat(3, 1fr)' : 'auto / 1fr',
32+
gridAutoFlow: 'row'
33+
}}>
34+
<MyGridListItem>1,1 <Button>Actions</Button></MyGridListItem>
35+
<MyGridListItem>1,2 <Button>Actions</Button></MyGridListItem>
36+
<MyGridListItem>1,3 <Button>Actions</Button></MyGridListItem>
37+
<MyGridListItem>2,1 <Button>Actions</Button></MyGridListItem>
38+
<MyGridListItem>2,2 <Button>Actions</Button></MyGridListItem>
39+
<MyGridListItem>2,3 <Button>Actions</Button></MyGridListItem>
40+
<MyGridListItem>3,1 <Button>Actions</Button></MyGridListItem>
41+
<MyGridListItem>3,2 <Button>Actions</Button></MyGridListItem>
42+
<MyGridListItem>3,3 <Button>Actions</Button></MyGridListItem>
43+
</GridList>
44+
);
45+
46+
const MyGridListItem = (props: GridListItemProps) => {
47+
return (
48+
<GridListItem
49+
{...props}
50+
style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}
51+
className={({isFocused, isSelected, isHovered}) => classNames(styles, 'item', {
52+
focused: isFocused,
53+
selected: isSelected,
54+
hovered: isHovered
55+
})} />
56+
);
57+
};
58+
59+
GridListExample.story = {
60+
args: {
61+
layout: 'stack'
62+
},
63+
argTypes: {
64+
layout: {
65+
control: 'radio',
66+
options: ['stack', 'grid']
67+
},
68+
keyboardNavigationBehavior: {
69+
control: 'radio',
70+
options: ['arrow', 'tab']
71+
}
72+
}
73+
};

0 commit comments

Comments
 (0)