From f6e1bd91ac611da1ccd9899b00a21b3cabe72e7c Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 28 Jan 2025 07:34:17 +1100 Subject: [PATCH 1/2] feat: GridList autoFocus (#7640) * feat: GridList autoFocus * Update packages/@react-aria/gridlist/src/useGridList.ts Co-authored-by: Reid Barber --------- Co-authored-by: Reid Barber --- packages/@react-aria/gridlist/src/useGridList.ts | 6 +++++- packages/react-aria-components/test/GridList.test.js | 7 +++++++ packages/react-aria-components/test/ListBox.test.js | 6 ++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/@react-aria/gridlist/src/useGridList.ts b/packages/@react-aria/gridlist/src/useGridList.ts index 6497b499985..c088a151652 100644 --- a/packages/@react-aria/gridlist/src/useGridList.ts +++ b/packages/@react-aria/gridlist/src/useGridList.ts @@ -16,6 +16,7 @@ import { DisabledBehavior, DOMAttributes, DOMProps, + FocusStrategy, Key, KeyboardDelegate, LayoutDelegate, @@ -30,6 +31,8 @@ import {useHasTabbableChild} from '@react-aria/focus'; import {useSelectableList} from '@react-aria/selection'; export interface GridListProps extends CollectionBase, MultipleSelection { + /** Whether to auto focus the gridlist or an option. */ + autoFocus?: boolean | FocusStrategy, /** * Handler that is called when a user performs an action on an item. The exact user event depends on * the collection's `selectionBehavior` prop and the interaction modality. @@ -113,7 +116,8 @@ export function useGridList(props: AriaGridListOptions, state: ListState { expect(itemRef.current).toBeInstanceOf(HTMLElement); }); + it('should support autoFocus', () => { + let {getByRole} = renderGridList({autoFocus: true}); + let gridList = getByRole('grid'); + + expect(document.activeElement).toBe(gridList); + }); + it('should support hover', async () => { let onHoverStart = jest.fn(); let onHoverChange = jest.fn(); diff --git a/packages/react-aria-components/test/ListBox.test.js b/packages/react-aria-components/test/ListBox.test.js index dcab5c3e5c8..e22219bf574 100644 --- a/packages/react-aria-components/test/ListBox.test.js +++ b/packages/react-aria-components/test/ListBox.test.js @@ -337,6 +337,12 @@ describe('ListBox', () => { expect(getAllByRole('option').map(o => o.textContent)).toEqual(['Hi']); }); + it('should support autoFocus', () => { + let {getByRole} = renderListbox({autoFocus: true}); + let listbox = getByRole('listbox'); + expect(document.activeElement).toBe(listbox); + }); + it('should support hover', async () => { let hoverStartSpy = jest.fn(); let hoverChangeSpy = jest.fn(); From fd7075c5f0e06998ea8ac6a341dee165919fb6c1 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 28 Jan 2025 09:02:06 +1100 Subject: [PATCH 2/2] fix: tablist auto selection (#7529) * Fix tabs auto selection * fix lint * see if it passes on CI * remove forced selection in controlled * add a little more to the test --- .../tabs/src/useTabListState.ts | 2 +- .../react-aria-components/test/Tabs.test.js | 102 +++++++++++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/@react-stately/tabs/src/useTabListState.ts b/packages/@react-stately/tabs/src/useTabListState.ts index 74a55aaf09e..c9bc6c3c23d 100644 --- a/packages/@react-stately/tabs/src/useTabListState.ts +++ b/packages/@react-stately/tabs/src/useTabListState.ts @@ -43,7 +43,7 @@ export function useTabListState(props: TabListStateOptions) useEffect(() => { // Ensure a tab is always selected (in case no selected key was specified or if selected item was deleted from collection) let selectedKey = currentSelectedKey; - if (selectionManager.isEmpty || selectedKey == null || !collection.getItem(selectedKey)) { + if (props.selectedKey == null && (selectionManager.isEmpty || selectedKey == null || !collection.getItem(selectedKey))) { selectedKey = findDefaultSelectedKey(collection, state.disabledKeys); if (selectedKey != null) { // directly set selection because replace/toggle selection won't consider disabled keys diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index 6b39a181bcd..c617756f258 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ +import {Button, Collection, Tab, TabList, TabPanel, Tabs} from '../'; import {fireEvent, pointerMap, render, waitFor, within} from '@react-spectrum/test-utils-internal'; -import React from 'react'; -import {Tab, TabList, TabPanel, Tabs} from '../'; +import React, {useState} from 'react'; import {TabsExample} from '../stories/Tabs.stories'; import {User} from '@react-aria/test-utils'; import userEvent from '@testing-library/user-event'; @@ -497,4 +497,102 @@ describe('Tabs', () => { expect(innerTabs[0]).toHaveTextContent('One'); expect(innerTabs[1]).toHaveTextContent('Two'); }); + + it('can add tabs and keep the current selected key', async () => { + let onSelectionChange = jest.fn(); + function Example(props) { + let [tabs, setTabs] = useState([ + {id: 1, title: 'Tab 1', content: 'Tab body 1'}, + {id: 2, title: 'Tab 2', content: 'Tab body 2'}, + {id: 3, title: 'Tab 3', content: 'Tab body 3'} + ]); + + const [selectedTabId, setSelectedTabId] = useState(tabs[0].id); + + let addTab = () => { + const tabId = tabs.length + 1; + + setTabs((prevTabs) => [ + ...prevTabs, + { + id: tabId, + title: `Tab ${tabId}`, + content: `Tab body ${tabId}` + } + ]); + + // Use functional update to ensure you're working with the most recent state + setSelectedTabId(tabId); + }; + + let removeTab = () => { + if (tabs.length > 1) { + setTabs((prevTabs) => { + const updatedTabs = prevTabs.slice(0, -1); + // Update selectedTabId to the last remaining tab's ID if the current selected tab is removed + const newSelectedTabId = updatedTabs[updatedTabs.length - 1].id; + setSelectedTabId(newSelectedTabId); + return updatedTabs; + }); + } + }; + + const onSelectionChange = (value) => { + setSelectedTabId(value); + props.onSelectionChange(value); + }; + + return ( + +
+ + {(item) => ( + + {({isSelected}) => ( +

+ {item.title} +

+ )} +
+ )} +
+
+ + +
+
+ + {(item) => ( + + {item.content} + + )} + +
+ ); + } + let {getAllByRole} = render(); + let tabs = getAllByRole('tab'); + await user.tab(); + await user.keyboard('{ArrowRight}'); + expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); + await user.tab(); + onSelectionChange.mockClear(); + await user.keyboard('{Enter}'); + expect(onSelectionChange).not.toHaveBeenCalled(); + tabs = getAllByRole('tab'); + expect(tabs[3]).toHaveAttribute('aria-selected', 'true'); + + await user.tab(); + await user.keyboard('{Enter}'); + expect(onSelectionChange).not.toHaveBeenCalled(); + tabs = getAllByRole('tab'); + expect(tabs[2]).toHaveAttribute('aria-selected', 'true'); + }); });