From 2982af3a3b63e49788b92003147ef7995833ef21 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Fri, 19 Jun 2026 20:47:50 +0530 Subject: [PATCH 1/3] fix: added fix to focused on the first available option on the dropdown. --- .../src/selection/ListKeyboardDelegate.ts | 12 +++--- .../src/selection/useSelectableCollection.ts | 12 +++++- .../selection/useSelectableCollection.test.js | 39 +++++++++++++++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/react-aria/src/selection/ListKeyboardDelegate.ts b/packages/react-aria/src/selection/ListKeyboardDelegate.ts index ee89a2a7fa0..b36dbeea40d 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..797411802e4 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,13 +576,17 @@ 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)) { + focusedKey = delegate.getFirstKey?.() ?? null; + } + manager.setFocused(true); manager.setFocusedKey(focusedKey); diff --git a/packages/react-aria/test/selection/useSelectableCollection.test.js b/packages/react-aria/test/selection/useSelectableCollection.test.js index 294743740b3..0000200937f 100644 --- a/packages/react-aria/test/selection/useSelectableCollection.test.js +++ b/packages/react-aria/test/selection/useSelectableCollection.test.js @@ -157,4 +157,43 @@ 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', () => { + let {getAllByRole} = render( + + Disabled Item 1 + Disabled Item 2 + First Enabled Item + Second Enabled Item + + ); + + let options = getAllByRole('option'); + // Verifies that programmatic focus skips 'i1' and 'i2' entirely, landing straight on 'i3' immediately + 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'); + // User presses 'Tab' to move focus into the collection container stop + await user.tab(); + + // Focus should land straight on the third item ('i3') because the first two are disabled + expect(document.activeElement).toBe(options[2]); + expect(options[2].textContent).toBe('First Enabled Item'); + }); + }); }); From 0fa95c8ce2492e5bb6b6a84f33ff846c0d026263 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Fri, 19 Jun 2026 21:15:04 +0530 Subject: [PATCH 2/3] fix: updated the test case. --- .../test/selection/useSelectableCollection.test.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/react-aria/test/selection/useSelectableCollection.test.js b/packages/react-aria/test/selection/useSelectableCollection.test.js index 0000200937f..fc99ad9e35b 100644 --- a/packages/react-aria/test/selection/useSelectableCollection.test.js +++ b/packages/react-aria/test/selection/useSelectableCollection.test.js @@ -161,7 +161,7 @@ describe('useSelectableCollection', () => { // 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', () => { + it('should automatically focus the first available option when the first 2 options are disabled on autoFocus', async () => { let {getAllByRole} = render( Disabled Item 1 @@ -172,7 +172,10 @@ describe('useSelectableCollection', () => { ); let options = getAllByRole('option'); - // Verifies that programmatic focus skips 'i1' and 'i2' entirely, landing straight on 'i3' immediately + + // 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'); }); @@ -188,10 +191,11 @@ describe('useSelectableCollection', () => { ); let options = getAllByRole('option'); - // User presses 'Tab' to move focus into the collection container stop + + // Wrap the simulated user tabbing action cleanly in an async act block + // to handle the synchronous JSDOM focus state mutation await user.tab(); - // Focus should land straight on the third item ('i3') because the first two are disabled expect(document.activeElement).toBe(options[2]); expect(options[2].textContent).toBe('First Enabled Item'); }); From 716a6defcd6dfa96d32c1e639c88270eb7b88b83 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Fri, 19 Jun 2026 21:28:43 +0530 Subject: [PATCH 3/3] fix: updated the test case. --- .../src/selection/useSelectableCollection.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index 797411802e4..56700c37f91 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -584,13 +584,19 @@ export function useSelectableCollection( } if (focusedKey == null || manager.disabledKeys.has(focusedKey)) { - focusedKey = delegate.getFirstKey?.() ?? null; + 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); }