Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ecd4440
added ui/resolution documentation
daniel-stoian-lgp Feb 4, 2026
c4460d0
changed jsdoc to comment
daniel-stoian-lgp Feb 5, 2026
9880edf
fixed documentation
daniel-stoian-lgp Feb 5, 2026
672e005
Apply suggestion from @daniel-stoian-lgp
daniel-stoian-lgp Feb 5, 2026
216c45c
Apply suggestion from @daniel-stoian-lgp
daniel-stoian-lgp Feb 5, 2026
0532782
Apply suggestion from @daniel-stoian-lgp
daniel-stoian-lgp Feb 5, 2026
58194a6
Update packages/ui/resolution/resolution.js
ion-andrusciac-lgp Feb 6, 2026
d5e06ed
Fixed lint warnings
ion-andrusciac-lgp Feb 6, 2026
1d64590
Merge branch 'develop' of https://github.com/enactjs/enact into featu…
ion-andrusciac-lgp Feb 6, 2026
28c4bfc
Merge remote-tracking branch 'refs/remotes/origin/develop' into featu…
daniel-stoian-lgp Feb 9, 2026
eb0b555
changed resolution config to private
daniel-stoian-lgp Feb 9, 2026
f3933d6
Merge branch 'feature/NXT-9808' of https://github.com/enactjs/enact i…
daniel-stoian-lgp Feb 9, 2026
35a717d
small fix
daniel-stoian-lgp Feb 9, 2026
2b2cec0
changed to public
daniel-stoian-lgp Feb 10, 2026
ada6b9e
merged jsdoc
daniel-stoian-lgp Feb 10, 2026
caf4e6c
added missing link
daniel-stoian-lgp Feb 10, 2026
3ece05a
small fix
daniel-stoian-lgp Feb 10, 2026
5046e8c
added support for scrollend
daniel-stoian-lgp Feb 11, 2026
c5f4a05
eslint fix
daniel-stoian-lgp Feb 11, 2026
f2977de
Merge remote-tracking branch 'origin/develop' into feature/NXT-9810
daniel-stoian-lgp Feb 11, 2026
b11794c
refactored duplicate code
daniel-stoian-lgp Feb 11, 2026
c4cf61a
refactored duplicate code
daniel-stoian-lgp Feb 11, 2026
a99baba
replaced jsdoc
daniel-stoian-lgp Feb 11, 2026
53695e1
added fix for key up
daniel-stoian-lgp Feb 12, 2026
091ada0
added fix for key up
daniel-stoian-lgp Feb 12, 2026
2ee96c5
Merge remote-tracking branch 'origin/develop' into feature/NXT-9810
daniel-stoian-lgp Feb 13, 2026
59dd0d7
added gracetime for scrollEnd
daniel-stoian-lgp Feb 13, 2026
9829b2f
removed redundant code
daniel-stoian-lgp Feb 13, 2026
f56b975
removed redundant code
daniel-stoian-lgp Feb 13, 2026
6a3a89c
added unit tests
daniel-stoian-lgp Feb 13, 2026
d597239
added unit tests
daniel-stoian-lgp Feb 13, 2026
4c210bd
added unit tests
daniel-stoian-lgp Feb 13, 2026
cb65964
added unit tests
daniel-stoian-lgp Feb 13, 2026
9ef7c6e
added unit tests
daniel-stoian-lgp Feb 13, 2026
aca9dbc
added fix for double scroll stop
daniel-stoian-lgp Feb 18, 2026
5f9c5aa
added extra check
daniel-stoian-lgp Feb 18, 2026
96f1355
Your commit message
daniel-stoian-lgp Feb 18, 2026
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
85 changes: 79 additions & 6 deletions packages/ui/useScroll/tests/useScroll-specs.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,17 @@ function createMockRefs () {
getScrollBounds: jest.fn(() => ({
clientWidth: 1920,
clientHeight: 1080,
scrollWidth: 1000,
scrollHeight: 800,
maxTop: 200,
maxLeft: 200
scrollWidth: 2000, // greater than clientWidth
scrollHeight: 2000, // greater than clientHeight
maxTop: 920, // 2000 - 1080 = 920
maxLeft: 80 // 2000 - 1920 = 80
})),
getMoreInfo: jest.fn(() => ({})),
hasDataSizeChanged: false,
syncClientSize: jest.fn(() => false),
getRtlPositionX: jest.fn((x) => x),
calculateMetrics: jest.fn(),
didScroll: jest.fn(),
props: {}
}
},
Expand All @@ -62,14 +63,16 @@ function createMockRefs () {
current: {
update: jest.fn(),
getContainerRef: jest.fn(() => document.createElement('div')),
startHidingScrollbarTrack: jest.fn()
startHidingScrollbarTrack: jest.fn(),
showScrollbarTrack: jest.fn()
}
},
verticalScrollbarHandle: {
current: {
update: jest.fn(),
getContainerRef: jest.fn(() => document.createElement('div')),
startHidingScrollbarTrack: jest.fn()
startHidingScrollbarTrack: jest.fn(),
showScrollbarTrack: jest.fn()
}
}
};
Expand Down Expand Up @@ -347,4 +350,74 @@ describe('useScroll', () => {
expect(roundedTargetY).toEqual(80.8);
});
});

