diff --git a/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx b/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx new file mode 100644 index 0000000000..2d8bef5309 --- /dev/null +++ b/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx @@ -0,0 +1,284 @@ +import { createRef, type RefObject, useContext, useEffect } from 'react'; +import { render, renderHook } from '@testing-library/react'; +import { noop } from '@vkontakte/vkjs'; +import { setRef } from '../../lib/utils'; +import { + ElementScrollController, + GlobalScrollController, + ScrollContext, + type ScrollContextInterface, + useScrollLock, +} from './ScrollContext'; + +type ChildWithContextProps = { + contextRef?: RefObject; + beforeScrollLockFn?: VoidFunction; +}; +const ChildWithContext = ({ contextRef, beforeScrollLockFn }: ChildWithContextProps) => { + const context = useContext(ScrollContext); + useEffect(() => { + if (context.beforeScrollLockFnSetRef?.current && beforeScrollLockFn) { + context.beforeScrollLockFnSetRef?.current.add(beforeScrollLockFn); + } + setRef(context, contextRef); + }, [beforeScrollLockFn, context, contextRef]); + return
; +}; + +describe(useScrollLock, () => { + describe('ScrollContext', () => { + test('default', () => { + const h = renderHook(() => useContext(ScrollContext)); + expect(h.result.current.getScroll()).toEqual({ x: 0, y: 0 }); + }); + }); + + describe(GlobalScrollController, () => { + test.each([true, false])('default behavior (width scroll: %s)', (withScroll) => { + const clearWindowMeasuresMock = withScroll ? noop : mockWindowMeasures(-1, -1); + + const beforeScrollLockFn = jest.fn(); + const h = renderHook(useScrollLock, { + wrapper: ({ children }) => ( + ()}> + {children} + + + ), + }); + expect(beforeScrollLockFn).toHaveBeenCalled(); + + expect(getStyleAttributeObject(document.body)).toEqual({ + position: 'fixed', + top: `-${0}px`, + left: `-${0}px`, + right: '0px', + ...(withScroll + ? { + 'overflow-x': 'scroll', + 'overflow-y': 'scroll', + } + : {}), + }); + expect(jestWorkaroundGetOverscrollBehaviorPropertyValue(document.body)).toBe('none'); + expect(jestWorkaroundGetOverscrollBehaviorPropertyValue(document.documentElement)).toBe('none'); // prettier-ignore + + h.rerender(false); + expect(getStyleAttributeObject(document.body)).toEqual({}); + expect(jestWorkaroundGetOverscrollBehaviorPropertyValue(document.body)).toBe(''); + expect(jestWorkaroundGetOverscrollBehaviorPropertyValue(document.documentElement)).toBe(''); + + clearWindowMeasuresMock(); + }); + + test('context api', () => { + const contextRef = createRef(); + render( + ()}> + + , + ); + + const clearWindowMeasuresMock = mockWindowMeasures(50, 50); + const clearElementScrollMock = mockElementScroll(document.body, 100, 100); + const clearMockWindowScrollToMock = mockWindowScrollTo(); + + expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 50 }); + expect(contextRef.current?.getScroll({ compensateKeyboardHeight: false })).toEqual({ + x: 0, + y: 0, + }); + contextRef.current?.scrollTo(10, 10); + expect(contextRef.current?.getScroll()).toEqual({ x: 10, y: 60 }); + contextRef.current?.scrollTo(); + expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 50 }); + + clearWindowMeasuresMock(); + clearElementScrollMock(); + clearMockWindowScrollToMock(); + }); + }); + + describe(ElementScrollController, () => { + test.each([true, false])('default behavior (width scroll: %s)', (withScroll) => { + const elRef = createRef(); + setRef(document.createElement('div'), elRef); + + const clearElementMeasuresMock = withScroll + ? mockElementMeasures(elRef.current!, -1, -1) + : mockElementMeasures(elRef.current!, 1, 1); + + const beforeScrollLockFn = jest.fn(); + const h = renderHook(useScrollLock, { + wrapper: ({ children }) => ( + + {children} + + + ), + }); + expect(beforeScrollLockFn).toHaveBeenCalled(); + + expect(getStyleAttributeObject(elRef.current)).toEqual({ + position: 'absolute', + top: `-${0}px`, + left: `-${0}px`, + right: '0px', + ...(withScroll + ? { + 'overflow-x': 'scroll', + 'overflow-y': 'scroll', + } + : {}), + }); + + h.rerender(false); + expect(getStyleAttributeObject(elRef.current)).toEqual({}); + + clearElementMeasuresMock(); + }); + + test('el is null', () => { + const elRef = createRef(); + const h = renderHook(useScrollLock, { + wrapper: ({ children }) => ( + {children} + ), + }); + expect(getStyleAttributeObject(elRef.current)).toBeNull(); + + h.rerender(false); + expect(getStyleAttributeObject(elRef.current)).toBeNull(); + }); + + test('context api', () => { + const contextRef = createRef(); + const elRef = createRef(); + setRef(document.createElement('div'), elRef); + render( + + + , + ); + + const clearElementMeasuresMock = mockElementMeasures(elRef.current!, 50, 50); + const clearElementScrollMock = mockElementScroll(elRef.current!, 100, 100); + const clearElementScrollToMock = mockElementScrollTo(elRef.current!); + + expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 0 }); + contextRef.current?.scrollTo(10, 10); + expect(contextRef.current?.getScroll()).toEqual({ x: 10, y: 10 }); + contextRef.current?.scrollTo(); + expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 0 }); + + clearElementMeasuresMock(); + clearElementScrollMock(); + clearElementScrollToMock(); + }); + + test('context api when el is null', () => { + const contextRef = createRef(); + const elRef = createRef(); + render( + + + , + ); + + expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 0 }); + contextRef.current?.scrollTo(10, 10); + expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 0 }); + contextRef.current?.scrollTo(); + expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 0 }); + }); + }); +}); + +/** + * В Jest через `el.getAttribute('style')` не получается получить свойство. + */ +function jestWorkaroundGetOverscrollBehaviorPropertyValue(el: HTMLElement) { + return el.style.overscrollBehavior; +} + +function getStyleAttributeObject(el: HTMLElement | null) { + const style = el ? el.getAttribute('style') : null; + + if (style === null) { + return null; + } + + return Object.fromEntries( + style + .split(';') + .map((style) => + style + .trim() + .split(':') + .map((part) => part && part.trim()), + ) + .filter(([key, value]) => key && value) + .map(([key, value]) => [key, value]), + ); +} + +function mockWindowMeasures(width: number, height: number) { + const originalW = window.innerWidth; + const originalH = window.innerHeight; + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: height }); + return function clearMock() { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalW }); + Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalH }); + }; +} + +function mockWindowScrollTo() { + const original = window.scrollTo; + Object.defineProperty(window, 'scrollTo', { + configurable: true, + value: (x: number, y: number) => { + Object.defineProperty(window, 'pageXOffset', { configurable: true, value: x }); + Object.defineProperty(window, 'pageYOffset', { configurable: true, value: y }); + }, + }); + return function clearMock() { + Object.defineProperty(window, 'scrollTo', { configurable: true, value: original }); + }; +} + +function mockElementMeasures(el: HTMLElement, width: number, height: number) { + const originalW = el.clientWidth; + const originalH = el.clientHeight; + Object.defineProperty(el, 'clientWidth', { configurable: true, value: width }); + Object.defineProperty(el, 'clientHeight', { configurable: true, value: height }); + return function clearMock() { + Object.defineProperty(el, 'clientWidth', { configurable: true, value: originalW }); + Object.defineProperty(el, 'clientHeight', { configurable: true, value: originalH }); + }; +} + +function mockElementScroll(el: HTMLElement, width: number, height: number) { + const originalW = el.scrollWidth; + const originalH = el.scrollHeight; + Object.defineProperty(el, 'scrollWidth', { configurable: true, value: width }); + Object.defineProperty(el, 'scrollHeight', { configurable: true, value: height }); + return function clearMock() { + Object.defineProperty(el, 'scrollWidth', { configurable: true, value: originalW }); + Object.defineProperty(el, 'scrollHeight', { configurable: true, value: originalH }); + }; +} + +function mockElementScrollTo(el: HTMLElement) { + const original = el.scrollTo.bind(el); + Object.defineProperty(el, 'scrollTo', { + configurable: true, + value: (x: number, y: number) => { + Object.defineProperty(el, 'scrollLeft', { configurable: true, value: x }); + Object.defineProperty(el, 'scrollTop', { configurable: true, value: y }); + }, + }); + return function clearMock() { + Object.defineProperty(el, 'scrollTo', { configurable: true, value: original }); + }; +} diff --git a/packages/vkui/src/components/AppRoot/ScrollContext.tsx b/packages/vkui/src/components/AppRoot/ScrollContext.tsx index 48e9c10345..93c80091de 100644 --- a/packages/vkui/src/components/AppRoot/ScrollContext.tsx +++ b/packages/vkui/src/components/AppRoot/ScrollContext.tsx @@ -13,6 +13,7 @@ const clearDisableScrollStyle = (node: HTMLElement) => { top: '', left: '', right: '', + overscrollBehavior: '', overflowY: '', overflowX: '', }); @@ -117,11 +118,13 @@ export const GlobalScrollController = ({ children }: ScrollControllerProps): Rea const overflowY = window!.innerWidth > document!.documentElement.clientWidth ? 'scroll' : ''; const overflowX = window!.innerHeight > document!.documentElement.clientHeight ? 'scroll' : ''; + Object.assign(document!.documentElement.style, { overscrollBehavior: 'none' }); Object.assign(document!.body.style, { position: 'fixed', top: `-${scrollY}px`, left: `-${scrollX}px`, right: '0', + overscrollBehavior: 'none', overflowY, overflowX, }); @@ -131,6 +134,7 @@ export const GlobalScrollController = ({ children }: ScrollControllerProps): Rea const scrollY = document!.body.style.top; const scrollX = document!.body.style.left; + Object.assign(document!.documentElement.style, { overscrollBehavior: '' }); clearDisableScrollStyle(document!.body); window!.scrollTo(-parseInt(scrollX || '0'), -parseInt(scrollY || '0')); }, [document, window]);