diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 4b2cea96c0b..c136d127ed7 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -164,49 +164,57 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions switch (e.key) { case 'ArrowDown': { if (delegate.getKeyBelow) { - e.preventDefault(); let nextKey = manager.focusedKey != null - ? delegate.getKeyBelow(manager.focusedKey) + ? delegate.getKeyBelow?.(manager.focusedKey) : delegate.getFirstKey?.(); if (nextKey == null && shouldFocusWrap) { nextKey = delegate.getFirstKey?.(manager.focusedKey); } - navigateToKey(nextKey); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } } break; } case 'ArrowUp': { if (delegate.getKeyAbove) { - e.preventDefault(); let nextKey = manager.focusedKey != null - ? delegate.getKeyAbove(manager.focusedKey) + ? delegate.getKeyAbove?.(manager.focusedKey) : delegate.getLastKey?.(); if (nextKey == null && shouldFocusWrap) { nextKey = delegate.getLastKey?.(manager.focusedKey); } - navigateToKey(nextKey); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } } break; } case 'ArrowLeft': { if (delegate.getKeyLeftOf) { - e.preventDefault(); - let nextKey = delegate.getKeyLeftOf(manager.focusedKey); + let nextKey = delegate.getKeyLeftOf?.(manager.focusedKey); if (nextKey == null && shouldFocusWrap) { nextKey = direction === 'rtl' ? delegate.getFirstKey?.(manager.focusedKey) : delegate.getLastKey?.(manager.focusedKey); } - navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last'); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey, direction === 'rtl' ? 'first' : 'last'); + } } break; } case 'ArrowRight': { if (delegate.getKeyRightOf) { - e.preventDefault(); - let nextKey = delegate.getKeyRightOf(manager.focusedKey); + let nextKey = delegate.getKeyRightOf?.(manager.focusedKey); if (nextKey == null && shouldFocusWrap) { nextKey = direction === 'rtl' ? delegate.getLastKey?.(manager.focusedKey) : delegate.getFirstKey?.(manager.focusedKey); } - navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey, direction === 'rtl' ? 'last' : 'first'); + } } break; } @@ -236,16 +244,20 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions break; case 'PageDown': if (delegate.getKeyPageBelow) { - e.preventDefault(); let nextKey = delegate.getKeyPageBelow(manager.focusedKey); - navigateToKey(nextKey); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } } break; case 'PageUp': if (delegate.getKeyPageAbove) { - e.preventDefault(); let nextKey = delegate.getKeyPageAbove(manager.focusedKey); - navigateToKey(nextKey); + if (nextKey != null) { + e.preventDefault(); + navigateToKey(nextKey); + } } break; case 'a': diff --git a/packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts b/packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts index 6ce58947848..463bad748f5 100644 --- a/packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts +++ b/packages/@react-aria/tabs/src/TabsKeyboardDelegate.ts @@ -16,11 +16,13 @@ export class TabsKeyboardDelegate implements KeyboardDelegate { private collection: Collection>; private flipDirection: boolean; private disabledKeys: Set; + private tabDirection: boolean; constructor(collection: Collection>, direction: Direction, orientation: Orientation, disabledKeys: Set = new Set()) { this.collection = collection; this.flipDirection = direction === 'rtl' && orientation === 'horizontal'; this.disabledKeys = disabledKeys; + this.tabDirection = orientation === 'horizontal'; } getKeyLeftOf(key: Key) { @@ -37,13 +39,6 @@ export class TabsKeyboardDelegate implements KeyboardDelegate { return this.getNextKey(key); } - getKeyAbove(key: Key) { - return this.getPreviousKey(key); - } - - getKeyBelow(key: Key) { - return this.getNextKey(key); - } private isDisabled(key: Key) { return this.disabledKeys.has(key) || !!this.collection.getItem(key)?.props?.isDisabled; @@ -64,6 +59,20 @@ export class TabsKeyboardDelegate implements KeyboardDelegate { } return key; } + + getKeyAbove(key: Key) { + if (this.tabDirection) { + return null; + } + return this.getPreviousKey(key); + } + + getKeyBelow(key: Key) { + if (this.tabDirection) { + return null; + } + return this.getNextKey(key); + } getNextKey(key) { do { diff --git a/packages/@react-spectrum/picker/intl/en-US.json b/packages/@react-spectrum/picker/intl/en-US.json index 2829e8a0db6..05ea63ada2e 100644 --- a/packages/@react-spectrum/picker/intl/en-US.json +++ b/packages/@react-spectrum/picker/intl/en-US.json @@ -1,4 +1,4 @@ { - "placeholder": "Select an option…", + "placeholder": "Select…", "loading": "Loading…" } diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js index 99c1a8cc651..9595777562e 100644 --- a/packages/@react-spectrum/picker/test/Picker.test.js +++ b/packages/@react-spectrum/picker/test/Picker.test.js @@ -70,7 +70,7 @@ describe('Picker', function () { expect(picker).toHaveAttribute('data-testid', 'test'); let label = getAllByText('Test')[0]; - let value = getByText('Select an option…'); + let value = getByText('Select…'); expect(label).toBeVisible(); expect(value).toBeVisible(); }); @@ -362,7 +362,7 @@ describe('Picker', function () { expect(listbox).toBeVisible(); expect(onOpenChange).not.toBeCalled(); - let picker = getByLabelText('Select an option…'); + let picker = getByLabelText('Select…'); expect(picker).toHaveAttribute('aria-expanded', 'true'); expect(picker).toHaveAttribute('aria-controls', listbox.id); @@ -393,7 +393,7 @@ describe('Picker', function () { expect(listbox).toBeVisible(); expect(onOpenChange).not.toBeCalled(); - let picker = getByLabelText('Select an option…'); + let picker = getByLabelText('Select…'); expect(picker).toHaveAttribute('aria-expanded', 'true'); expect(picker).toHaveAttribute('aria-controls', listbox.id); @@ -721,7 +721,7 @@ describe('Picker', function () { expect(listbox).toBeVisible(); expect(onOpenChange).not.toBeCalled(); - let picker = getByLabelText('Select an option…'); + let picker = getByLabelText('Select…'); expect(picker).toHaveAttribute('aria-expanded', 'true'); expect(picker).toHaveAttribute('aria-controls', listbox.id); @@ -751,7 +751,7 @@ describe('Picker', function () { expect(getByRole('listbox')).toBeVisible(); expect(onOpenChange).not.toBeCalled(); - let picker = getByLabelText('Select an option…'); + let picker = getByLabelText('Select…'); expect(picker).toHaveAttribute('aria-expanded', 'true'); let listbox = getByRole('listbox'); @@ -804,7 +804,7 @@ describe('Picker', function () { expect(picker).toHaveAttribute('aria-haspopup', 'listbox'); let label = getAllByText('Test')[0]; - let value = getByText('Select an option…'); + let value = getByText('Select…'); expect(label).toHaveAttribute('id'); expect(value).toHaveAttribute('id'); expect(picker).toHaveAttribute('aria-labelledby', `${value.id} ${label.id}`); @@ -829,7 +829,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - let value = getByText('Select an option…'); + let value = getByText('Select…'); expect(picker).toHaveAttribute('id'); expect(value).toHaveAttribute('id'); expect(picker).toHaveAttribute('aria-label', 'Test'); @@ -855,7 +855,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - let value = getByText('Select an option…'); + let value = getByText('Select…'); expect(picker).toHaveAttribute('id'); expect(value).toHaveAttribute('id'); expect(picker).toHaveAttribute('aria-labelledby', `${value.id} foo`); @@ -880,7 +880,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - let value = getByText('Select an option…'); + let value = getByText('Select…'); expect(picker).toHaveAttribute('id'); expect(value).toHaveAttribute('id'); expect(picker).toHaveAttribute('aria-label', 'Test'); @@ -914,7 +914,7 @@ describe('Picker', function () { expect(span).not.toHaveAttribute('aria-hidden'); let label = span.parentElement; - let value = getByText('Select an option…'); + let value = getByText('Select…'); expect(label).toHaveAttribute('id'); expect(value).toHaveAttribute('id'); expect(picker).toHaveAttribute('aria-labelledby', `${value.id} ${label.id}`); @@ -978,7 +978,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); await user.click(picker); act(() => jest.runAllTimers()); @@ -1015,7 +1015,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); await user.click(picker); act(() => jest.runAllTimers()); @@ -1086,7 +1086,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); await user.click(picker); act(() => jest.runAllTimers()); @@ -1132,7 +1132,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); act(() => {picker.focus();}); fireEvent.keyDown(picker, {key: 'ArrowUp'}); @@ -1177,7 +1177,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); await user.click(picker); act(() => jest.runAllTimers()); @@ -1223,7 +1223,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); expect(onOpenChangeSpy).toHaveBeenCalledTimes(0); await user.click(picker); act(() => jest.runAllTimers()); @@ -1370,7 +1370,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); await user.click(picker); act(() => jest.runAllTimers()); @@ -1443,7 +1443,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); await user.click(picker); act(() => jest.runAllTimers()); @@ -1545,7 +1545,7 @@ describe('Picker', function () { let picker = getByRole('button'); act(() => {picker.focus();}); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); fireEvent.keyDown(picker, {key: 'ArrowDown'}); act(() => jest.runAllTimers()); @@ -1641,7 +1641,7 @@ describe('Picker', function () { let picker = getByRole('button'); await user.tab(); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); await user.keyboard('{ArrowLeft}'); act(() => jest.runAllTimers()); expect(onSelectionChange).toHaveBeenCalledTimes(1); @@ -1687,7 +1687,7 @@ describe('Picker', function () { let picker = getByRole('button'); act(() => {picker.focus();}); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); fireEvent.keyDown(picker, {key: 't'}); fireEvent.keyUp(picker, {key: 't'}); @@ -1715,7 +1715,7 @@ describe('Picker', function () { let picker = getByRole('button'); act(() => {picker.focus();}); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); fireEvent.keyDown(picker, {key: 't'}); fireEvent.keyUp(picker, {key: 't'}); @@ -1744,7 +1744,7 @@ describe('Picker', function () { let picker = getByRole('button'); act(() => {picker.focus();}); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); fireEvent.keyDown(picker, {key: 't'}); fireEvent.keyUp(picker, {key: 't'}); @@ -1772,7 +1772,7 @@ describe('Picker', function () { ); let picker = getByRole('button'); - expect(picker).toHaveTextContent('Select an option…'); + expect(picker).toHaveTextContent('Select…'); let hiddenLabel = getByText('Test', {hidden: true, selector: 'label'}); expect(hiddenLabel.tagName).toBe('LABEL'); @@ -2500,7 +2500,7 @@ describe('Picker', function () { jest.runAllTimers(); }); - expect(button).toHaveTextContent('Select an option…'); + expect(button).toHaveTextContent('Select…'); expect(listbox).not.toBeInTheDocument(); expect(onClick.mock.calls[0][0].target).toBeInstanceOf(HTMLAnchorElement); expect(onClick.mock.calls[0][0].target.href).toBe('https://google.com/'); diff --git a/packages/@react-spectrum/table/src/TableViewWrapper.tsx b/packages/@react-spectrum/table/src/TableViewWrapper.tsx index 5385a6488c2..1dda249c33b 100644 --- a/packages/@react-spectrum/table/src/TableViewWrapper.tsx +++ b/packages/@react-spectrum/table/src/TableViewWrapper.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import type {AriaLabelingProps, DOMProps, DOMRef, Key, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; +import type {AriaLabelingProps, DisabledBehavior, DOMProps, DOMRef, Key, SpectrumSelectionProps, StyleProps} from '@react-types/shared'; import type {ColumnSize, TableProps} from '@react-types/table'; import type {DragAndDropHooks} from '@react-spectrum/dnd'; import React, {JSX, ReactElement} from 'react'; @@ -33,6 +33,11 @@ export interface SpectrumTableProps extends TableProps, SpectrumSelectionP isQuiet?: boolean, /** Sets what the TableView should render when there is no content to display. */ renderEmptyState?: () => JSX.Element, + /** + * Whether `disabledKeys` applies to all interactions, or only selection. + * @default "selection" + */ + disabledBehavior?: DisabledBehavior, /** Handler that is called when a user performs an action on a row. */ onAction?: (key: Key) => void, /** diff --git a/packages/@react-spectrum/table/stories/Table.stories.tsx b/packages/@react-spectrum/table/stories/Table.stories.tsx index f3bdc80f13f..33fc1db14f4 100644 --- a/packages/@react-spectrum/table/stories/Table.stories.tsx +++ b/packages/@react-spectrum/table/stories/Table.stories.tsx @@ -117,6 +117,10 @@ export default { }, disallowEmptySelection: { control: 'boolean' + }, + disabledBehavior: { + control: 'select', + options: ['all', 'selection'] } } } as ComponentMeta; diff --git a/packages/@react-spectrum/tabs/test/Tabs.test.js b/packages/@react-spectrum/tabs/test/Tabs.test.js index 4b839486b94..7a411b22046 100644 --- a/packages/@react-spectrum/tabs/test/Tabs.test.js +++ b/packages/@react-spectrum/tabs/test/Tabs.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, fireEvent, mockImplementation, pointerMap, render, waitFor, within} from '@react-spectrum/test-utils-internal'; +import {act, createEvent, fireEvent, mockImplementation, pointerMap, render, waitFor, within} from '@react-spectrum/test-utils-internal'; import {Item, TabList, TabPanels, Tabs} from '../src'; import {Links as LinksExample} from '../stories/Tabs.stories'; import {Provider} from '@react-spectrum/provider'; @@ -113,18 +113,25 @@ describe('Tabs', function () { expect(selectedItem).toHaveAttribute('aria-selected', 'true'); act(() => {selectedItem.focus();}); - fireEvent.keyDown(selectedItem, {key: 'ArrowRight', code: 39, charCode: 39}); + let arrowRight = createEvent.keyDown(selectedItem, {key: 'ArrowRight', code: 39, charCode: 39}); + fireEvent(selectedItem, arrowRight); let nextSelectedItem = tabs[1]; expect(nextSelectedItem).toHaveAttribute('aria-selected', 'true'); - fireEvent.keyDown(nextSelectedItem, {key: 'ArrowLeft', code: 37, charCode: 37}); + expect(arrowRight.defaultPrevented).toBe(true); + let arrowLeft = createEvent.keyDown(nextSelectedItem, {key: 'ArrowLeft', code: 37, charCode: 37}); + fireEvent(nextSelectedItem, arrowLeft); expect(selectedItem).toHaveAttribute('aria-selected', 'true'); + expect(arrowLeft.defaultPrevented).toBe(true); - /** Changes selection regardless if it's horizontal tabs. */ - fireEvent.keyDown(selectedItem, {key: 'ArrowUp', code: 38, charCode: 38}); - nextSelectedItem = tabs[2]; - expect(nextSelectedItem).toHaveAttribute('aria-selected', 'true'); - fireEvent.keyDown(selectedItem, {key: 'ArrowDown', code: 40, charCode: 40}); + /** prevent changing tabs for horizontal orientations in aria-selected */ + let arrowUp = createEvent.keyDown(selectedItem, {key: 'ArrowUp', code: 38, charCode: 38}); + fireEvent(selectedItem, arrowUp); + expect(selectedItem).toHaveAttribute('aria-selected', 'true'); + expect(arrowUp.defaultPrevented).toBe(false); + let arrowDown = createEvent.keyDown(selectedItem, {key: 'ArrowDown', code: 40, charCode: 40}); + fireEvent(selectedItem, arrowDown); expect(selectedItem).toHaveAttribute('aria-selected', 'true'); + expect(arrowDown.defaultPrevented).toBe(false); }); it('allows user to change tab item select via arrow keys with vertical tabs', function () { @@ -890,4 +897,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; diff --git a/packages/@react-stately/toggle/src/useToggleState.ts b/packages/@react-stately/toggle/src/useToggleState.ts index 38fd3b71a3e..c3a6ca0a06a 100644 --- a/packages/@react-stately/toggle/src/useToggleState.ts +++ b/packages/@react-stately/toggle/src/useToggleState.ts @@ -10,10 +10,10 @@ * governing permissions and limitations under the License. */ -import {ToggleProps} from '@react-types/checkbox'; +import {ToggleStateOptions} from '@react-types/checkbox'; import {useControlledState} from '@react-stately/utils'; -export interface ToggleStateOptions extends Omit {} +export type {ToggleStateOptions}; export interface ToggleState { /** Whether the toggle is selected. */ diff --git a/packages/@react-types/checkbox/src/index.d.ts b/packages/@react-types/checkbox/src/index.d.ts index 656e867df1d..ef61aaf24cb 100644 --- a/packages/@react-types/checkbox/src/index.d.ts +++ b/packages/@react-types/checkbox/src/index.d.ts @@ -30,11 +30,7 @@ import { } from '@react-types/shared'; import {ReactElement, ReactNode} from 'react'; -export interface ToggleProps extends InputBase, Validation, FocusableProps { - /** - * The label for the element. - */ - children?: ReactNode, +export interface ToggleStateOptions extends InputBase { /** * Whether the element should be selected (uncontrolled). */ @@ -46,7 +42,14 @@ export interface ToggleProps extends InputBase, Validation, FocusablePr /** * Handler that is called when the element's selection state changes. */ - onChange?: (isSelected: boolean) => void, + onChange?: (isSelected: boolean) => void +} + +export interface ToggleProps extends ToggleStateOptions, Validation, FocusableProps { + /** + * The label for the element. + */ + children?: ReactNode, /** * The value of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefvalue). */