diff --git a/packages/@react-aria/gridlist/src/index.ts b/packages/@react-aria/gridlist/src/index.ts index 740ca23b200..0e52ce29d6a 100644 --- a/packages/@react-aria/gridlist/src/index.ts +++ b/packages/@react-aria/gridlist/src/index.ts @@ -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'; diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 296bb37e31d..2ecfc4f8125 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -291,7 +291,10 @@ export function useGridListItem(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; } let gridCellProps = { diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts new file mode 100644 index 00000000000..f7d8bce9433 --- /dev/null +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -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(props: AriaGridListSectionProps, state: ListState, ref: RefObject): GridListSectionAria { + let {'aria-label': ariaLabel} = props; + let headingId = useSlotId(); + let labelProps = useLabels({ + 'aria-label': ariaLabel, + 'aria-labelledby': headingId + }); + + return { + rowProps: { + role: 'row' + }, + rowHeaderProps: { + id: headingId, + role: 'rowheader' + }, + rowGroupProps: { + role: 'rowgroup', + ...labelProps + } + }; +} diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 9cb34f49795..825d584f21e 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -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'; @@ -561,3 +562,58 @@ export const GridListLoadMoreItem = createLeafComponent('loader', function GridL ); }); + +export interface GridListSectionProps extends SectionProps {} + +/** + * A GridListSection represents a section within a GridList. + */ +export const GridListSection = /*#__PURE__*/ createBranchComponent('section', (props: GridListSectionProps, ref: ForwardedRef, item: Node) => { + let state = useContext(ListStateContext)!; + let {CollectionBranch} = useContext(CollectionRendererContext); + let headingRef = useRef(null); + ref = useObjectRef(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 ( +
+ + + +
+ ); +}); + +const GridListHeaderContext = createContext | null>(null); + +export const GridListHeader = /*#__PURE__*/ createLeafComponent('header', function Header(props: HTMLAttributes, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, HeaderContext); + let rowHeaderProps = useContext(GridListHeaderContext); + + return ( +
+
+ {props.children} +
+
+ ); +}); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index e0384baca75..00f432242b6 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -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'; diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index a58143c296e..88f8f3664bb 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -21,10 +21,12 @@ import { DropIndicator, GridLayout, GridList, + GridListHeader, GridListItem, GridListItemProps, GridListLoadMoreItem, GridListProps, + GridListSection, Heading, ListLayout, Modal, @@ -145,6 +147,105 @@ const MyCheckbox = ({children, ...props}: CheckboxProps) => { ); }; + +export const GridListSectionExample = (args) => ( + + + Section 1 + 1,1 + 1,2 + 1,3 + + + Section 2 + 2,1 + 2,2 + 2,3 + + + Section 3 + 3,1 + 3,2 + 3,3 + + +); + +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 ( + + + + {section => ( + + {section.name} + + {item => {item.name}} + + + )} + + + + ); +} + + const VirtualizedGridListRender = (args: GridListProps & {isLoading: boolean}) => { let items: {id: number, name: string}[] = []; for (let i = 0; i < 10000; i++) { diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 3dc1f0bf7f7..dc01ea3f93b 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -20,7 +20,9 @@ import { DropIndicator, GridList, GridListContext, + GridListHeader, GridListItem, + GridListSection, Label, ListLayout, Modal, @@ -45,6 +47,23 @@ let TestGridList = ({listBoxProps, itemProps}) => ( ); +let TestGridListSections = ({listBoxProps, itemProps}) => ( + + + Favorite Animal + Cat + Dog + Kangaroo + + + Vanilla + Chocolate + Strawberry + + +); + + let DraggableGridList = (props) => { let {dragAndDropHooks} = useDragAndDrop({ getItems: (keys) => [...keys].map((key) => ({'text/plain': key})), @@ -413,6 +432,68 @@ describe('GridList', () => { expect(items[2]).toHaveAttribute('aria-selected', 'true'); }); + it('should support sections', () => { + let {getAllByRole} = render(); + + 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( + + + Veggies + Lettuce + Tomato + Onion + + + Meats + Ham + Tuna + Tofu + + + ); + + 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( + + + Veggies + Lettuce + Tomato + Onion + Ham + + + Meats + Tuna + Tofu + + + ); + + 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(); diff --git a/packages/react-aria/src/index.ts b/packages/react-aria/src/index.ts index bc63fd9515a..92c549151ad 100644 --- a/packages/react-aria/src/index.ts +++ b/packages/react-aria/src/index.ts @@ -24,7 +24,7 @@ export {FocusRing, FocusScope, useFocusManager, useFocusRing} from '@react-aria/ export {I18nProvider, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter} from '@react-aria/i18n'; export {useFocus, useFocusVisible, useFocusWithin, useHover, useInteractOutside, useKeyboard, useMove, usePress, useLongPress, useFocusable, Pressable, Focusable} from '@react-aria/interactions'; export {useField, useLabel} from '@react-aria/label'; -export {useGridList, useGridListItem, useGridListSelectionCheckbox} from '@react-aria/gridlist'; +export {useGridList, useGridListItem, useGridListSection, useGridListSelectionCheckbox} from '@react-aria/gridlist'; export {useLandmark} from '@react-aria/landmark'; export {useLink} from '@react-aria/link'; export {useListBox, useListBoxSection, useOption} from '@react-aria/listbox';