From 4ef4317981574ea7bcf9d10decfe4d030a376f3c Mon Sep 17 00:00:00 2001 From: Sagirov Eugeniy Date: Tue, 21 Mar 2023 14:33:04 +0200 Subject: [PATCH 1/2] feat: Add fixed increment scroll behavior to useOverflowScroll --- src/OverflowScroll/OverflowScroll.jsx | 4 ++ src/OverflowScroll/data/useOverflowScroll.js | 6 +++ .../data/useOverflowScrollActions.js | 47 +++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/OverflowScroll/OverflowScroll.jsx b/src/OverflowScroll/OverflowScroll.jsx index 1d321ad239..65f34549af 100644 --- a/src/OverflowScroll/OverflowScroll.jsx +++ b/src/OverflowScroll/OverflowScroll.jsx @@ -13,6 +13,7 @@ function OverflowScroll({ disableOpacityMasks, onScrollPrevious, onScrollNext, + offset, }) { const [overflowRef, setOverflowRef] = useState(); @@ -29,6 +30,7 @@ function OverflowScroll({ onScrollPrevious, onScrollNext, overflowRef, + offset, }); const contextValue = useMemo(() => ({ @@ -80,6 +82,7 @@ OverflowScroll.propTypes = { onScrollPrevious: PropTypes.func, /** Callback function for when the user scrolls to the next element. */ onScrollNext: PropTypes.func, + offset: PropTypes.string, }; OverflowScroll.defaultProps = { @@ -89,6 +92,7 @@ OverflowScroll.defaultProps = { disableOpacityMasks: false, onScrollPrevious: undefined, onScrollNext: undefined, + offset: undefined, }; export default OverflowScroll; diff --git a/src/OverflowScroll/data/useOverflowScroll.js b/src/OverflowScroll/data/useOverflowScroll.js index d2b96fd1b4..8ea647b9c3 100644 --- a/src/OverflowScroll/data/useOverflowScroll.js +++ b/src/OverflowScroll/data/useOverflowScroll.js @@ -27,6 +27,7 @@ import getOverflowElementScrollLeft from './getOverflowElementScrollLeft'; * @param {boolean} args.disableOpacityMasks Whether the start/end opacity masks should be shown, when applicable. * @param {string} args.scrollAnimationBehavior Optional override for the scroll behavior. See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTo for * more details. + * @param {string} args.offset Fixed increment in pixels or percentage for scroll. * * @returns {object} An object with the following properties: * - overflowRef @@ -45,7 +46,9 @@ const useOverflowScroll = ({ disableScroll = false, disableOpacityMasks = false, scrollAnimationBehavior = 'smooth', + offset, }) => { + const [currentOffset, setCurrentOffset] = useState(0); const [isScrolledToStart, setIsScrolledToStart] = useState(true); const [isScrolledToEnd, setIsScrolledToEnd] = useState(true); @@ -153,6 +156,9 @@ const useOverflowScroll = ({ scrollAnimationBehavior, onScrollPrevious: handleScrollPrevious, onScrollNext: handleScrollNext, + onChangeOffset: setCurrentOffset, + currentOffset, + offset, }); return { diff --git a/src/OverflowScroll/data/useOverflowScrollActions.js b/src/OverflowScroll/data/useOverflowScrollActions.js index d1cb313b61..7de5ae7de4 100644 --- a/src/OverflowScroll/data/useOverflowScrollActions.js +++ b/src/OverflowScroll/data/useOverflowScrollActions.js @@ -15,7 +15,24 @@ const useOverflowScrollActions = ({ scrollAnimationBehavior = 'smooth', onScrollPrevious, onScrollNext, + onChangeOffset, + currentOffset, + offset, }) => { + + const getOffset = (offsetString, maxOffset) => { + let offset = 0; + + if (offsetString.endsWith("px")) { + offset = parseInt(offsetString); + } else if (offsetString.endsWith("%")) { + const percent = parseInt(offsetString) / 100; + offset = Math.round(maxOffset * percent); + } + + return offset; + } + /** * A helper function to scroll to the previous element in the overflow container. */ @@ -34,7 +51,19 @@ const useOverflowScrollActions = ({ const previousChildElementIndex = activeChildElementIndex - 1; const previousChildElement = getPreviousChildElement(previousChildElementIndex); - const calculatedOffsetLeft = calculateOffsetLeft(previousChildElement); + let calculatedOffsetLeft = calculateOffsetLeft(previousChildElement); + + if (offset) { + const maxOffset = overflowRef.scrollWidth - overflowRef.clientWidth; + const offsetValue = getOffset(offset, maxOffset); + calculatedOffsetLeft = currentOffset - offsetValue; + if (calculatedOffsetLeft < 0) { + onChangeOffset(0); + } else { + onChangeOffset(calculatedOffsetLeft); + } + } + overflowRef.scrollTo({ left: calculatedOffsetLeft, behavior: scrollAnimationBehavior, @@ -72,8 +101,20 @@ const useOverflowScrollActions = ({ return childrenElements[nextChildElementIndex]; }; - const nextChildElement = getNextChildElement(); - const calculatedOffsetLeft = calculateOffsetLeft(nextChildElement); + let nextChildElement = getNextChildElement(); + let calculatedOffsetLeft = calculateOffsetLeft(nextChildElement); + + if (offset) { + const maxOffset = overflowRef.scrollWidth - overflowRef.clientWidth; + const offsetValue = getOffset(offset, maxOffset); + calculatedOffsetLeft = currentOffset + offsetValue; + if (calculatedOffsetLeft > maxOffset) { + onChangeOffset(maxOffset); + } else { + onChangeOffset(calculatedOffsetLeft); + } + } + overflowRef.scrollTo({ left: calculatedOffsetLeft, behavior: scrollAnimationBehavior, From 6f587a0755f14d38916fe85c751c36a10ec447e9 Mon Sep 17 00:00:00 2001 From: monteri Date: Fri, 2 Jun 2023 12:35:16 +0300 Subject: [PATCH 2/2] feat: eslint, tests, docs --- src/OverflowScroll/OverflowScroll.jsx | 6 +- .../tests/useOverflowScrollActions.test.jsx | 80 ++++++++++++++++ src/OverflowScroll/data/useOverflowScroll.js | 2 + .../data/useOverflowScrollActions.js | 75 +++++++-------- src/OverflowScroll/useOverflowScroll.mdx | 92 +++++++++++++++++++ 5 files changed, 213 insertions(+), 42 deletions(-) diff --git a/src/OverflowScroll/OverflowScroll.jsx b/src/OverflowScroll/OverflowScroll.jsx index 65f34549af..ec47b5ab30 100644 --- a/src/OverflowScroll/OverflowScroll.jsx +++ b/src/OverflowScroll/OverflowScroll.jsx @@ -14,6 +14,7 @@ function OverflowScroll({ onScrollPrevious, onScrollNext, offset, + offsetType, }) { const [overflowRef, setOverflowRef] = useState(); @@ -31,6 +32,7 @@ function OverflowScroll({ onScrollNext, overflowRef, offset, + offsetType, }); const contextValue = useMemo(() => ({ @@ -82,7 +84,8 @@ OverflowScroll.propTypes = { onScrollPrevious: PropTypes.func, /** Callback function for when the user scrolls to the next element. */ onScrollNext: PropTypes.func, - offset: PropTypes.string, + offset: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + offsetType: PropTypes.oneOf(['percentage', 'fixed']), }; OverflowScroll.defaultProps = { @@ -93,6 +96,7 @@ OverflowScroll.defaultProps = { onScrollPrevious: undefined, onScrollNext: undefined, offset: undefined, + offsetType: 'percentage', }; export default OverflowScroll; diff --git a/src/OverflowScroll/data/tests/useOverflowScrollActions.test.jsx b/src/OverflowScroll/data/tests/useOverflowScrollActions.test.jsx index a03e07731e..0443428105 100644 --- a/src/OverflowScroll/data/tests/useOverflowScrollActions.test.jsx +++ b/src/OverflowScroll/data/tests/useOverflowScrollActions.test.jsx @@ -114,4 +114,84 @@ describe('useOverflowScrollActions', () => { ); expect(mockScrollTo).toBeCalledTimes(1); }); + + it('scrollToPrevious moves scroll to specified percentage', () => { + const scrollWidth = 1000; + const clientWidth = 200; + + const overflowRef = { + scrollWidth, + clientWidth, + scrollTo: jest.fn(), + }; + const activeChildElementIndex = 2; + const childrenElements = [...Array(5)]; + const offset = '20'; + const offsetType = 'percentage'; + const currentOffset = 500; + const onChangeOffset = jest.fn(); + const scrollAnimationBehavior = 'smooth'; + + const { result } = renderHook(() => useOverflowScrollActions({ + overflowRef, + activeChildElementIndex, + childrenElements, + offset, + offsetType, + currentOffset, + onChangeOffset, + scrollAnimationBehavior, + })); + const { scrollToPrevious } = result.current; + + act(() => { + scrollToPrevious(); + }); + + expect(onChangeOffset).toHaveBeenCalledWith(currentOffset - 160); + expect(overflowRef.scrollTo).toHaveBeenCalledWith({ + left: currentOffset - 160, + behavior: scrollAnimationBehavior, + }); + }); + + it('scrollToNext moves scroll to specified fixed value', async () => { + const scrollWidth = 1000; + const clientWidth = 200; + + const overflowRef = { + scrollWidth, + clientWidth, + scrollTo: jest.fn(), + }; + const activeChildElementIndex = 2; + const childrenElements = [...Array(5)]; + const offset = '20'; + const offsetType = 'percentage'; + const currentOffset = 0; + const onChangeOffset = jest.fn(); + const scrollAnimationBehavior = 'smooth'; + + const { result } = renderHook(() => useOverflowScrollActions({ + overflowRef, + activeChildElementIndex, + childrenElements, + offset, + offsetType, + currentOffset, + onChangeOffset, + scrollAnimationBehavior, + })); + const { scrollToNext } = result.current; + + act(() => { + scrollToNext(); + }); + + expect(onChangeOffset).toHaveBeenCalledWith(currentOffset + 160); + expect(overflowRef.scrollTo).toHaveBeenCalledWith({ + left: currentOffset + 160, + behavior: scrollAnimationBehavior, + }); + }); }); diff --git a/src/OverflowScroll/data/useOverflowScroll.js b/src/OverflowScroll/data/useOverflowScroll.js index 8ea647b9c3..f7af84c58d 100644 --- a/src/OverflowScroll/data/useOverflowScroll.js +++ b/src/OverflowScroll/data/useOverflowScroll.js @@ -47,6 +47,7 @@ const useOverflowScroll = ({ disableOpacityMasks = false, scrollAnimationBehavior = 'smooth', offset, + offsetType = 'percentage', }) => { const [currentOffset, setCurrentOffset] = useState(0); const [isScrolledToStart, setIsScrolledToStart] = useState(true); @@ -159,6 +160,7 @@ const useOverflowScroll = ({ onChangeOffset: setCurrentOffset, currentOffset, offset, + offsetType, }); return { diff --git a/src/OverflowScroll/data/useOverflowScrollActions.js b/src/OverflowScroll/data/useOverflowScrollActions.js index 7de5ae7de4..6f54f920ed 100644 --- a/src/OverflowScroll/data/useOverflowScrollActions.js +++ b/src/OverflowScroll/data/useOverflowScrollActions.js @@ -18,20 +18,15 @@ const useOverflowScrollActions = ({ onChangeOffset, currentOffset, offset, + offsetType = 'percentage', }) => { + const getOffset = useCallback((value, type, maxOffset) => { + const numOffset = parseInt(value, 10); - const getOffset = (offsetString, maxOffset) => { - let offset = 0; - - if (offsetString.endsWith("px")) { - offset = parseInt(offsetString); - } else if (offsetString.endsWith("%")) { - const percent = parseInt(offsetString) / 100; - offset = Math.round(maxOffset * percent); - } - - return offset; - } + return type === 'percentage' + ? Math.round(maxOffset * (numOffset / 100)) + : numOffset; + }, []); /** * A helper function to scroll to the previous element in the overflow container. @@ -55,12 +50,14 @@ const useOverflowScrollActions = ({ if (offset) { const maxOffset = overflowRef.scrollWidth - overflowRef.clientWidth; - const offsetValue = getOffset(offset, maxOffset); + const offsetValue = getOffset(offset, offsetType, maxOffset); calculatedOffsetLeft = currentOffset - offsetValue; - if (calculatedOffsetLeft < 0) { - onChangeOffset(0); - } else { - onChangeOffset(calculatedOffsetLeft); + if (onChangeOffset) { + if (calculatedOffsetLeft < 0) { + onChangeOffset(0); + } else { + onChangeOffset(calculatedOffsetLeft); + } } } @@ -69,16 +66,13 @@ const useOverflowScrollActions = ({ behavior: scrollAnimationBehavior, }); const currentActiveChildElementIndex = previousChildElementIndex <= 0 ? 0 : previousChildElementIndex; - onScrollPrevious({ - currentActiveChildElementIndex, - }); - }, [ - overflowRef, - childrenElements, - activeChildElementIndex, - scrollAnimationBehavior, - onScrollPrevious, - ]); + if (onScrollPrevious) { + onScrollPrevious({ + currentActiveChildElementIndex, + }); + } + }, [overflowRef, activeChildElementIndex, offset, scrollAnimationBehavior, onScrollPrevious, childrenElements, + getOffset, offsetType, currentOffset, onChangeOffset]); /** * A helper function to scroll to the next element in the overflow container. @@ -101,17 +95,19 @@ const useOverflowScrollActions = ({ return childrenElements[nextChildElementIndex]; }; - let nextChildElement = getNextChildElement(); + const nextChildElement = getNextChildElement(); let calculatedOffsetLeft = calculateOffsetLeft(nextChildElement); if (offset) { const maxOffset = overflowRef.scrollWidth - overflowRef.clientWidth; - const offsetValue = getOffset(offset, maxOffset); + const offsetValue = getOffset(offset, offsetType, maxOffset); calculatedOffsetLeft = currentOffset + offsetValue; - if (calculatedOffsetLeft > maxOffset) { - onChangeOffset(maxOffset); - } else { - onChangeOffset(calculatedOffsetLeft); + if (onChangeOffset) { + if (calculatedOffsetLeft > maxOffset) { + onChangeOffset(maxOffset); + } else { + onChangeOffset(calculatedOffsetLeft); + } } } @@ -120,14 +116,11 @@ const useOverflowScrollActions = ({ behavior: scrollAnimationBehavior, }); const currentActiveChildElementIndex = isNextChildIndexAtEnd ? lastChildElementIndex : nextChildElementIndex; - onScrollNext({ currentActiveChildElementIndex }); - }, [ - overflowRef, - activeChildElementIndex, - scrollAnimationBehavior, - childrenElements, - onScrollNext, - ]); + if (onScrollNext) { + onScrollNext({ currentActiveChildElementIndex }); + } + }, [overflowRef, childrenElements, activeChildElementIndex, offset, scrollAnimationBehavior, onScrollNext, + getOffset, offsetType, currentOffset, onChangeOffset]); return { scrollToPrevious, diff --git a/src/OverflowScroll/useOverflowScroll.mdx b/src/OverflowScroll/useOverflowScroll.mdx index 463f0dcf7a..ebaf85c59d 100644 --- a/src/OverflowScroll/useOverflowScroll.mdx +++ b/src/OverflowScroll/useOverflowScroll.mdx @@ -98,3 +98,95 @@ The hook returns the following: * `activeChildElementIndex`. The index of the child element that is currently deemed to be "active", i.e. the child element used as the reference position for any `scrollToPrevious` or `scrollToNext` calls. See [`OverflowScroll`](/components/overflowscroll/overflowscroll) for React components that encapsulate the logic within `useOverflowScroll`. + +### Use of `offset` and `offsetType` + +```jsx live +() => { + const [offset, setOffset] = useState(50); + const [offsetType, setOffsetType] = useState('percentage'); + + const [overflowRef, setOverflowRef] = useState(); + + const { + isScrolledToStart, + isScrolledToEnd, + scrollToPrevious, + scrollToNext, + } = useOverflowScroll({ + childQuerySelector: '.example-item', + overflowRef, + offset, + offsetType, + }); + + const ExampleItem = ({ className }) => ( +
+ Item +
+ ); + const itemCount = 20; + const items = useMemo(() => Array.from({ length: itemCount }).map((index) => { + if (index !== itemCount - 1) { + return ; + } + // last element, no right margin + return ; + }), []); + + return ( + <> + {/* start example form block */} + + {/* end example form block */} + +
+ + +
+
+ {items} +
+ + ); +}; +```