describe('Native scroll behavior', () => {
beforeEach(() => {
jest.useFakeTimers();
mockPlatform = {chrome: 132};
mockRiScale = jest.fn((val) => val);
});

afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});

test('should update scroll position from scroll event', () => {
const mocks = createMockRefs();

const props = {
direction: 'vertical',
scrollMode: 'native',
...mocks,
assignProperties: jest.fn(),
horizontalScrollbar: 'auto',
verticalScrollbar: 'auto'
};

renderHook(() => useScrollBase(props));

const scrollEvent = new Event('scroll');
Object.defineProperty(scrollEvent, 'target', {
value: {scrollLeft: 50, scrollTop: 150},
writable: false
});

// eslint-disable-next-line testing-library/no-unnecessary-act
act(() => {
fireEvent(mocks.scrollContentRef.current, scrollEvent);
});

expect(mocks.scrollContentHandle.current.didScroll).toHaveBeenCalledWith(50, 150);
});

test('should handle RTL scroll position correctly', () => {
const mocks = createMockRefs();
mockPlatform = {chrome: 132};

const props = {
direction: 'horizontal',
scrollMode: 'native',
rtl: true,
...mocks,
assignProperties: jest.fn(),
horizontalScrollbar: 'auto',
verticalScrollbar: 'auto'
};

renderHook(() => useScrollBase(props));

const scrollEvent = new Event('scroll');
Object.defineProperty(scrollEvent, 'target', {
value: {scrollLeft: -50, scrollTop: 0}
});

// eslint-disable-next-line testing-library/no-unnecessary-act
act(() => {
fireEvent(mocks.scrollContentRef.current, scrollEvent);
});

expect(mocks.scrollContentHandle.current.didScroll).toHaveBeenCalledWith(50, 0);
});
});
});
71 changes: 65 additions & 6 deletions packages/ui/useScroll/useScroll.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ const useScrollBase = (props) => {

// Enable the early bail out of repeated scrolling to the same position
animationInfo: null,
scrollEndGraceTimer: null,

resizeRegistry: null,

Expand Down Expand Up @@ -844,17 +845,20 @@ const useScrollBase = (props) => {
// scrollMode 'translate' ]]

// scrollMode 'native' [[
function onScroll (ev) {
/**
* Updates scroll position from a scroll event.
* Handles RTL adjustment and position updates.
*
* @param {Event} ev - Scroll event
* @private
*/
function updateScrollPosition (ev) {
let {scrollLeft, scrollTop} = ev.target;

const
bounds = getScrollBounds(),
canScrollH = canScrollHorizontally(bounds);

if (!mutableRef.current.scrolling) {
scrollStartOnScroll();
}

if (rtl && canScrollH) {
scrollLeft = (platform.chrome < 85) ? bounds.maxLeft - scrollLeft : -scrollLeft;
}
Expand All @@ -869,9 +873,62 @@ const useScrollBase = (props) => {
if (scrollContentHandle.current.didScroll) {
scrollContentHandle.current.didScroll(mutableRef.current.scrollLeft, mutableRef.current.scrollTop);
}
}

function onScroll (ev) {
// Track if we had a grace timer active before clearing it
const hadGraceTimer = !!mutableRef.current.scrollEndGraceTimer;

if (mutableRef.current.scrollEndGraceTimer) {
clearTimeout(mutableRef.current.scrollEndGraceTimer);
mutableRef.current.scrollEndGraceTimer = null;
}

if (!mutableRef.current.scrolling) {
scrollStartOnScroll();
}

updateScrollPosition(ev);

if (mutableRef.current.lastInputType === 'wheel' && !mutableRef.current.isScrollAnimationTargetAccumulated) {
mutableRef.current.isScrollAnimationTargetAccumulated = true;
}

forwardScrollEvent('onScroll');
mutableRef.current.scrollStopJob.start();

if (!hadGraceTimer) {
mutableRef.current.scrollStopJob.start();
}
}

/*
* Handler for scrollend event
*/
function onScrollEnd (ev) {
if (!mutableRef.current.scrolling) {
return;
}

updateScrollPosition(ev);

// Stop the fallback timer since the native scrollend has fired
mutableRef.current.scrollStopJob.stop();

// stop for non-accumulating scrolls (mouse/touch)
if (!mutableRef.current.isScrollAnimationTargetAccumulated) {
scrollStopOnScroll();
return;
}

// This prevents spamming onScrollStop during smooth scroll continuation
if (mutableRef.current.scrollEndGraceTimer) {
clearTimeout(mutableRef.current.scrollEndGraceTimer);
}

mutableRef.current.scrollEndGraceTimer = setTimeout(() => {
mutableRef.current.scrollEndGraceTimer = null;
scrollStopOnScroll();
}, 100);
}
// scrollMode 'native' ]]

Expand Down Expand Up @@ -1553,6 +1610,7 @@ const useScrollBase = (props) => {
// scrollMode 'native' [[
if (scrollMode === 'native' && scrollContentRef.current) {
utilEvent('scroll').addEventListener(scrollContentRef, onScroll, {passive: true});
utilEvent('scrollend').addEventListener(scrollContentRef, onScrollEnd);
}
// scrollMode 'native' ]]

Expand All @@ -1574,6 +1632,7 @@ const useScrollBase = (props) => {

// scrollMode 'native' [[
utilEvent('scroll').removeEventListener(scrollContentRef, onScroll, {passive: true});
utilEvent('scrollend').removeEventListener(scrollContentRef, onScrollEnd);
// scrollMode 'native' ]]

if (props.removeEventListeners) {
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/useScroll/utilEvent.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
// A `React.useEvent` hooks is introduced in https://github.com/facebook/react/pull/17651
// The `useEvent` below will be replaced with the `React.useEvent` later.

/*
* Detects if the browser natively supports the scrollend event.
*/
const supportsScrollEnd = () => {
if (typeof window === 'undefined') {
return false;
}
return 'onscrollend' in window;
};

const utilEvent = (eventName) => {
const isScrollEndEvent = eventName === 'scrollend';
const hasNativeScrollEnd = isScrollEndEvent && supportsScrollEnd();

return {
addEventListener (ref, fn, param) {
if (!ref) return;

if (isScrollEndEvent && !hasNativeScrollEnd) {
return;
}

if (typeof window !== 'undefined' && (ref === window || ref === document)) {
ref.addEventListener(eventName, fn, param);
} else if (ref.current) {
Expand All @@ -17,6 +35,10 @@ const utilEvent = (eventName) => {
removeEventListener (ref, fn, param) {
if (!ref) return;

if (isScrollEndEvent && !hasNativeScrollEnd) {
return;
}

if (typeof window !== 'undefined' && (ref === window || ref === document)) {
ref.removeEventListener(eventName, fn, param);
} else if (ref.current) {
Expand Down