diff --git a/packages/@react-aria/collections/src/BaseCollection.ts b/packages/@react-aria/collections/src/BaseCollection.ts index 739f85995c2..b7a7a4dc1bd 100644 --- a/packages/@react-aria/collections/src/BaseCollection.ts +++ b/packages/@react-aria/collections/src/BaseCollection.ts @@ -254,7 +254,7 @@ export class BaseCollection implements ICollection> { throw new Error('Cannot add a node to a frozen collection'); } - if (node.type === 'item' && this.keyMap.get(node.key) == null) { + if ((node.type === 'item' || node.type === 'header') && this.keyMap.get(node.key) == null) { this.itemCount++; } diff --git a/packages/@react-aria/gridlist/package.json b/packages/@react-aria/gridlist/package.json index 83cbbb0ec2c..dcc3069c87e 100644 --- a/packages/@react-aria/gridlist/package.json +++ b/packages/@react-aria/gridlist/package.json @@ -26,6 +26,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/collections": "3.0.0-rc.4", "@react-aria/focus": "^3.21.1", "@react-aria/grid": "^3.14.4", "@react-aria/i18n": "^3.12.12", diff --git a/packages/@react-aria/gridlist/src/useGridList.ts b/packages/@react-aria/gridlist/src/useGridList.ts index 055602c8146..905e9eaa198 100644 --- a/packages/@react-aria/gridlist/src/useGridList.ts +++ b/packages/@react-aria/gridlist/src/useGridList.ts @@ -145,7 +145,9 @@ export function useGridList(props: AriaGridListOptions, state: ListState node.type === 'section')}); let descriptionProps = useHighlightSelectionDescription({ selectionManager: state.selectionManager, diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 2ecfc4f8125..ffb4d7c1dc4 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -11,6 +11,7 @@ */ import {chain, getScrollParent, mergeProps, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; +import {CollectionNode} from '@react-aria/collections'; import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; @@ -67,7 +68,7 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt // let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/gridlist'); let {direction} = useLocale(); - let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} = listMap.get(state)!; + let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp, hasSection} = 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 @@ -277,6 +278,27 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt // }); // } + let sumOfNodes = (node: CollectionNode): number => { + // If prevKey is null, then this is the first node in the collection so get number of row(s) + if (node.prevKey === null) { + return getNumberOfRows(node, state); + } + + // If the node is an item inside of a section, get number of rows in the current section + let parentNode = node.parentKey ? state.collection.getItem(node.parentKey) as CollectionNode : null; + if (parentNode && parentNode.type === 'section') { + return sumOfNodes(parentNode); + } + + // Otherwise, if the node is a section or item outside of a section, recursively call to get the current sum + get the number of row(s) + let prevNode = state.collection.getItem(node.prevKey!) as CollectionNode; + if (prevNode) { + return sumOfNodes(prevNode) + getNumberOfRows(node, state); + } + + return 0; + }; + let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, { role: 'row', onKeyDownCapture, @@ -291,10 +313,26 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }); if (isVirtualized) { - 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; + // TODO: refactor BaseCollection to store an absolute index of a node's position? + if (hasSection) { + let parentNode = node.parentKey ? state.collection.getItem(node.parentKey) as CollectionNode : null; + let isInSection = parentNode && parentNode.type === 'section'; + let lastChildKey = parentNode?.lastChildKey; + if (isInSection && lastChildKey) { + let lastChild = state.collection.getItem(lastChildKey); + let delta = lastChild ? lastChild.index - node.index : 0; + if (parentNode && parentNode.prevKey) { + rowProps['aria-rowindex'] = sumOfNodes(parentNode) - delta; + } else { + // If the item is within a section but the section is the first node in the collection + rowProps['aria-rowindex'] = node.index + 1; + } + } else { + rowProps['aria-rowindex'] = sumOfNodes(node as CollectionNode); + } + } else { + rowProps['aria-rowindex'] = node.index + 1; + } } let gridCellProps = { @@ -324,3 +362,15 @@ function last(walker: TreeWalker) { } while (last); return next; } + +export function getNumberOfRows(node: RSNode, state: ListState | TreeState) { + if (node.type === 'section') { + // Use the index of the last child to determine the number of nodes in the section + let currentNode = node as CollectionNode; + let lastChild = currentNode.lastChildKey ? state.collection.getItem(currentNode.lastChildKey) : null; + return lastChild ? lastChild.index + 1 : 0; + } else if (node.type === 'item') { + return 1; + } + return 0; +} diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts index f7d8bce9433..4ef3fdd431b 100644 --- a/packages/@react-aria/gridlist/src/useGridListSection.ts +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -10,13 +10,19 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, RefObject} from '@react-types/shared'; +import {CollectionNode} from '@react-aria/collections'; +import {DOMAttributes, RefObject, Node as RSNode} from '@react-types/shared'; +import {getNumberOfRows} from './useGridListItem'; 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 + 'aria-label'?: string, + /** An object representing the section. */ + node: RSNode, + /** Whether the list row is contained in a virtual scroller. */ + isVirtualized?: boolean } export interface GridListSectionAria { @@ -37,20 +43,49 @@ export interface GridListSectionAria { */ // 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 {'aria-label': ariaLabel, node, isVirtualized} = props; let headingId = useSlotId(); let labelProps = useLabels({ 'aria-label': ariaLabel, 'aria-labelledby': headingId }); + let rowIndex; + + let sumOfNodes = (node: CollectionNode): number => { + // If prevKey is null, then this is the first node in the collection + if (node.prevKey === null) { + return getNumberOfRows(node, state); + } + + // Otherwise, if the node is a section or item outside of a section, recursively call to get the current sum + get the number of row(s) + let prevNode = state.collection.getItem(node.prevKey!) as CollectionNode; + if (prevNode) { + return sumOfNodes(prevNode) + getNumberOfRows(node, state); + } + + return 0; + }; + + if (isVirtualized) { + if (node.prevKey) { + let prevNode = state.collection.getItem(node.prevKey); + if (prevNode) { + rowIndex = sumOfNodes(prevNode as CollectionNode) + 1; + } + } else { + rowIndex = 1; + } + } return { rowProps: { - role: 'row' + role: 'row', + 'aria-rowindex': rowIndex }, rowHeaderProps: { id: headingId, - role: 'rowheader' + role: 'rowheader', + 'aria-colindex': 1 }, rowGroupProps: { role: 'rowgroup', diff --git a/packages/@react-aria/gridlist/src/utils.ts b/packages/@react-aria/gridlist/src/utils.ts index 9d1ab97a95d..0a625ce3777 100644 --- a/packages/@react-aria/gridlist/src/utils.ts +++ b/packages/@react-aria/gridlist/src/utils.ts @@ -18,7 +18,8 @@ interface ListMapShared { onAction?: (key: Key) => void, linkBehavior?: 'action' | 'selection' | 'override', keyboardNavigationBehavior: 'arrow' | 'tab', - shouldSelectOnPressUp?: boolean + shouldSelectOnPressUp?: boolean, + hasSection?: boolean } // Used to share: diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index d3a87620b09..cbf0d6abb93 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -21,7 +21,6 @@ import {DraggableCollectionState, DroppableCollectionState, Collection as IColle import {FieldInputContext, SelectableCollectionContext} from './context'; 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'; @@ -580,13 +579,15 @@ export interface GridListSectionProps extends SectionProps {} /** * A GridListSection represents a section within a GridList. */ -export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, (props: GridListSectionProps, ref: ForwardedRef, item: Node) => { +export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, (props: GridListSectionProps, ref: ForwardedRef, item: Node) => { let state = useContext(ListStateContext)!; - let {CollectionBranch} = useContext(CollectionRendererContext); + let {CollectionBranch, isVirtualized} = useContext(CollectionRendererContext); let headingRef = useRef(null); - ref = useObjectRef(ref); + ref = useObjectRef(ref); let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({ - 'aria-label': props['aria-label'] ?? undefined + 'aria-label': props['aria-label'] ?? undefined, + node: item, + isVirtualized }, state, ref); let renderProps = useRenderProps({ defaultClassName: 'react-aria-GridListSection', @@ -599,33 +600,35 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, delete DOMProps.id; return ( -
-
+ ); }); -const GridListHeaderContext = createContext | null>(null); -export const GridListHeader = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes, ref: ForwardedRef) { - [props, ref] = useContextProps(props, ref, HeaderContext); - let rowHeaderProps = useContext(GridListHeaderContext); +export const GridListHeaderContext = createContext, HTMLDivElement>>({}); +const GridListHeaderInnerContext = createContext | null>(null); + +export const GridListHeader = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, GridListHeaderContext); + let rowHeaderProps = useContext(GridListHeaderInnerContext); return ( -
+
{props.children}
-
+ ); }); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index 36cf3e61b43..f5fac2b62b0 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, GridListHeader, GridListSection} from './GridList'; +export {GridListLoadMoreItem, GridList, GridListItem, GridListContext, GridListHeader, GridListHeaderContext, 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 0d8c197519a..c75e0b02650 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -160,21 +160,21 @@ export const GridListSectionExample = (args) => ( }}> Section 1 - 1,1 - 1,2 - 1,3 + 1,1 + 1,2 + 1,3 Section 2 - 2,1 - 2,2 - 2,3 + 2,1 + 2,2 + 2,3 Section 3 - 3,1 - 3,2 - 3,3 + 3,1 + 3,2 + 3,3 ); @@ -213,7 +213,7 @@ 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++) { + for (let i = 0; i < 5; i++) { items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`}); } sections.push({id: `section_${s}`, name: `Section ${s}`, children: items}); @@ -223,11 +223,11 @@ export function VirtualizedGridListSection() { @@ -236,7 +236,7 @@ export function VirtualizedGridListSection() { {section.name} - {item => {item.name}} + {item => {item.name}} )} diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index dc01ea3f93b..db20692c46b 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -494,6 +494,93 @@ describe('GridList', () => { expect(items).toHaveLength(2); }); + it('should calculate the correct aria-rowindex when gridlist is made up of only sections', () => { + let sections = []; + for (let s = 0; s < 5; s++) { + let items = []; + for (let i = 0; i < 2; i++) { + items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`}); + } + sections.push({id: `section_${s}`, name: `Section ${s}`, children: items}); + } + + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 300); + + let {getAllByRole, getByRole} = render( + + + + {section => ( + + {section.name} + + {item => {item.name}} + + + )} + + + + ); + + let grid = getByRole('grid'); + expect(grid).toHaveAttribute('aria-rowcount'); + expect(grid.getAttribute('aria-rowcount')).toEqual('15'); + + let rows = getAllByRole('row'); + expect(rows).toHaveLength(15); + expect(rows.map(r => r.textContent)).toEqual(['Section 0', 'Section 0, Item 0', 'Section 0, Item 1', 'Section 1', 'Section 1, Item 0', 'Section 1, Item 1', 'Section 2', 'Section 2, Item 0', 'Section 2, Item 1', 'Section 3', 'Section 3, Item 0', 'Section 3, Item 1', 'Section 4', 'Section 4, Item 0', 'Section 4, Item 1']); + + for (let i = 0; i < 15; i++) { + expect(rows[i]).toHaveAttribute('aria-rowindex'); + expect(rows[i].getAttribute('aria-rowindex')).toEqual(`${i + 1}`); + } + }); + + it('should calculate the correct aria-rowindex when there is a mix of sections and individual items', () => { + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 300); + + let {getAllByRole, getByRole} = render( + + + Home + School + + Pets + Cat + Dog + + + Vanilla + Chocolate + + City Hall + Pharmacy + + Plants + Sunflower + Daffodil + + + + ); + + let grid = getByRole('grid'); + expect(grid).toHaveAttribute('aria-rowcount'); + expect(grid.getAttribute('aria-rowcount')).toEqual('12'); + + let rows = getAllByRole('row'); + expect(rows).toHaveLength(12); + expect(rows.map(r => r.textContent)).toEqual(['Home', 'School', 'Pets', 'Cat', 'Dog', 'Vanilla', 'Chocolate', 'City Hall', 'Pharmacy', 'Plants', 'Sunflower', 'Daffodil']); + + for (let i = 0; i < 12; i++) { + expect(rows[i]).toHaveAttribute('aria-rowindex'); + expect(rows[i].getAttribute('aria-rowindex')).toEqual(`${i + 1}`); + } + }); + describe('selectionBehavior="replace"', () => { // Required for proper touch detection installPointerEvent(); diff --git a/yarn.lock b/yarn.lock index ee1fcc75171..34940643e0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5520,6 +5520,23 @@ __metadata: languageName: unknown linkType: soft +"@react-aria/collections@npm:3.0.0-rc.4": + version: 3.0.0-rc.4 + resolution: "@react-aria/collections@npm:3.0.0-rc.4" + dependencies: + "@react-aria/interactions": "npm:^3.25.4" + "@react-aria/ssr": "npm:^3.9.10" + "@react-aria/utils": "npm:^3.30.0" + "@react-types/shared": "npm:^3.31.0" + "@swc/helpers": "npm:^0.5.0" + use-sync-external-store: "npm:^1.4.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + checksum: 10c0/712c574704bc735b525a8c184780373e1854be515cabf83b22ee59ea9c52befd02495445b5546db44d913bb37273e16a59672298a180b57f72930e697d81c9ad + languageName: node + linkType: hard + "@react-aria/collections@npm:3.0.0-rc.6, @react-aria/collections@workspace:packages/@react-aria/collections": version: 0.0.0-use.local resolution: "@react-aria/collections@workspace:packages/@react-aria/collections" @@ -5728,6 +5745,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-aria/gridlist@workspace:packages/@react-aria/gridlist" dependencies: + "@react-aria/collections": "npm:3.0.0-rc.4" "@react-aria/focus": "npm:^3.21.1" "@react-aria/grid": "npm:^3.14.4" "@react-aria/i18n": "npm:^3.12.12" @@ -5762,7 +5780,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.1, @react-aria/interactions@npm:^3.25.5, @react-aria/interactions@workspace:packages/@react-aria/interactions": +"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.1, @react-aria/interactions@npm:^3.25.4, @react-aria/interactions@npm:^3.25.5, @react-aria/interactions@workspace:packages/@react-aria/interactions": version: 0.0.0-use.local resolution: "@react-aria/interactions@workspace:packages/@react-aria/interactions" dependencies: @@ -6303,7 +6321,7 @@ __metadata: languageName: unknown linkType: soft -"@react-aria/utils@npm:^3.29.0, @react-aria/utils@npm:^3.30.1, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils": +"@react-aria/utils@npm:^3.29.0, @react-aria/utils@npm:^3.30.0, @react-aria/utils@npm:^3.30.1, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils": version: 0.0.0-use.local resolution: "@react-aria/utils@workspace:packages/@react-aria/utils" dependencies: @@ -8766,7 +8784,7 @@ __metadata: languageName: unknown linkType: soft -"@react-types/shared@npm:^3.1.0, @react-types/shared@npm:^3.30.0, @react-types/shared@npm:^3.32.0, @react-types/shared@workspace:packages/@react-types/shared": +"@react-types/shared@npm:^3.1.0, @react-types/shared@npm:^3.30.0, @react-types/shared@npm:^3.31.0, @react-types/shared@npm:^3.32.0, @react-types/shared@workspace:packages/@react-types/shared": version: 0.0.0-use.local resolution: "@react-types/shared@workspace:packages/@react-types/shared" peerDependencies: