Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support grid layout and tab navigation in GridList #6486

Merged
merged 5 commits into from
Jun 8, 2024
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
14 changes: 11 additions & 3 deletions packages/@react-aria/gridlist/src/useGridList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ export interface GridListProps<T> extends CollectionBase<T>, MultipleSelection {
disabledBehavior?: DisabledBehavior
}

export interface AriaGridListProps<T> extends GridListProps<T>, DOMProps, AriaLabelingProps {}
export interface AriaGridListProps<T> extends GridListProps<T>, DOMProps, AriaLabelingProps {
/**
* Whether keyboard navigation to focusable elements within grid list items is
* via the left/right arrow keys or the tab key.
* @default 'arrow'
*/
keyboardNavigationBehavior?: 'arrow' | 'tab'
}

export interface AriaGridListOptions<T> extends Omit<AriaGridListProps<T>, 'children'> {
/** Whether the list uses virtual scrolling. */
Expand Down Expand Up @@ -80,7 +87,8 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
isVirtualized,
keyboardDelegate,
onAction,
linkBehavior = 'action'
linkBehavior = 'action',
keyboardNavigationBehavior = 'arrow'
} = props;

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

let id = useId(props.id);
listMap.set(state, {id, onAction, linkBehavior});
listMap.set(state, {id, onAction, linkBehavior, keyboardNavigationBehavior});

let descriptionProps = useHighlightSelectionDescription({
selectionManager: state.selectionManager,
Expand Down
18 changes: 17 additions & 1 deletion packages/@react-aria/gridlist/src/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt

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

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

switch (e.key) {
case 'ArrowLeft': {
if (keyboardNavigationBehavior === 'arrow') {
// Find the next focusable element within the row.
let focusable = direction === 'rtl'
? walker.nextNode() as FocusableElement
Expand All @@ -165,9 +166,11 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
}
}
}
}
break;
}
case 'ArrowRight': {
if (keyboardNavigationBehavior === 'arrow') {
let focusable = direction === 'rtl'
? walker.previousNode() as FocusableElement
: walker.nextNode() as FocusableElement;
Expand All @@ -192,6 +195,7 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
}
}
}
}
break;
}
case 'ArrowUp':
Expand All @@ -207,6 +211,18 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
);
}
break;
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 = document.activeElement;
let next = e.shiftKey ? walker.previousNode() : walker.nextNode();
if (next) {
e.stopPropagation();
}
}
}
}
};

Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/gridlist/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import type {ListState} from '@react-stately/list';
interface ListMapShared {
id: string,
onAction: (key: Key) => void,
linkBehavior?: 'action' | 'selection' | 'override'
linkBehavior?: 'action' | 'selection' | 'override',
keyboardNavigationBehavior: 'arrow' | 'tab'
}

