Skip to content

feat: support sections and headers in RAC gridlist #8667

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

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
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: 1 addition & 0 deletions packages/@react-aria/gridlist/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
export {useGridList} from './useGridList';
export {useGridListItem} from './useGridListItem';
export {useGridListSelectionCheckbox} from './useGridListSelectionCheckbox';
export {useGridListSection} from './useGridListSection';

export type {AriaGridListOptions, AriaGridListProps, GridListAria, GridListProps} from './useGridList';
export type {AriaGridListItemOptions, GridListItemAria} from './useGridListItem';
Expand Down
5 changes: 4 additions & 1 deletion packages/@react-aria/gridlist/src/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,10 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
});

if (isVirtualized) {
rowProps['aria-rowindex'] = node.index + 1;
let {collection} = state;
let nodes = [...collection];
// TODO: refactor ListCollection to store an absolute index of a node's position?
rowProps['aria-rowindex'] = nodes.find(node => node.type === 'section') ? [...collection.getKeys()].filter((key) => collection.getItem(key)?.type !== 'section').findIndex((key) => key === node.key) + 1 : node.index + 1;
Copy link
Member Author

Choose a reason for hiding this comment

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

i did end up changing how we calculate rowIndex for rows inside Sections since Safari VO would read out the rows incorrectly otherwise. for example, say you were on the first item in Section 3, instead of saying something like "row 8 of 12" it would say "row 1 of 12"

}

