Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ The following is a curated list of changes in the Enact limestone module, newest

- `limestone/ImageItem` styling to match the latest GUI

### Fixed

- `limestone/VirtualList` focus jump and scroll freeze when scrolled by long press

## [1.10.1] - 2026-06-01

### Deprecated
Expand Down
45 changes: 45 additions & 0 deletions VirtualList/tests/useEvent-specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const keyDownUp = (keyCode) => (elm) => {
return fireEvent.keyUp(elm, {keyCode});
};

const keyDownRepeat = (keyCode) => (elm) => fireEvent.keyDown(elm, {keyCode, repeat: true});

const pressLeftKey = keyDownUp(37);
const pressRightKey = keyDownUp(39);
const pressUpKey = keyDownUp(38);
Expand Down Expand Up @@ -169,6 +171,49 @@ describe('VirtualList useEvent', () => {
global.Element.prototype.scrollTo = scrollToFn;
});

test('should handle repeat keydown on first VirtualList entry without error', () => {
render(
<VirtualList
clientSize={clientSize}
dataSize={dataSize}
itemRenderer={renderItem}
itemSize={itemSize}
/>
);

const list = screen.getByRole('list');
const item0 = list.children.item(0).children.item(0);

focus(item0);
expect(currentFocusIndex).toBe(0);

keyDownRepeat(40)(item0);
expect(currentFocusIndex).toBe(0);
});

test('should handle repeat keydown when data-index jumps unexpectedly without error', () => {
render(
<VirtualList
clientSize={clientSize}
dataSize={dataSize}
itemRenderer={renderItem}
itemSize={itemSize}
/>
);

const list = screen.getByRole('list');
const item0 = list.children.item(0).children.item(0);
const item1 = list.children.item(1).children.item(0);

focus(item0);
pressDownKey(item0);
expect(currentFocusIndex).toBe(1);

item1.dataset.index = '20';
keyDownRepeat(40)(item1);
expect(currentFocusIndex).toBe(1);
});

test('should scroll by page-down key', () => {
const spy = jest.fn(() => {});
const scrollToFn = global.Element.prototype.scrollTo;
Expand Down
53 changes: 43 additions & 10 deletions VirtualList/useEvent.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@ const
return number >= 0 ? number : -1;
};

let prevKeyDownIndex = -1;

const useEventKey = (props, instances, context) => {
// Mutable value

const mutableRef = useRef({
fn: null
});

const prevKeyDownIndexRef = useRef(-1);
const hasProcessedKeyDownRef = useRef(false);

// Functions

const findSpottableItem = useCallback((indexFrom, indexTo) => {
Expand Down Expand Up @@ -116,6 +117,7 @@ const useEventKey = (props, instances, context) => {
handle5WayKeyUp,
handleDirectionKeyDown,
handlePageUpDownKeyDown,
resetAccelerator,
spotlightAcceleratorProcessKey
} = context;

Expand Down Expand Up @@ -150,17 +152,47 @@ const useEventKey = (props, instances, context) => {
} else if (index >= 0 && candidateIndex !== index) { // the focused node is an item and focus will move out of the item
const {repeat} = ev;
const {isDownKey, isUpKey, isLeftMovement, isRightMovement, isWrapped, nextIndex} = getNextIndex({index, keyCode, repeat});
const {dimensionToExtent} = scrollContentHandle.current;

if (nextIndex >= 0) { // if the candidate is another item
// VirtualList recycles DOM nodes during scroll. If a node gets reused for a different index
// while browser focus stays on it, target.dataset.index reflects the new (wrong) index.
// Detect this by checking if the index jumped unnaturally during key repeat.
const isOutdatedIndex = repeat && prevKeyDownIndexRef.current !== -1 && (
(isDownKey && (prevKeyDownIndexRef.current > index || index > prevKeyDownIndexRef.current + dimensionToExtent)) ||
(isUpKey && (prevKeyDownIndexRef.current < index || index < prevKeyDownIndexRef.current - dimensionToExtent))
);

// Block the first repeat event when entering VirtualList from outside with acceleration.
// prevKeyDownIndexRef is -1 only on first entry; a repeat here means key was held before entering.
const isFirstEntryRepeat = repeat && !hasProcessedKeyDownRef.current;

if (isFirstEntryRepeat) {
ev.preventDefault();
ev.stopPropagation();
resetAccelerator();
return;
}

if (repeat && prevKeyDownIndex !== -1 &&
((isDownKey && prevKeyDownIndex > index) || (isUpKey && prevKeyDownIndex < index))) {
// Ignore keyEvent from item with wrong data-index (Workaround for data-index bug)
// Sometimes keyDown event occurs before the data-index updated, it causes reverse focus change
return;
if (isOutdatedIndex) {
ev.preventDefault();
ev.stopPropagation();
resetAccelerator();
const correctedNextIndex = isDownKey ? prevKeyDownIndexRef.current + 1 : prevKeyDownIndexRef.current - 1;
if (correctedNextIndex >= 0 && correctedNextIndex < props.dataSize) {
const currentItemNode = instances.itemRefs.current[prevKeyDownIndexRef.current % scrollContentHandle.current.state.numOfItems];
const correctedTarget = (currentItemNode && parseInt(currentItemNode.dataset.index) === prevKeyDownIndexRef.current) ? currentItemNode : target;
handleDirectionKeyDown(ev, 'acceleratedKeyDown', {
isWrapped: false, keyCode, nextIndex: correctedNextIndex, repeat, target: correctedTarget
});
prevKeyDownIndexRef.current = correctedNextIndex;
}
// At list boundary: keep prevKeyDownIndexRef.current unchanged so next event stays recoverable
return;
}

if (nextIndex >= 0) { // if the candidate is another item
ev.preventDefault();
ev.stopPropagation();

if (props.scrollContainerHandle && props.scrollContainerHandle.current) {
props.scrollContainerHandle.current.lastInputType = 'arrowKey';
Expand All @@ -169,7 +201,7 @@ const useEventKey = (props, instances, context) => {
handleDirectionKeyDown(ev, 'acceleratedKeyDown', {isWrapped, keyCode, nextIndex, repeat, target});
} else { // if the candidate is not found
const {dataSize, focusableScrollbar, isHorizontalScrollbarVisible, isVerticalScrollbarVisible} = props;
const {dimensionToExtent, isPrimaryDirectionVertical} = scrollContentHandle.current;
const {isPrimaryDirectionVertical} = scrollContentHandle.current;
const column = index % dimensionToExtent;
const row = (index - column) % dataSize / dimensionToExtent;
const directions = {};
Expand Down Expand Up @@ -215,7 +247,8 @@ const useEventKey = (props, instances, context) => {
}
}

prevKeyDownIndex = index;
prevKeyDownIndexRef.current = index;
hasProcessedKeyDownRef.current = true;

if (isLeaving) {
handleDirectionKeyDown(ev, 'keyLeave');
Expand Down
3 changes: 3 additions & 0 deletions VirtualList/useThemeVirtualList.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ const useSpottable = (props, instances) => {
handle5WayKeyUp: () => {
SpotlightAccelerator.reset();
},
resetAccelerator: () => {
SpotlightAccelerator.reset();
},
spotlightAcceleratorProcessKey: (ev) => {
return SpotlightAccelerator.processKey(ev, nop);
}
Expand Down
Loading