From 455cbf45d0641cc4efa1fbe43a68ef858bc84442 Mon Sep 17 00:00:00 2001 From: solimant Date: Mon, 5 Aug 2024 12:04:24 -0400 Subject: [PATCH] feat: Support fragments in collections (#6430) Co-authored-by: Reid Barber Co-authored-by: Robert Snow --- .../@react-spectrum/tabs/test/Tabs.test.js | 382 ++++++++++++++++++ .../collections/src/CollectionBuilder.ts | 28 +- 2 files changed, 408 insertions(+), 2 deletions(-) diff --git a/packages/@react-spectrum/tabs/test/Tabs.test.js b/packages/@react-spectrum/tabs/test/Tabs.test.js index 4b839486b94..e402a0995ad 100644 --- a/packages/@react-spectrum/tabs/test/Tabs.test.js +++ b/packages/@react-spectrum/tabs/test/Tabs.test.js @@ -890,4 +890,386 @@ describe('Tabs', function () { fireEvent.keyDown(tabs[1], {key: 'ArrowRight'}); expect(tabs[2]).toHaveAttribute('aria-selected', 'true'); }); + + describe('when using fragments', function () { + it('renders fragment with children properly', function () { + let container = render( + + + + <> + Tab 1 + Tab 2 + + + + <> + + Tab 1 content + + + Tab 2 content + + + + + + ); + + let tablist = container.getByRole('tablist'); + expect(tablist).toBeTruthy(); + + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + + let tabs = within(tablist).getAllByRole('tab'); + expect(tabs.length).toBe(2); + + for (let tab of tabs) { + expect(tab).toHaveAttribute('tabindex'); + expect(tab).toHaveAttribute('aria-selected'); + let isSelected = tab.getAttribute('aria-selected') === 'true'; + if (isSelected) { + expect(tab).toHaveAttribute('aria-controls'); + let tabpanel = document.getElementById(tab.getAttribute('aria-controls')); + expect(tabpanel).toBeTruthy(); + expect(tabpanel).toHaveAttribute('aria-labelledby', tab.id); + expect(tabpanel).toHaveAttribute('role', 'tabpanel'); + expect(tabpanel).toHaveTextContent('Tab 1 content'); + } + } + }); + + it('renders beginning fragment sibling properly', function () { + let container = render( + + + + <> + Tab 1 + + Tab 2 + + + <> + + Tab 1 content + + + + Tab 2 content + + + + + ); + + let tablist = container.getByRole('tablist'); + expect(tablist).toBeTruthy(); + + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + + let tabs = within(tablist).getAllByRole('tab'); + expect(tabs.length).toBe(2); + + for (let tab of tabs) { + expect(tab).toHaveAttribute('tabindex'); + expect(tab).toHaveAttribute('aria-selected'); + let isSelected = tab.getAttribute('aria-selected') === 'true'; + if (isSelected) { + expect(tab).toHaveAttribute('aria-controls'); + let tabpanel = document.getElementById(tab.getAttribute('aria-controls')); + expect(tabpanel).toBeTruthy(); + expect(tabpanel).toHaveAttribute('aria-labelledby', tab.id); + expect(tabpanel).toHaveAttribute('role', 'tabpanel'); + expect(tabpanel).toHaveTextContent('Tab 1 content'); + } + } + }); + + it('renders middle fragment sibling properly', function () { + let container = render( + + + + Tab 1 + <> + Tab 2 + + Tab 3 + + + + Tab 1 content + + <> + + Tab 2 content + + + + Tab 3 content + + + + + ); + + let tablist = container.getByRole('tablist'); + expect(tablist).toBeTruthy(); + + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + + let tabs = within(tablist).getAllByRole('tab'); + expect(tabs.length).toBe(3); + + for (let tab of tabs) { + expect(tab).toHaveAttribute('tabindex'); + expect(tab).toHaveAttribute('aria-selected'); + let isSelected = tab.getAttribute('aria-selected') === 'true'; + if (isSelected) { + expect(tab).toHaveAttribute('aria-controls'); + let tabpanel = document.getElementById(tab.getAttribute('aria-controls')); + expect(tabpanel).toBeTruthy(); + expect(tabpanel).toHaveAttribute('aria-labelledby', tab.id); + expect(tabpanel).toHaveAttribute('role', 'tabpanel'); + expect(tabpanel).toHaveTextContent('Tab 1 content'); + } + } + }); + + it('renders ending fragment sibling properly', function () { + let container = render( + + + + Tab 1 + <> + Tab 2 + + + + + Tab 1 content + + <> + + Tab 2 content + + + + + + ); + + let tablist = container.getByRole('tablist'); + expect(tablist).toBeTruthy(); + + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + + let tabs = within(tablist).getAllByRole('tab'); + expect(tabs.length).toBe(2); + + for (let tab of tabs) { + expect(tab).toHaveAttribute('tabindex'); + expect(tab).toHaveAttribute('aria-selected'); + let isSelected = tab.getAttribute('aria-selected') === 'true'; + if (isSelected) { + expect(tab).toHaveAttribute('aria-controls'); + let tabpanel = document.getElementById(tab.getAttribute('aria-controls')); + expect(tabpanel).toBeTruthy(); + expect(tabpanel).toHaveAttribute('aria-labelledby', tab.id); + expect(tabpanel).toHaveAttribute('role', 'tabpanel'); + expect(tabpanel).toHaveTextContent('Tab 1 content'); + } + } + }); + + it('renders list and panel fragment siblings in non-matching positions properly, list fragment first', function () { + let container = render( + + + + <> + Tab 1 + + Tab 2 + + + + Tab 1 content + + <> + + Tab 2 content + + + + + + ); + + let tablist = container.getByRole('tablist'); + expect(tablist).toBeTruthy(); + + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + + let tabs = within(tablist).getAllByRole('tab'); + expect(tabs.length).toBe(2); + + for (let tab of tabs) { + expect(tab).toHaveAttribute('tabindex'); + expect(tab).toHaveAttribute('aria-selected'); + let isSelected = tab.getAttribute('aria-selected') === 'true'; + if (isSelected) { + expect(tab).toHaveAttribute('aria-controls'); + let tabpanel = document.getElementById(tab.getAttribute('aria-controls')); + expect(tabpanel).toBeTruthy(); + expect(tabpanel).toHaveAttribute('aria-labelledby', tab.id); + expect(tabpanel).toHaveAttribute('role', 'tabpanel'); + expect(tabpanel).toHaveTextContent('Tab 1 content'); + } + } + }); + + it('renders list and panel fragment siblings in non-matching positions properly, panel fragment first', function () { + let container = render( + + + + Tab 1 + <> + Tab 2 + + + + <> + + Tab 1 content + + + + Tab 2 content + + + + + ); + + let tablist = container.getByRole('tablist'); + expect(tablist).toBeTruthy(); + + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + + let tabs = within(tablist).getAllByRole('tab'); + expect(tabs.length).toBe(2); + + for (let tab of tabs) { + expect(tab).toHaveAttribute('tabindex'); + expect(tab).toHaveAttribute('aria-selected'); + let isSelected = tab.getAttribute('aria-selected') === 'true'; + if (isSelected) { + expect(tab).toHaveAttribute('aria-controls'); + let tabpanel = document.getElementById(tab.getAttribute('aria-controls')); + expect(tabpanel).toBeTruthy(); + expect(tabpanel).toHaveAttribute('aria-labelledby', tab.id); + expect(tabpanel).toHaveAttribute('role', 'tabpanel'); + expect(tabpanel).toHaveTextContent('Tab 1 content'); + } + } + }); + + it('renders fragment with renderer properly', function () { + let container = render( + + + + <> + {item => ( + + )} + + + + <> + {item => ( + + {item.children} + + )} + + + + + ); + + let tablist = container.getByRole('tablist'); + expect(tablist).toBeTruthy(); + + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + + let tabs = within(tablist).getAllByRole('tab'); + expect(tabs.length).toBe(3); + + for (let tab of tabs) { + expect(tab).toHaveAttribute('tabindex'); + expect(tab).toHaveAttribute('aria-selected'); + let isSelected = tab.getAttribute('aria-selected') === 'true'; + if (isSelected) { + expect(tab).toHaveAttribute('aria-controls'); + let tabpanel = document.getElementById(tab.getAttribute('aria-controls')); + expect(tabpanel).toBeTruthy(); + expect(tabpanel).toHaveAttribute('aria-labelledby', tab.id); + expect(tabpanel).toHaveAttribute('role', 'tabpanel'); + expect(tabpanel).toHaveTextContent(defaultItems[0].children); + } + } + }); + + it('renders fragment with mapper properly', function () { + let container = render( + + + + <> + {defaultItems.map(item => ( + + ))} + + + + <> + {defaultItems.map(item => ( + + {item.children} + + ))} + + + + + ); + + let tablist = container.getByRole('tablist'); + expect(tablist).toBeTruthy(); + + expect(tablist).toHaveAttribute('aria-orientation', 'horizontal'); + + let tabs = within(tablist).getAllByRole('tab'); + expect(tabs.length).toBe(3); + + for (let tab of tabs) { + expect(tab).toHaveAttribute('tabindex'); + expect(tab).toHaveAttribute('aria-selected'); + let isSelected = tab.getAttribute('aria-selected') === 'true'; + if (isSelected) { + expect(tab).toHaveAttribute('aria-controls'); + let tabpanel = document.getElementById(tab.getAttribute('aria-controls')); + expect(tabpanel).toBeTruthy(); + expect(tabpanel).toHaveAttribute('aria-labelledby', tab.id); + expect(tabpanel).toHaveAttribute('role', 'tabpanel'); + expect(tabpanel).toHaveTextContent(defaultItems[0].children); + } + } + }); + }); }); diff --git a/packages/@react-stately/collections/src/CollectionBuilder.ts b/packages/@react-stately/collections/src/CollectionBuilder.ts index 3d7bae6c1e6..09f804818b4 100644 --- a/packages/@react-stately/collections/src/CollectionBuilder.ts +++ b/packages/@react-stately/collections/src/CollectionBuilder.ts @@ -27,10 +27,15 @@ export class CollectionBuilder { return iterable(() => this.iterateCollection(props)); } - private *iterateCollection(props: CollectionBase) { + private *iterateCollection(props: CollectionBase): Generator> { let {children, items} = props; - if (typeof children === 'function') { + if (React.isValidElement<{children: CollectionElement}>(children) && children.type === React.Fragment) { + yield* this.iterateCollection({ + children: children.props.children, + items + }); + } else if (typeof children === 'function') { if (!items) { throw new Error('props.children was a function but props.items is missing'); } @@ -90,6 +95,25 @@ export class CollectionBuilder { } private *getFullNode(partialNode: PartialNode, state: CollectionBuilderState, parentKey?: Key, parentNode?: Node): Generator> { + if (React.isValidElement<{children: CollectionElement}>(partialNode.element) && partialNode.element.type === React.Fragment) { + let children: CollectionElement[] = []; + + React.Children.forEach(partialNode.element.props.children, child => { + children.push(child); + }); + + let index = partialNode.index; + + for (const child of children) { + yield* this.getFullNode({ + element: child, + index: index++ + }, state, parentKey, parentNode); + } + + return; + } + // If there's a value instead of an element on the node, and a parent renderer function is available, // use it to render an element for the value. let element = partialNode.element;