Skip to content

Commit b39acc3

Browse files
feat: Add 'orientation' prop to GridList (adobe#9785)
* add orientation prop to GridList * add new tests for horizontal orientations * update storybook examples to account for horizontal orientation * Apply suggestion from @snowystinger --------- Co-authored-by: Robert Snow <snowystinger@gmail.com>
1 parent adcdec9 commit b39acc3

File tree

3 files changed

+185
-30
lines changed

3 files changed

+185
-30
lines changed

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,12 @@ export interface GridListProps<T> extends Omit<AriaGridListProps<T>, 'children'>
9696
* Whether the items are arranged in a stack or grid.
9797
* @default 'stack'
9898
*/
99-
layout?: 'stack' | 'grid'
99+
layout?: 'stack' | 'grid',
100+
/**
101+
* The primary orientation of the items. Usually this is the direction that the collection scrolls.
102+
* @default 'vertical'
103+
*/
104+
orientation?: 'horizontal' | 'vertical'
100105
}
101106

102107

@@ -127,7 +132,7 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
127132
[props, ref] = useContextProps(props, ref, SelectableCollectionContext);
128133
// eslint-disable-next-line @typescript-eslint/no-unused-vars
129134
let {shouldUseVirtualFocus, filter, disallowTypeAhead, ...DOMCollectionProps} = props;
130-
let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props;
135+
let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack', orientation = 'vertical'} = props;
131136
let {CollectionRoot, isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate} = useContext(CollectionRendererContext);
132137
let gridlistState = useListState({
133138
...DOMCollectionProps,
@@ -149,9 +154,10 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
149154
disabledBehavior,
150155
layoutDelegate,
151156
layout,
157+
orientation,
152158
direction
153159
})
154-
), [filteredState.collection, ref, layout, disabledKeys, disabledBehavior, layoutDelegate, collator, direction]);
160+
), [filteredState.collection, ref, layout, orientation, disabledKeys, disabledBehavior, layoutDelegate, collator, direction]);
155161