// Used to share:
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/selection/src/ListKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
this.collator = opts.collator;
this.disabledKeys = opts.disabledKeys || new Set();
this.disabledBehavior = opts.disabledBehavior || 'all';
this.orientation = opts.orientation;
this.orientation = opts.orientation || 'vertical';
this.direction = opts.direction;
this.layout = opts.layout || 'stack';
} else {
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/tree/src/useTreeGridList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {TreeState} from '@react-stately/tree';

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

export interface AriaTreeGridListProps<T> extends AriaGridListProps<T> {}
export interface AriaTreeGridListProps<T> extends Omit<AriaGridListProps<T>, 'keyboardNavigationBehavior'> {}
export interface AriaTreeGridListOptions<T> extends Omit<AriaGridListOptions<T>, 'children' | 'isVirtualized' | 'shouldFocusWrap'> {
/**
* An optional keyboard delegate implementation for type to select,
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {useCollator, useLocalizedStringFormatter} from '@react-aria/i18n';
import {useProvider} from '@react-spectrum/provider';
import {Virtualizer} from '@react-aria/virtualizer';

export interface SpectrumListViewProps<T> extends AriaGridListProps<T>, StyleProps, SpectrumSelectionProps, Omit<AsyncLoadable, 'isLoading'> {
export interface SpectrumListViewProps<T> extends Omit<AriaGridListProps<T>, 'keyboardNavigationBehavior'>, StyleProps, SpectrumSelectionProps, Omit<AsyncLoadable, 'isLoading'> {
/**
* Sets the amount of vertical padding within each cell.
* @default 'regular'
Expand Down
46 changes: 39 additions & 7 deletions packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useVisuallyHidden} from 'react-aria';
import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria';
import {ButtonContext} from './Button';
import {CheckboxContext} from './RSPContexts';
import {Collection, DraggableCollectionState, DroppableCollectionState, ListState, Node, SelectionBehavior, useListState} from 'react-stately';
Expand All @@ -19,7 +19,7 @@ import {DragAndDropContext, DragAndDropHooks, DropIndicator, DropIndicatorContex
import {filterDOMProps, useObjectRef} from '@react-aria/utils';
import {HoverEvents, Key, LinkDOMProps} from '@react-types/shared';
import {ListStateContext} from './ListBox';
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, RefObject, useContext, useEffect, useRef} from 'react';
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, RefObject, useContext, useEffect, useMemo, useRef} from 'react';
import {TextContext} from './Text';

export interface GridListRenderProps {
Expand All @@ -43,6 +43,11 @@ export interface GridListRenderProps {
* @selector [data-drop-target]
*/
isDropTarget: boolean,
/**
* Whether the items are arranged in a stack or grid.
* @selector [data-layout="stack | grid"]
*/
layout: 'stack' | 'grid',
/**
* State of the grid list.
*/
Expand All @@ -55,7 +60,12 @@ export interface GridListProps<T> extends Omit<AriaGridListProps<T>, 'children'>
/** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the GridList. */
dragAndDropHooks?: DragAndDropHooks,
/** Provides content to display when there are no items in the list. */
renderEmptyState?: (props: GridListRenderProps) => ReactNode
renderEmptyState?: (props: GridListRenderProps) => ReactNode,
/**
* Whether the items are arranged in a stack or grid.
* @default 'stack'
*/
layout?: 'stack' | 'grid'
}


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

function GridListInner<T extends object>({props, collection, gridListRef: ref}: GridListInnerProps<T>) {
let {dragAndDropHooks} = props;
let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props;
let state = useListState({
...props,
collection,
children: undefined
});

let {gridProps} = useGridList(props, state, ref);
let collator = useCollator({usage: 'search', sensitivity: 'base'});
let {disabledBehavior, disabledKeys} = state.selectionManager;
let {direction} = useLocale();
let keyboardDelegate = useMemo(() => (
new ListKeyboardDelegate({
collection,
collator,
ref,
disabledKeys,
disabledBehavior,
layout,
direction
})
), [collection, ref, layout, disabledKeys, disabledBehavior, collator, direction]);

let {gridProps} = useGridList({
...props,
keyboardDelegate,
// Only tab navigation is supported in grid layout.
keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior
Copy link

@tryggvigy tryggvigy Nov 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wondering if 'arrow' should be allowed in grid layouts so for example https://www.w3.org/WAI/ARIA/apg/patterns/grid/examples/layout-grids/#ex2_label can be implemented.

Having the ability to implement the following behaviour for arrow key navigations in grid layout lists regardless of orientation (vertical or horizontal), since layout is only relevant for sighted users, seems useful:

  • left/right: navigate to next focusable element, either within the item, or in the next/prev item.
  • up/down: Move to prev/next item and attempt to land on the corresponding element.

}, state, ref);

let selectionManager = state.selectionManager;
let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState;
Expand Down Expand Up @@ -136,7 +166,7 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
disabledBehavior: selectionManager.disabledBehavior,
ref
});
let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref);
let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref, {layout, direction});
droppableCollection = dragAndDropHooks.useDroppableCollection!({
keyboardDelegate,
dropTargetDelegate
Expand All @@ -151,6 +181,7 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
isEmpty: state.collection.size === 0,
isFocused,
isFocusVisible,
layout,
state
};
let renderProps = useRenderProps({
Expand Down Expand Up @@ -185,7 +216,8 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
data-drop-target={isRootDropTarget || undefined}
data-empty={state.collection.size === 0 || undefined}
data-focused={isFocused || undefined}
data-focus-visible={isFocusVisible || undefined}>
data-focus-visible={isFocusVisible || undefined}
data-layout={layout}>
<Provider
values={[
[ListStateContext, state],
Expand Down
73 changes: 73 additions & 0 deletions packages/react-aria-components/stories/GridList.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2022 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {Button, GridList, GridListItem, GridListItemProps} from 'react-aria-components';
import {classNames} from '@react-spectrum/utils';
import React from 'react';
import styles from '../example/index.css';

export default {
title: 'React Aria Components'
};

export const GridListExample = (args) => (
<GridList
{...args}
className={styles.menu}
aria-label="test gridlist"
style={{
width: 300,
height: 300,
display: 'grid',
gridTemplate: args.layout === 'grid' ? 'repeat(3, 1fr) / repeat(3, 1fr)' : 'auto / 1fr',
gridAutoFlow: 'row'
}}>
<MyGridListItem>1,1 <Button>Actions</Button></MyGridListItem>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do items know what is their index in the data source?
do items know what is their position in the visible viewport/row?

<MyGridListItem>1,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>1,3 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>2,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>2,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>2,3 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>3,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>3,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>3,3 <Button>Actions</Button></MyGridListItem>
</GridList>
);

const MyGridListItem = (props: GridListItemProps) => {
return (
<GridListItem
{...props}
style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}
className={({isFocused, isSelected, isHovered}) => classNames(styles, 'item', {
focused: isFocused,
selected: isSelected,
hovered: isHovered
})} />
);
};

GridListExample.story = {
args: {
layout: 'stack'
},
argTypes: {
layout: {
control: 'radio',
options: ['stack', 'grid']
},
keyboardNavigationBehavior: {
control: 'radio',
options: ['arrow', 'tab']
}
}
};
31 changes: 31 additions & 0 deletions packages/react-aria-components/test/GridList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,37 @@ describe('GridList', () => {
expect(onScroll).toHaveBeenCalled();
});

it('should support grid layout', async () => {
let buttonRef = React.createRef();
let {getAllByRole} = render(
<GridList aria-label="Test" layout="grid">
<GridListItem id="cat">Cat</GridListItem>
<GridListItem id="dog" textValue="Dog">Dog <Button aria-label="Info" ref={buttonRef}>ⓘ</Button></GridListItem>
<GridListItem id="kangaroo">Kangaroo</GridListItem>
</GridList>
);

let items = getAllByRole('row');

await user.tab();
expect(document.activeElement).toBe(items[0]);

await user.keyboard('{ArrowRight}');
expect(document.activeElement).toBe(items[1]);

await user.keyboard('{ArrowRight}');
expect(document.activeElement).toBe(items[2]);

await user.keyboard('{ArrowLeft}');
expect(document.activeElement).toBe(items[1]);

await user.tab();
expect(document.activeElement).toBe(buttonRef.current);

await user.tab();
expect(document.activeElement).toBe(document.body);
});

describe('drag and drop', () => {
it('should support drag button slot', () => {
let {getAllByRole} = render(<DraggableGridList />);
Expand Down