let gridCellProps = {
Expand Down
60 changes: 60 additions & 0 deletions packages/@react-aria/gridlist/src/useGridListSection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2020 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 {DOMAttributes, RefObject} from '@react-types/shared';
import type {ListState} from '@react-stately/list';
import {useLabels, useSlotId} from '@react-aria/utils';

export interface AriaGridListSectionProps {
/** An accessibility label for the section. Required if `heading` is not present. */
'aria-label'?: string
}

export interface GridListSectionAria {
/** Props for the wrapper list item. */
rowProps: DOMAttributes,

/** Props for the heading element, if any. */
rowHeaderProps: DOMAttributes,

/** Props for the grid's row group element. */
rowGroupProps: DOMAttributes
}

/**
* Provides the behavior and accessibility implementation for a section in a grid list.
* See `useGridList` for more details about grid list.
* @param props - Props for the section.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useGridListSection<T>(props: AriaGridListSectionProps, state: ListState<T>, ref: RefObject<HTMLElement | null>): GridListSectionAria {
let {'aria-label': ariaLabel} = props;
let headingId = useSlotId();
let labelProps = useLabels({
'aria-label': ariaLabel,
'aria-labelledby': headingId
});

return {
rowProps: {
Copy link
Member Author

Choose a reason for hiding this comment

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

so ideally rowProps would have aria-rowindex but it's difficult to determine what the correct index is for the header. especially in the case where a gridlist has sections that both contain headers and don't container headers. basically, we rely on knowing the index of the previous section to determine what the index of the header should be. at the same time, we don't actually want to include sections in our calculations because they don't have the rowindex value which is what makes this tricky.

it'd be easy to determine if we calculated it inside GridListHeader but that wouldn't match any of our existing API and would sort of defeat the purpose of having this hook supply this information...

role: 'row'
},
rowHeaderProps: {
id: headingId,
role: 'rowheader'
},
rowGroupProps: {
role: 'rowgroup',
...labelProps
}
};
}
62 changes: 59 additions & 3 deletions packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
* 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, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria';
import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSection, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria';
import {ButtonContext} from './Button';
import {CheckboxContext} from './RSPContexts';
import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections';
import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection';
import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections';
import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, SectionProps} from './Collection';
import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} from './utils';
import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop';
import {DragAndDropHooks} from './useDragAndDrop';
import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately';
import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils';
import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared';
import {HeaderContext} from './Header';
import {ListStateContext} from './ListBox';
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
import {TextContext} from './Text';
Expand Down Expand Up @@ -561,3 +562,58 @@ export const GridListLoadMoreItem = createLeafComponent('loader', function GridL
</>
);
});

export interface GridListSectionProps<T> extends SectionProps<T> {}

/**
* A GridListSection represents a section within a GridList.
*/
export const GridListSection = /*#__PURE__*/ createBranchComponent('section', <T extends object>(props: GridListSectionProps<T>, ref: ForwardedRef<HTMLElement>, item: Node<T>) => {
let state = useContext(ListStateContext)!;
let {CollectionBranch} = useContext(CollectionRendererContext);
let headingRef = useRef(null);
ref = useObjectRef<HTMLElement>(ref);
let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({
'aria-label': props['aria-label'] ?? undefined
}, state, ref);
let renderProps = useRenderProps({
defaultClassName: 'react-aria-GridListSection',
className: props.className,
style: props.style,
values: {}
});

let DOMProps = filterDOMProps(props as any, {global: true});
delete DOMProps.id;

return (
<section
{...mergeProps(DOMProps, renderProps, rowGroupProps)}
ref={ref}>
<Provider
values={[
[HeaderContext, {...rowProps, ref: headingRef}],
[GridListHeaderContext, {...rowHeaderProps}]
]}>
<CollectionBranch
collection={state.collection}
parent={item} />
</Provider>
</section>
);
});

const GridListHeaderContext = createContext<HTMLAttributes<HTMLElement> | null>(null);

export const GridListHeader = /*#__PURE__*/ createLeafComponent('header', function Header(props: HTMLAttributes<HTMLElement>, ref: ForwardedRef<HTMLElement>) {
Copy link
Member Author

Choose a reason for hiding this comment

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

with ListBox, you can just use a normal Header, but with GridList, in order to follow correct aria-pattern, the header must be inside a div with a role=row, hence why i've created a new component called GridListHeader. are we okay with that?

from WAI-ARIA:

Each cell is either a DOM descendant of or owned by a row element and has one of the following roles:

  • columnheader if the cell contains a title or header information for the column.
  • rowheader if the cell contains title or header information for the row.
  • gridcell if the cell does not contain column or row header information.

Copy link
Member

Choose a reason for hiding this comment

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

I think this is ok, but the row itself should get aria-attributes like aria-rowindex when virtualized if so.

[props, ref] = useContextProps(props, ref, HeaderContext);
let rowHeaderProps = useContext(GridListHeaderContext);

return (
<header {...props} ref={ref}>
<div {...rowHeaderProps} style={{display: 'contents'}}>
{props.children}
</div>
</header>
);
});
2 changes: 1 addition & 1 deletion packages/react-aria-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export {DropZone, DropZoneContext} from './DropZone';
export {FieldError, FieldErrorContext} from './FieldError';
export {FileTrigger} from './FileTrigger';
export {Form, FormContext} from './Form';
export {GridListLoadMoreItem, GridList, GridListItem, GridListContext} from './GridList';
export {GridListLoadMoreItem, GridList, GridListItem, GridListContext, GridListHeader, GridListSection} from './GridList';
export {Group, GroupContext} from './Group';
export {Header, HeaderContext} from './Header';
export {Heading} from './Heading';
Expand Down
101 changes: 101 additions & 0 deletions packages/react-aria-components/stories/GridList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import {
DropIndicator,
GridLayout,
GridList,
GridListHeader,
GridListItem,
GridListItemProps,
GridListLoadMoreItem,
GridListProps,
GridListSection,
Heading,
ListLayout,
Modal,
Expand Down Expand Up @@ -145,6 +147,105 @@ const MyCheckbox = ({children, ...props}: CheckboxProps) => {
);
};


export const GridListSectionExample = (args) => (
<GridList
{...args}
className={styles.menu}
aria-label="test gridlist"
style={{
width: 400,
height: 400
}}>
<GridListSection>
<GridListHeader>Section 1</GridListHeader>
<MyGridListItem>1,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>1,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>1,3 <Button>Actions</Button></MyGridListItem>
</GridListSection>
<GridListSection>
<GridListHeader>Section 2</GridListHeader>
<MyGridListItem>2,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>2,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>2,3 <Button>Actions</Button></MyGridListItem>
</GridListSection>
<GridListSection>
<GridListHeader>Section 3</GridListHeader>
<MyGridListItem>3,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>3,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>3,3 <Button>Actions</Button></MyGridListItem>
</GridListSection>
</GridList>
);

GridListSectionExample.story = {
args: {
layout: 'stack',
escapeKeyBehavior: 'clearSelection',
shouldSelectOnPressUp: false
},
argTypes: {
layout: {
control: 'radio',
options: ['stack', 'grid']
},
keyboardNavigationBehavior: {
control: 'radio',
options: ['arrow', 'tab']
},
selectionMode: {
control: 'radio',
options: ['none', 'single', 'multiple']
},
selectionBehavior: {
control: 'radio',
options: ['toggle', 'replace']
},
escapeKeyBehavior: {
control: 'radio',
options: ['clearSelection', 'none']
}
}
};

export function VirtualizedGridListSection() {
let sections: {id: string, name: string, children: {id: string, name: string}[]}[] = [];
for (let s = 0; s < 10; s++) {
let items: {id: string, name: string}[] = [];
for (let i = 0; i < 3; i++) {
items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`});
}
sections.push({id: `section_${s}`, name: `Section ${s}`, children: items});
}

return (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight: 25
}}>
<GridList
className={styles.menu}
// selectionMode="multiple"
style={{height: 400}}
aria-label="virtualized with grid section"
items={sections}>
<Collection items={sections}>
{section => (
<GridListSection>
<GridListHeader>{section.name}</GridListHeader>
<Collection items={section.children} >
{item => <MyGridListItem>{item.name}</MyGridListItem>}
</Collection>
</GridListSection>
)}
</Collection>
</GridList>
</Virtualizer>
);
}


const VirtualizedGridListRender = (args: GridListProps<any> & {isLoading: boolean}) => {
let items: {id: number, name: string}[] = [];
for (let i = 0; i < 10000; i++) {
Expand Down
81 changes: 81 additions & 0 deletions packages/react-aria-components/test/GridList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import {
DropIndicator,
GridList,
GridListContext,
GridListHeader,
GridListItem,
GridListSection,
Label,
ListLayout,
Modal,
Expand All @@ -45,6 +47,23 @@ let TestGridList = ({listBoxProps, itemProps}) => (
</GridList>
);

let TestGridListSections = ({listBoxProps, itemProps}) => (
<GridList aria-label="Test" {...listBoxProps}>
<GridListSection>
<GridListHeader>Favorite Animal</GridListHeader>
<GridListItem {...itemProps} id="cat" textValue="Cat"><Checkbox slot="selection" /> Cat</GridListItem>
<GridListItem {...itemProps} id="dog" textValue="Dog"><Checkbox slot="selection" /> Dog</GridListItem>
<GridListItem {...itemProps} id="kangaroo" textValue="Kangaroo"><Checkbox slot="selection" /> Kangaroo</GridListItem>
</GridListSection>
<GridListSection aria-label="Favorite Ice Cream">
<GridListItem {...itemProps} id="cat" textValue="Vanilla"><Checkbox slot="selection" />Vanilla</GridListItem>
<GridListItem {...itemProps} id="dog" textValue="Chocolate"><Checkbox slot="selection" />Chocolate</GridListItem>
<GridListItem {...itemProps} id="kangaroo" textValue="Strawberry"><Checkbox slot="selection" />Strawberry</GridListItem>
</GridListSection>
</GridList>
);


let DraggableGridList = (props) => {
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) => [...keys].map((key) => ({'text/plain': key})),
Expand Down Expand Up @@ -413,6 +432,68 @@ describe('GridList', () => {
expect(items[2]).toHaveAttribute('aria-selected', 'true');
});

it('should support sections', () => {
let {getAllByRole} = render(<TestGridListSections />);

let groups = getAllByRole('rowgroup');
expect(groups).toHaveLength(2);

expect(groups[0]).toHaveClass('react-aria-GridListSection');
expect(groups[1]).toHaveClass('react-aria-GridListSection');

expect(groups[0]).toHaveAttribute('aria-labelledby');
expect(document.getElementById(groups[0].getAttribute('aria-labelledby'))).toHaveTextContent('Favorite Animal');
expect(groups[1].getAttribute('aria-label')).toEqual('Favorite Ice Cream');
});

it('should update collection when moving item to a different section', () => {
let {getAllByRole, rerender} = render(
<GridList aria-label="Test">
<GridListSection id="veggies">
<GridListHeader>Veggies</GridListHeader>
<GridListItem key="lettuce" id="lettuce">Lettuce</GridListItem>
<GridListItem key="tomato" id="tomato">Tomato</GridListItem>
<GridListItem key="onion" id="onion">Onion</GridListItem>
</GridListSection>
<GridListSection id="meats">
<GridListHeader>Meats</GridListHeader>
<GridListItem key="ham" id="ham">Ham</GridListItem>
<GridListItem key="tuna" id="tuna">Tuna</GridListItem>
<GridListItem key="tofu" id="tofu">Tofu</GridListItem>
</GridListSection>
</GridList>
);

let sections = getAllByRole('rowgroup');
let items = within(sections[0]).getAllByRole('gridcell');
expect(items).toHaveLength(3);
items = within(sections[1]).getAllByRole('gridcell');
expect(items).toHaveLength(3);

rerender(
<GridList aria-label="Test">
<GridListSection id="veggies">
<GridListHeader>Veggies</GridListHeader>
<GridListItem key="lettuce" id="lettuce">Lettuce</GridListItem>
<GridListItem key="tomato" id="tomato">Tomato</GridListItem>
<GridListItem key="onion" id="onion">Onion</GridListItem>
<GridListItem key="ham" id="ham">Ham</GridListItem>
</GridListSection>
<GridListSection id="meats">
<GridListHeader>Meats</GridListHeader>
<GridListItem key="tuna" id="tuna">Tuna</GridListItem>
<GridListItem key="tofu" id="tofu">Tofu</GridListItem>
</GridListSection>
</GridList>
);

sections = getAllByRole('rowgroup');
items = within(sections[0]).getAllByRole('gridcell');
expect(items).toHaveLength(4);
items = within(sections[1]).getAllByRole('gridcell');
expect(items).toHaveLength(2);
});

describe('selectionBehavior="replace"', () => {
// Required for proper touch detection
installPointerEvent();
Expand Down
Loading