156162
let {gridProps} = useGridList({
157163
...DOMCollectionProps,
@@ -211,9 +217,11 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
211217
collection: filteredState.collection,
212218
disabledKeys: selectionManager.disabledKeys,
213219
disabledBehavior: selectionManager.disabledBehavior,
214-
ref
220+
ref,
221+
orientation,
222+
direction
215223
});
216-
let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref, {layout, direction});
224+
let dropTargetDelegate = dragAndDropHooks.dropTargetDelegate || ctxDropTargetDelegate || new dragAndDropHooks.ListDropTargetDelegate(collection, ref, {layout, direction, orientation});
217225
droppableCollection = dragAndDropHooks.useDroppableCollection!({
218226
keyboardDelegate,
219227
dropTargetDelegate

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

Lines changed: 86 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -59,29 +59,32 @@ export default {
5959

6060
export type GridListStory = StoryFn<typeof GridList>;
6161

62-
export const GridListExample: GridListStory = (args) => (
63-
<GridList
64-
{...args}
65-
className={styles.menu}
66-
aria-label="test gridlist"
67-
style={{
68-
width: 300,
69-
height: 300,
70-
display: 'grid',
71-
gridTemplate: args.layout === 'grid' ? 'repeat(3, 1fr) / repeat(3, 1fr)' : 'auto / 1fr',
72-
gridAutoFlow: 'row'
73-
}}>
74-
<MyGridListItem textValue="1,1">1,1 <Button>Actions</Button></MyGridListItem>
75-
<MyGridListItem textValue="1,2">1,2 <Button>Actions</Button></MyGridListItem>
76-
<MyGridListItem textValue="1,3">1,3 <Button>Actions</Button></MyGridListItem>
77-
<MyGridListItem textValue="2,1">2,1 <Button>Actions</Button></MyGridListItem>
78-
<MyGridListItem textValue="2,2">2,2 <Button>Actions</Button></MyGridListItem>
79-
<MyGridListItem textValue="2,3">2,3 <Button>Actions</Button></MyGridListItem>
80-
<MyGridListItem textValue="3,1">3,1 <Button>Actions</Button></MyGridListItem>
81-
<MyGridListItem textValue="3,2">3,2 <Button>Actions</Button></MyGridListItem>
82-
<MyGridListItem textValue="3,3">3,3 <Button>Actions</Button></MyGridListItem>
83-
</GridList>
84-
);
62+
export const GridListExample: GridListStory = (args) => {
63+
let isHorizontalStack = args.orientation === 'horizontal' && args.layout !== 'grid';
64+
return (
65+
<GridList
66+
{...args}
67+
className={styles.menu}
68+
aria-label="test gridlist"
69+
style={{
70+
width: isHorizontalStack ? undefined : 300,
71+
height: isHorizontalStack ? undefined : 300,
72+
display: isHorizontalStack ? 'flex' : 'grid',
73+
gridTemplate: args.layout === 'grid' ? 'repeat(3, 1fr) / repeat(3, 1fr)' : 'auto / 1fr',
74+
gridAutoFlow: args.orientation === 'horizontal' ? 'column' : 'row'
75+
}}>
76+
<MyGridListItem textValue="1,1">1,1 <Button>Actions</Button></MyGridListItem>
77+
<MyGridListItem textValue="1,2">1,2 <Button>Actions</Button></MyGridListItem>
78+
<MyGridListItem textValue="1,3">1,3 <Button>Actions</Button></MyGridListItem>
79+
<MyGridListItem textValue="2,1">2,1 <Button>Actions</Button></MyGridListItem>
80+
<MyGridListItem textValue="2,2">2,2 <Button>Actions</Button></MyGridListItem>
81+
<MyGridListItem textValue="2,3">2,3 <Button>Actions</Button></MyGridListItem>
82+
<MyGridListItem textValue="3,1">3,1 <Button>Actions</Button></MyGridListItem>
83+
<MyGridListItem textValue="3,2">3,2 <Button>Actions</Button></MyGridListItem>
84+
<MyGridListItem textValue="3,3">3,3 <Button>Actions</Button></MyGridListItem>
85+
</GridList>
86+
);
87+
};
8588

8689
export const MyGridListItem = (props: GridListItemProps) => {
8790
return (
@@ -105,6 +108,7 @@ export const MyGridListItem = (props: GridListItemProps) => {
105108
GridListExample.story = {
106109
args: {
107110
layout: 'stack',
111+
orientation: 'vertical',
108112
escapeKeyBehavior: 'clearSelection',
109113
shouldSelectOnPressUp: false,
110114
disallowTypeAhead: false
@@ -114,6 +118,10 @@ GridListExample.story = {
114118
control: 'radio',
115119
options: ['stack', 'grid']
116120
},
121+
orientation: {
122+
control: 'radio',
123+
options: ['vertical', 'horizontal']
124+
},
117125
keyboardNavigationBehavior: {
118126
control: 'radio',
119127
options: ['arrow', 'tab']
@@ -133,6 +141,61 @@ GridListExample.story = {
133141
}
134142
};
135143

144+
const DraggableGridListRender = (args: GridListProps<any>) => {
145+
let list = useListData({
146+
initialItems: [
147+
{id: '1', name: 'Item 1'},
148+
{id: '2', name: 'Item 2'},
149+
{id: '3', name: 'Item 3'},
150+
{id: '4', name: 'Item 4'},
151+
{id: '5', name: 'Item 5'}
152+
]
153+
});
154+
155+
let {dragAndDropHooks} = useDragAndDrop({
156+
getItems: (keys) => [...keys].map(key => ({'text/plain': list.getItem(key)?.name ?? ''})),
157+
onReorder(e) {
158+
if (e.target.dropPosition === 'before') {
159+
list.moveBefore(e.target.key, e.keys);
160+
} else if (e.target.dropPosition === 'after') {
161+
list.moveAfter(e.target.key, e.keys);
162+
}
163+
}
164+
});
165+
166+
let isHorizontal = args.orientation === 'horizontal';
167+
168+
return (
169+
<GridList
170+
className={styles.menu}
171+
aria-label="draggable gridlist"
172+
orientation={args.orientation}
173+
selectionMode="multiple"
174+
dragAndDropHooks={dragAndDropHooks}
175+
items={list.items}
176+
style={{
177+
display: 'flex',
178+
flexDirection: isHorizontal ? 'row' : 'column',
179+
width: isHorizontal ? undefined : 300
180+
}}>
181+
{item => <MyGridListItem>{item.name}</MyGridListItem>}
182+
</GridList>
183+
);
184+
};
185+
186+
export const DraggableGridListExample: StoryObj<typeof DraggableGridListRender> = {
187+
render: (args) => <DraggableGridListRender {...args} />,
188+
args: {
189+
orientation: 'vertical'
190+
},
191+
argTypes: {
192+
orientation: {
193+
control: 'radio',
194+
options: ['vertical', 'horizontal']
195+
}
196+
}
197+
};
198+
136199
const MyCheckbox = ({children, ...props}: CheckboxProps) => {
137200
return (
138201
<Checkbox {...props}>

packages/react-aria-components/test/GridList.test.js

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,14 @@ let TestGridListSections = ({listBoxProps, itemProps}) => (
6464
);
6565

6666

67-
let DraggableGridList = (props) => {
67+
let DraggableGridList = ({orientation, ...props}) => {
6868
let {dragAndDropHooks} = useDragAndDrop({
6969
getItems: (keys) => [...keys].map((key) => ({'text/plain': key})),
7070
...props
7171
});
7272

7373
return (
74-
<GridList aria-label="Test" dragAndDropHooks={dragAndDropHooks}>
74+
<GridList aria-label="Test" orientation={orientation} dragAndDropHooks={dragAndDropHooks}>
7575
<GridListItem id="cat" textValue="Cat"><Button slot="drag"></Button><Checkbox slot="selection" /> Cat</GridListItem>
7676
<GridListItem id="dog" textValue="Dog"><Button slot="drag"></Button><Checkbox slot="selection" /> Dog</GridListItem>
7777
<GridListItem id="kangaroo" textValue="Kangaroo"><Button slot="drag"></Button><Checkbox slot="selection" /> Kangaroo</GridListItem>
@@ -427,6 +427,64 @@ describe('GridList', () => {
427427
expect(document.activeElement).toBe(document.body);
428428
});
429429

430+
it('should support horizontal orientation', async () => {
431+
let {getAllByRole} = render(
432+
<GridList aria-label="Test" orientation="horizontal">
433+
<GridListItem id="cat">Cat</GridListItem>
434+
<GridListItem id="dog">Dog</GridListItem>
435+
<GridListItem id="kangaroo">Kangaroo</GridListItem>
436+
</GridList>
437+
);
438+
439+
let items = getAllByRole('row');
440+
441+
await user.tab();
442+
expect(document.activeElement).toBe(items[0]);
443+
444+
await user.keyboard('{ArrowDown}');
445+
expect(document.activeElement).toBe(items[1]);
446+
447+
await user.keyboard('{ArrowDown}');
448+
expect(document.activeElement).toBe(items[2]);
449+
450+
await user.keyboard('{ArrowUp}');
451+
expect(document.activeElement).toBe(items[1]);
452+
453+
await user.keyboard('{ArrowUp}');
454+
expect(document.activeElement).toBe(items[0]);
455+
});
456+
457+
it('should support horizontal orientation with grid layout', async () => {
458+
let buttonRef = React.createRef();
459+
let {getAllByRole} = render(
460+
<GridList aria-label="Test" orientation="horizontal" layout="grid">
461+
<GridListItem id="cat">Cat</GridListItem>
462+
<GridListItem id="dog" textValue="Dog">Dog <Button aria-label="Info" ref={buttonRef}></Button></GridListItem>
463+
<GridListItem id="kangaroo">Kangaroo</GridListItem>
464+
</GridList>
465+
);
466+
467+
let items = getAllByRole('row');
468+
469+
await user.tab();
470+
expect(document.activeElement).toBe(items[0]);
471+
472+
await user.keyboard('{ArrowDown}');
473+
expect(document.activeElement).toBe(items[1]);
474+
475+
await user.keyboard('{ArrowDown}');
476+
expect(document.activeElement).toBe(items[2]);
477+
478+
await user.keyboard('{ArrowUp}');
479+
expect(document.activeElement).toBe(items[1]);
480+
481+
await user.tab();
482+
expect(document.activeElement).toBe(buttonRef.current);
483+
484+
await user.tab();
485+
expect(document.activeElement).toBe(document.body);
486+
});
487+
430488
it('should support selectionMode="replace" with checkboxes', async () => {
431489
let {getAllByRole} = renderGridList({selectionMode: 'multiple', selectionBehavior: 'replace'});
432490
let items = getAllByRole('row');
@@ -996,6 +1054,32 @@ describe('GridList', () => {
9961054

9971055
expect(onRootDrop).toHaveBeenCalledTimes(1);
9981056
});
1057+
1058+
it('should support dropping with horizontal arrow keys when orientation is horizontal', async () => {
1059+
let onReorder = jest.fn();
1060+
let {getAllByRole} = render(<DraggableGridList orientation="horizontal" onReorder={onReorder} renderDropIndicator={(target) => <DropIndicator target={target}>Test</DropIndicator>} />);
1061+
1062+
await user.tab();
1063+
await user.keyboard('{ArrowRight}');
1064+
await user.keyboard('{Enter}');
1065+
act(() => jest.runAllTimers());
1066+
1067+
let rows = getAllByRole('row');
1068+
expect(rows[2]).toHaveAttribute('data-drop-target');
1069+
expect(within(rows[2]).getByRole('button')).toHaveAttribute('aria-label', 'Insert between Cat and Dog');
1070+
1071+
await user.keyboard('{ArrowRight}');
1072+
expect(rows[3]).toHaveAttribute('data-drop-target', 'true');
1073+
expect(document.activeElement).toHaveAttribute('aria-label', 'Insert between Dog and Kangaroo');
1074+
1075+
await user.keyboard('{ArrowLeft}');
1076+
expect(rows[2]).toHaveAttribute('data-drop-target', 'true');
1077+
1078+
await user.keyboard('{Enter}');
1079+
act(() => jest.runAllTimers());
1080+
1081+
expect(onReorder).toHaveBeenCalledTimes(1);
1082+
});
9991083
});
10001084

10011085
describe('links', function () {

0 commit comments

Comments
 (0)