Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/react-aria/src/selection/ListKeyboardDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,14 @@ export class ListKeyboardDelegate<T> 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 {
Expand Down
22 changes: 18 additions & 4 deletions packages/react-aria/src/selection/useSelectableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
Expand Down
43 changes: 43 additions & 0 deletions packages/react-aria/test/selection/useSelectableCollection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<List selectionMode="single" autoFocus="first" disabledKeys={['i1', 'i2']}>
<Item key="i1">Disabled Item 1</Item>
<Item key="i2">Disabled Item 2</Item>
<Item key="i3">First Enabled Item</Item>
<Item key="i4">Second Enabled Item</Item>
</List>
);

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(
<List selectionMode="single" disabledKeys={['i1', 'i2']}>
<Item key="i1">Disabled Item 1</Item>
<Item key="i2">Disabled Item 2</Item>
<Item key="i3">First Enabled Item</Item>
<Item key="i4">Second Enabled Item</Item>
</List>
);

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');
});
});
});