Skip to content

Commit e7a38ef

Browse files
author
Pierre Poupin
authored
fix(lists): fix imperative focus not working properly on android (#149)
* fix(lists): fix imperative focus for lists not working on android * refactor(lists): rename deferred focus method * test(lists): add test for imperative focus on long lists
1 parent d9509dc commit e7a38ef

4 files changed

Lines changed: 79 additions & 16 deletions

File tree

packages/lib/src/spatial-navigation/SpatialNavigator.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Direction, Lrud } from '@bam.tech/lrud';
2+
import { isError } from './helpers/isError';
23

34
export type OnDirectionHandledWithoutMovement = (direction: Direction) => void;
45
type OnDirectionHandledWithoutMovementRef = { current: OnDirectionHandledWithoutMovement };
@@ -85,6 +86,15 @@ export default class SpatialNavigator {
8586
*/
8687
private focusQueue: string | null = null;
8788

89+
/**
90+
* In the case of virtualized lists, we have some race condition issues when trying
91+
* to imperatively assign focus.
92+
* Indeed, we need the list to scroll to the element and then focus it. But the element
93+
* needs to exist to be focused, so we need first to scroll then wait for the element to render
94+
* then focus it.
95+
*/
96+
private virtualNodeFocusQueue: string | null = null;
97+
8898
/**
8999
* To handle the default focus, we want to queue the element to be focused.
90100
* We queue it because it might not be registered yet when it asks for focus.
@@ -110,13 +120,19 @@ export default class SpatialNavigator {
110120
*
111121
* Still, I want to queue it, because the element might not be registered yet (example: in the case of virtualized lists)
112122
*/
113-
public deferredFocus = (id: string) => {
114-
setTimeout(() => {
123+
public grabFocusDeferred = (id: string) => {
124+
try {
115125
if (this.lrud.getNode(id)) {
116126
this.lrud.assignFocus(id);
117127
return;
118128
}
119-
}, 0);
129+
} catch (error) {
130+
// If the element exists but is not focusable, it is very likely that it will
131+
// have a focusable child soon. This is the case for imperative focus on virtualized lists.
132+
if (isError(error) && error.message === 'trying to assign focus to a non focusable node') {
133+
this.virtualNodeFocusQueue = id;
134+
}
135+
}
120136
};
121137

122138
/**
@@ -127,6 +143,7 @@ export default class SpatialNavigator {
127143
* when the element is finally registered.
128144
*/
129145
private handleQueuedFocus = () => {
146+
// Handle focus queue
130147
if (this.focusQueue && this.lrud.getNode(this.focusQueue)) {
131148
try {
132149
this.lrud.assignFocus(this.focusQueue);
@@ -135,6 +152,19 @@ export default class SpatialNavigator {
135152
// pass
136153
}
137154
}
155+
156+
// Handle virtual nodes (for virtualized lists) focus queue
157+
if (
158+
this.virtualNodeFocusQueue &&
159+
this.lrud.getNode(this.virtualNodeFocusQueue)?.children?.length !== 0
160+
) {
161+
try {
162+
this.lrud.assignFocus(this.virtualNodeFocusQueue);
163+
this.virtualNodeFocusQueue = null;
164+
} catch (e) {
165+
// pass
166+
}
167+
}
138168
};
139169

140170
public grabFocus = (id: string) => {

packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList.test.tsx

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { SpatialNavigationVirtualizedList } from './SpatialNavigationVirtualized
77
import { DefaultFocus } from '../../context/DefaultFocusContext';
88
import testRemoteControlManager from '../tests/helpers/testRemoteControlManager';
99
import { setComponentLayoutSize } from '../../../testing/setComponentLayoutSize';
10-
import { useRef } from 'react';
10+
import { useMemo, useRef } from 'react';
1111
import { SpatialNavigationVirtualizedListRef } from '../../types/SpatialNavigationVirtualizedListRef';
1212
import { SpatialNavigationNode } from '../Node';
1313

@@ -26,10 +26,13 @@ describe('SpatialNavigationVirtualizedList', () => {
2626
<TestButton title={`button ${index + 1}`} onSelect={item.onSelect} />
2727
);
2828

29-
const data = Array.from({ length: 10 }, (_, index) => ({
30-
onSelect: () => undefined,
31-
currentItemSize: index % 2 === 0 ? 100 : 200,
32-
}));
29+
const generateData = (size = 10) =>
30+
Array.from({ length: size }, (_, index) => ({
31+
onSelect: () => undefined,
32+
currentItemSize: index % 2 === 0 ? 100 : 200,
33+
}));
34+
35+
const data = generateData();
3336

3437
const dataWithVariableSizes = Array.from({ length: 10 }, (_, index) => ({
3538
onSelect: () => undefined,
@@ -56,8 +59,15 @@ describe('SpatialNavigationVirtualizedList', () => {
5659
</SpatialNavigationRoot>,
5760
);
5861

59-
const VirtualizedListWithNavigationButtons = () => {
60-
const listRef = useRef<SpatialNavigationVirtualizedListRef>(null);
62+
// This is bad practice but easiest way to access the ref from outside the component for testing
63+
let currentListRef: React.RefObject<SpatialNavigationVirtualizedListRef>;
64+
const VirtualizedListWithNavigationButtons = ({ listSize = 10 }) => {
65+
currentListRef = useRef<SpatialNavigationVirtualizedListRef>(null);
66+
67+
const data = useMemo(() => {
68+
return generateData(listSize);
69+
}, [listSize]);
70+
6171
return (
6272
<SpatialNavigationRoot>
6373
<SpatialNavigationNode orientation="vertical">
@@ -70,19 +80,19 @@ describe('SpatialNavigationVirtualizedList', () => {
7080
itemSize={100}
7181
numberOfRenderedItems={5}
7282
numberOfItemsVisibleOnScreen={3}
73-
ref={listRef}
83+
ref={currentListRef}
7484
/>
7585
</DefaultFocus>
76-
<TestButton title="Go to first" onSelect={() => listRef.current?.focus(0)} />
77-
<TestButton title="Go to last" onSelect={() => listRef.current?.focus(9)} />
86+
<TestButton title="Go to first" onSelect={() => currentListRef.current?.focus(0)} />
87+
<TestButton title="Go to last" onSelect={() => currentListRef.current?.focus(9)} />
7888
</>
7989
</SpatialNavigationNode>
8090
</SpatialNavigationRoot>
8191
);
8292
};
8393

84-
const renderVirtualizedListWithNavigationButtons = () =>
85-
render(<VirtualizedListWithNavigationButtons />);
94+
const renderVirtualizedListWithNavigationButtons = (size = 10) =>
95+
render(<VirtualizedListWithNavigationButtons listSize={size} />);
8696

8797
it('renders the correct number of item', async () => {
8898
const component = renderList();
@@ -626,4 +636,26 @@ describe('SpatialNavigationVirtualizedList', () => {
626636
expectButtonToHaveFocus(component, 'button 10');
627637
expectListToHaveScroll(listElement, -700);
628638
});
639+
640+
it('jumps to first and last element for a huge list, even while focus is still on the list', async () => {
641+
const component = renderVirtualizedListWithNavigationButtons(1000);
642+
act(() => jest.runAllTimers());
643+
644+
setComponentLayoutSize(listTestId, component, { width: 300, height: 300 });
645+
const listElement = await component.findByTestId(listTestId);
646+
647+
testRemoteControlManager.handleRight();
648+
testRemoteControlManager.handleRight();
649+
expectButtonToHaveFocus(component, 'button 3');
650+
expectListToHaveScroll(listElement, -200);
651+
652+
act(() => {
653+
currentListRef?.current?.focus(999);
654+
});
655+
expectButtonToHaveFocus(component, 'button 1000');
656+
act(() => {
657+
currentListRef?.current?.focus(0);
658+
});
659+
expectButtonToHaveFocus(component, 'button 1');
660+
});
629661
});

packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedListWithScroll.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export const SpatialNavigationVirtualizedListWithScroll = typedMemo(
204204
setCurrentlyFocusedItemIndex(index);
205205
if (idRef.current) {
206206
const newId = idRef.current.getNthVirtualNodeID(index);
207-
spatialNavigator.deferredFocus(newId);
207+
spatialNavigator.grabFocusDeferred(newId);
208208
}
209209
},
210210
}),
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const isError = (e: unknown): e is Error => e instanceof Error;

0 commit comments

Comments
 (0)