diff --git a/packages/react-aria/src/selection/ListKeyboardDelegate.ts b/packages/react-aria/src/selection/ListKeyboardDelegate.ts index 06e736447c6..8dab4534f9f 100644 --- a/packages/react-aria/src/selection/ListKeyboardDelegate.ts +++ b/packages/react-aria/src/selection/ListKeyboardDelegate.ts @@ -277,14 +277,14 @@ export class ListKeyboardDelegate implements KeyboardDelegate { return null; } - getFirstKey(): Key | null { - let key = this.collection.getFirstKey(); - return this.findNextNonDisabled(key, key => this.collection.getKeyAfter(key)); + getFirstKey(key?: Key, includeDisabled?: boolean): Key | null { + let firstKey = this.collection.getFirstKey(); + return this.findNextNonDisabled(firstKey, k => this.collection.getKeyAfter(k), includeDisabled); } - getLastKey(): Key | null { - let key = this.collection.getLastKey(); - return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key)); + getLastKey(key?: Key, includeDisabled?: boolean): Key | null { + let lastKey = this.collection.getLastKey(); + return this.findNextNonDisabled(lastKey, k => this.collection.getKeyBefore(k), includeDisabled); } getKeyPageAbove(key: Key): Key | null { diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index a8599907985..56700c37f91 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -450,7 +450,11 @@ export function useSelectableCollection( ) { navigateToKey(manager.lastSelectedKey ?? delegate.getLastKey?.()); } else { - navigateToKey(manager.firstSelectedKey ?? delegate.getFirstKey?.()); + let firstKey = manager.firstSelectedKey ?? delegate.getFirstKey?.(); + if (firstKey && manager.disabledKeys.has(firstKey)) { + firstKey = delegate.getFirstKey?.(); + } + navigateToKey(firstKey); } } else if (scrollRef.current) { // Restore the scroll position to what it was before. @@ -572,17 +576,27 @@ export function useSelectableCollection( let selectedKeys = manager.selectedKeys; if (selectedKeys.size) { for (let key of selectedKeys) { - if (manager.canSelectItem(key)) { + if (manager.canSelectItem(key) && !manager.disabledKeys.has(key)) { focusedKey = key; break; } } } + if (focusedKey == null || manager.disabledKeys.has(focusedKey)) { + let firstEnabledKey = delegate.getFirstKey?.() ?? null; + + if (firstEnabledKey != null) { + focusedKey = firstEnabledKey; + } + } + manager.setFocused(true); - manager.setFocusedKey(focusedKey); - // If no default focus key is selected, focus the collection itself. + if (focusedKey != null) { + manager.setFocusedKey(focusedKey); + } + if (focusedKey == null && !shouldUseVirtualFocus && ref.current) { focusSafely(ref.current); } diff --git a/packages/react-aria/test/selection/useSelectableCollection.test.js b/packages/react-aria/test/selection/useSelectableCollection.test.js index 294743740b3..fc99ad9e35b 100644 --- a/packages/react-aria/test/selection/useSelectableCollection.test.js +++ b/packages/react-aria/test/selection/useSelectableCollection.test.js @@ -157,4 +157,47 @@ describe('useSelectableCollection', () => { expect(onSelectionChange2).not.toHaveBeenCalled(); }); }); + // ========================================================================= + // ADDED: Tests for skipping disabled items automatically on mount/focus + // ========================================================================= + describe('with disabled items boundary keys', () => { + it('should automatically focus the first available option when the first 2 options are disabled on autoFocus', async () => { + let {getAllByRole} = render( + + Disabled Item 1 + Disabled Item 2 + First Enabled Item + Second Enabled Item + + ); + + let options = getAllByRole('option'); + + // Let any asynchronous layout microtasks complete smoothly + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(document.activeElement).toBe(options[2]); + expect(options[2].textContent).toBe('First Enabled Item'); + }); + + it('should automatically focus the first available option when tabbing into the collection with leading disabled keys', async () => { + let {getAllByRole} = render( + + Disabled Item 1 + Disabled Item 2 + First Enabled Item + Second Enabled Item + + ); + + let options = getAllByRole('option'); + + // Wrap the simulated user tabbing action cleanly in an async act block + // to handle the synchronous JSDOM focus state mutation + await user.tab(); + + expect(document.activeElement).toBe(options[2]); + expect(options[2].textContent).toBe('First Enabled Item'); + }); + }); });