• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

react-ui-org / react-ui / 16141702577

08 Jul 2025 11:16AM UTC coverage: 91.677%. First build
16141702577

Pull #657

github

web-flow
Merge 03b28976b into 801831f53
Pull Request #657: Fix `ScrollView` scroll position issues (#501)

766 of 841 branches covered (91.08%)

Branch coverage included in aggregate %.

18 of 22 new or added lines in 2 files covered. (81.82%)

732 of 793 relevant lines covered (92.31%)

76.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

58.28
/src/components/ScrollView/ScrollView.jsx
1
import PropTypes from 'prop-types';
2
import React, {
3
  useContext,
4
  useEffect,
5
  useLayoutEffect,
6
  useRef,
7
  useState,
8
  useCallback,
9
} from 'react';
10
import { TranslationsContext } from '../../providers/translations';
11
import { withGlobalProps } from '../../providers/globalProps';
12
import { classNames } from '../../helpers/classNames/classNames';
13
import { transferProps } from '../../helpers/transferProps';
14
import { getElementsPositionDifference } from './_helpers/getElementsPositionDifference';
15
import { useLoadResize } from './_hooks/useLoadResizeHook';
16
import { useScrollPosition } from './_hooks/useScrollPositionHook';
17
import styles from './ScrollView.module.scss';
18

19
// Function `getElementsPositionDifference` sometimes returns floating point values that results
20
// in inaccurate detection of start/end. It is necessary to accept this inaccuracy and take
21
// every value less or equal to 1px as start/end.
22
const EDGE_DETECTION_INACCURACY_PX = 1;
8✔
23

24
export const ScrollView = React.forwardRef((props, ref) => {
8✔
25
  const {
26
    arrows,
27
    arrowsScrollStep,
28
    autoScroll,
29
    children,
30
    endShadowBackground,
31
    endShadowInitialOffset,
32
    endShadowSize,
33
    id,
34
    debounce,
35
    direction,
36
    nextArrowColor,
37
    nextArrowElement,
38
    nextArrowInitialOffset,
39
    prevArrowColor,
40
    prevArrowElement,
41
    prevArrowInitialOffset,
42
    scrollbar,
43
    shadows,
44
    startShadowBackground,
45
    startShadowInitialOffset,
46
    startShadowSize,
47
    ...restProps
48
  } = props;
30✔
49

50
  const translations = useContext(TranslationsContext);
30✔
51

52
  const [isAutoScrollInProgress, setIsAutoScrollInProgress] = useState(false);
30✔
53
  const [isScrolledAtStart, setIsScrolledAtStart] = useState(false);
30✔
54
  const [isScrolledAtEnd, setIsScrolledAtEnd] = useState(false);
30✔
55

56
  const scrollPositionStart = direction === 'horizontal' ? 'left' : 'top';
30✔
57
  const scrollPositionEnd = direction === 'horizontal' ? 'right' : 'bottom';
30✔
58
  const scrollViewContentEl = useRef(null);
30✔
59
  const blankRef = useRef(null);
30✔
60
  const scrollViewViewportEl = ref ?? blankRef;
30✔
61

62
  const handleScrollViewState = useCallback((currentPosition) => {
30✔
63
    const isScrolledAtStartActive = currentPosition[scrollPositionStart]
30✔
64
      <= -1 * EDGE_DETECTION_INACCURACY_PX;
65
    const isScrolledAtEndActive = currentPosition[scrollPositionEnd]
30✔
66
      >= EDGE_DETECTION_INACCURACY_PX;
67

68
    setIsScrolledAtStart((prevIsScrolledAtStart) => {
30✔
69
      if (isScrolledAtStartActive !== prevIsScrolledAtStart) {
30!
NEW
70
        return isScrolledAtStartActive;
×
71
      }
72
      return prevIsScrolledAtStart;
30✔
73
    });
74

75
    setIsScrolledAtEnd((prevIsScrolledAtEnd) => {
30✔
76
      if (isScrolledAtEndActive !== prevIsScrolledAtEnd) {
30!
NEW
77
        return isScrolledAtEndActive;
×
78
      }
79
      return prevIsScrolledAtEnd;
30✔
80
    });
81
  }, [scrollPositionStart, scrollPositionEnd]);
82

83
  /**
84
   * It handles scroll event fired on `scrollViewViewportEl` element. If autoScroll is in progress,
85
   * and element it scrolled to the end of viewport, `isAutoScrollInProgress` is set to `false`.
86
   */
87
  const handleScrollWhenAutoScrollIsInProgress = () => {
30✔
88
    const currentPosition = getElementsPositionDifference(
×
89
      scrollViewContentEl,
90
      scrollViewViewportEl,
91
    );
92

93
    if (currentPosition[scrollPositionEnd] <= EDGE_DETECTION_INACCURACY_PX) {
×
94
      setIsAutoScrollInProgress(false);
×
95
      scrollViewViewportEl.current.removeEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
×
96
    }
97
  };
98

99
  /**
100
   * If autoScroll is enabled, it automatically scrolls viewport element to the end of the
101
   * viewport when content is changed. It is performed only when viewport element is
102
   * scrolled to the end of the viewport or when viewport element is in any position but
103
   * autoScroll triggered by previous change is still in progress.
104
   */
105
  const handleScrollWhenAutoScrollIsEnabled = (forceAutoScroll = false) => {
30✔
106
    if (autoScroll === 'off') {
30!
107
      return () => {};
30✔
108
    }
109

110
    const scrollViewContentElement = scrollViewContentEl.current;
×
111
    const scrollViewViewportElement = scrollViewViewportEl.current;
×
112

113
    const differenceX = direction === 'horizontal' ? scrollViewContentElement.offsetWidth : 0;
×
114
    const differenceY = direction !== 'horizontal' ? scrollViewContentElement.offsetHeight : 0;
×
115

116
    if (autoScroll === 'always' || forceAutoScroll) {
×
117
      scrollViewViewportElement.scrollBy(differenceX, differenceY);
×
118
    } else if (!isScrolledAtEnd || isAutoScrollInProgress) {
×
119
      setIsAutoScrollInProgress(true);
×
120
      scrollViewViewportElement.scrollBy(differenceX, differenceY);
×
121

122
      // Handler `handleScrollWhenAutoScrollIsInProgress` sets `isAutoScrollInProgress` to `false`
123
      // when viewport element is scrolled to the end of the viewport
124
      scrollViewViewportElement.addEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
×
125

126
      return () => {
×
127
        scrollViewViewportElement.removeEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
×
128
      };
129
    }
130

131
    return () => {};
×
132
  };
133

134
  useEffect(
30✔
135
    () => {
136
      handleScrollViewState(
30✔
137
        getElementsPositionDifference(scrollViewContentEl, scrollViewViewportEl),
138
      );
139
    },
140
    [], // eslint-disable-line react-hooks/exhaustive-deps
141
  );
142

143
  useLoadResize(
30✔
144
    (currentPosition) => {
145
      handleScrollViewState(currentPosition);
×
146
      handleScrollWhenAutoScrollIsEnabled(true);
×
147
    },
148
    [isScrolledAtStart, isScrolledAtEnd],
149
    scrollViewContentEl,
150
    scrollViewViewportEl,
151
    debounce,
152
  );
153

154
  useScrollPosition(
30✔
155
    (currentPosition) => (handleScrollViewState(currentPosition)),
×
156
    scrollViewContentEl,
157
    scrollViewViewportEl,
158
    debounce,
159
  );
160

161
  const autoScrollChildrenKeys = autoScroll !== 'off' && children && React.Children
30!
162
    .map(children, (child) => child.key)
×
163
    .reduce((reducedKeys, childKey) => reducedKeys + childKey, '');
×
164
  const autoScrollChildrenLength = autoScroll !== 'off' && children && children.length;
30!
165

166
  useLayoutEffect(
30✔
167
    handleScrollWhenAutoScrollIsEnabled,
168
    // eslint-disable-next-line react-hooks/exhaustive-deps
169
    [autoScroll, autoScrollChildrenKeys, autoScrollChildrenLength],
170
  );
171

172
  // ResizeObserver to detect when content or viewport dimensions change due to style changes
173
  useLayoutEffect(() => {
30✔
174
    const contentElement = scrollViewContentEl.current;
30✔
175
    const viewportElement = scrollViewViewportEl.current;
30✔
176

177
    if (!contentElement || !viewportElement) {
30!
NEW
178
      return () => {};
×
179
    }
180

181
    const resizeObserver = new ResizeObserver(() => {
30✔
NEW
182
      handleScrollViewState(
×
183
        getElementsPositionDifference(scrollViewContentEl, scrollViewViewportEl),
184
      );
185
    });
186

187
    // Observe both content and viewport for dimension changes
188
    resizeObserver.observe(contentElement);
30✔
189
    resizeObserver.observe(viewportElement);
30✔
190

191
    return () => {
30✔
192
      resizeObserver.disconnect();
30✔
193
    };
194
  }, [scrollViewContentEl, scrollViewViewportEl, handleScrollViewState]);
195

196
  const arrowHandler = (contentEl, viewportEl, scrollViewDirection, shiftDirection, step) => {
30✔
197
    const offset = shiftDirection === 'next' ? step : -1 * step;
×
198
    const differenceX = scrollViewDirection === 'horizontal' ? offset : 0;
×
199
    const differenceY = scrollViewDirection !== 'horizontal' ? offset : 0;
×
200

201
    viewportEl.current.scrollBy(differenceX, differenceY);
×
202
  };
203

204
  return (
30✔
205
    <div
206
      {...transferProps(restProps)}
207
      className={classNames(
208
        styles.root,
209
        isScrolledAtStart && styles.isRootScrolledAtStart,
15!
210
        isScrolledAtEnd && styles.isRootScrolledAtEnd,
15!
211
        !scrollbar && styles.hasRootScrollbarDisabled,
16✔
212
        direction === 'horizontal' ? styles.isRootHorizontal : styles.isRootVertical,
15✔
213
      )}
214
      id={id}
215
      style={{
216
        '--rui-local-end-shadow-background': endShadowBackground,
217
        '--rui-local-end-shadow-direction': direction === 'horizontal' ? 'to left' : 'to top',
15✔
218
        '--rui-local-end-shadow-initial-offset': endShadowInitialOffset,
219
        '--rui-local-end-shadow-size': endShadowSize,
220
        '--rui-local-next-arrow-color': nextArrowColor,
221
        '--rui-local-next-arrow-initial-offset': nextArrowInitialOffset,
222
        '--rui-local-prev-arrow-color': prevArrowColor,
223
        '--rui-local-prev-arrow-initial-offset': prevArrowInitialOffset,
224
        '--rui-local-start-shadow-background': startShadowBackground,
225
        '--rui-local-start-shadow-direction': direction === 'horizontal' ? 'to right' : 'to bottom',
15✔
226
        '--rui-local-start-shadow-initial-offset': startShadowInitialOffset,
227
        '--rui-local-start-shadow-size': startShadowSize,
228
      }}
229
    >
230
      <div
231
        className={styles.viewport}
232
        ref={scrollViewViewportEl}
233
      >
234
        <div
235
          className={styles.content}
236
          id={id && `${id}__content`}
17✔
237
          ref={scrollViewContentEl}
238
        >
239
          {children}
240
        </div>
241
      </div>
242
      {shadows && (
30✔
243
        <div
244
          aria-hidden
245
          className={styles.scrollingShadows}
246
        />
247
      )}
248
      {arrows && (
20✔
249
        <>
250
          <button
251
            className={styles.arrowPrev}
252
            id={id && `${id}__arrowPrevButton`}
6✔
253
            onClick={() => arrowHandler(
×
254
              scrollViewContentEl,
255
              scrollViewViewportEl,
256
              direction,
257
              'prev',
258
              arrowsScrollStep,
259
            )}
260
            title={translations.ScrollView.previous}
261
            type="button"
262
          >
263
            {prevArrowElement || (
9✔
264
              <span
265
                aria-hidden
266
                className={styles.arrowIcon}
267
              />
268
            )}
269
          </button>
270
          <button
271
            className={styles.arrowNext}
272
            id={id && `${id}__arrowNextButton`}
6✔
273
            onClick={() => arrowHandler(
×
274
              scrollViewContentEl,
275
              scrollViewViewportEl,
276
              direction,
277
              'next',
278
              arrowsScrollStep,
279
            )}
280
            title={translations.ScrollView.next}
281
            type="button"
282
          >
283
            {nextArrowElement || (
9✔
284
              <span
285
                aria-hidden
286
                className={styles.arrowIcon}
287
              />
288
            )}
289
          </button>
290
        </>
291
      )}
292
    </div>
293
  );
294
});
295

296
ScrollView.defaultProps = {
8✔
297
  arrows: false,
298
  arrowsScrollStep: 200,
299
  autoScroll: 'off',
300
  children: null,
301
  debounce: 50,
302
  direction: 'vertical',
303
  endShadowBackground: 'linear-gradient(var(--rui-local-end-shadow-direction), rgba(255 255 255 / 1), rgba(255 255 255 / 0))',
304
  endShadowInitialOffset: '-1rem',
305
  endShadowSize: '2em',
306
  id: undefined,
307
  nextArrowColor: undefined,
308
  nextArrowElement: null,
309
  nextArrowInitialOffset: '-0.5rem',
310
  prevArrowColor: undefined,
311
  prevArrowElement: null,
312
  prevArrowInitialOffset: '-0.5rem',
313
  scrollbar: true,
314
  shadows: true,
315
  startShadowBackground: 'linear-gradient(var(--rui-local-start-shadow-direction), rgba(255 255 255 / 1), rgba(255 255 255 / 0))',
316
  startShadowInitialOffset: '-1rem',
317
  startShadowSize: '2em',
318
};
319

320
ScrollView.propTypes = {
8✔
321
  /**
322
   * If `true`, display the arrow controls.
323
   */
324
  arrows: PropTypes.bool,
325
  /**
326
   * Portion to scroll by when the arrows are clicked, in px.
327
   */
328
  arrowsScrollStep: PropTypes.number,
329
  /**
330
   * The auto-scroll mechanism requires having the `key` prop set for every child present in `children`
331
   * because it detects changes of those keys. Without the keys, the auto-scroll will not work.
332
   *
333
   * Option `always` means the auto-scroll scrolls to the end every time the content changes.
334
   * Option `detectEnd` means the auto-scroll scrolls to the end only when the content is changed
335
   * and the user has scrolled at the end of the viewport at the moment of the change.
336
   *
337
   * See https://reactjs.org/docs/lists-and-keys.html#keys
338
   */
339
  autoScroll: PropTypes.oneOf(['always', 'detectEnd', 'off']),
340
  /**
341
   * Content to be scrollable.
342
   */
343
  children: PropTypes.node,
344
  /**
345
   * Delay in ms before the display of arrows and scrolling shadows is evaluated during interaction.
346
   */
347
  debounce: PropTypes.number,
348
  /**
349
   * Direction of scrolling.
350
   */
351
  direction: PropTypes.oneOf(['horizontal', 'vertical']),
352
  /**
353
   * Custom background of the end scrolling shadow. Can be a CSS gradient or an image `url()`.
354
   */
355
  endShadowBackground: PropTypes.string,
356
  /**
357
   * Initial offset of the end scrolling shadow (transitioned). If set, the end scrolling shadow slides in
358
   * by this distance.
359
   */
360
  endShadowInitialOffset: PropTypes.string,
361
  /**
362
   * Size of the end scrolling shadow. Accepts any valid CSS length value.
363
   */
364
  endShadowSize: PropTypes.string,
365
  /**
366
   * ID of the root HTML element. It also serves as base for nested elements:
367
   * * `<ID>__content`
368
   * * `<ID>__arrowPrevButton`
369
   * * `<ID>__arrowNextButton`
370
   */
371
  id: PropTypes.string,
372
  /**
373
   * Text color of the end arrow control. Accepts any valid CSS color value.
374
   */
375
  nextArrowColor: PropTypes.string,
376
  /**
377
   * Custom HTML or React Component to replace the default next-arrow control.
378
   */
379
  nextArrowElement: PropTypes.node,
380
  /**
381
   * Initial offset of the end arrow control (transitioned). If set, the next arrow slides in by this distance.
382
   */
383
  nextArrowInitialOffset: PropTypes.string,
384
  /**
385
   * Text color of the start arrow control. Accepts any valid CSS color value.
386
   */
387
  prevArrowColor: PropTypes.string,
388
  /**
389
   * Custom HTML or React Component to replace the default prev-arrow control.
390
   */
391
  prevArrowElement: PropTypes.node,
392
  /**
393
   * Initial offset of the start arrow control (transitioned). If set, the prev arrow slides in by this distance.
394
   */
395
  prevArrowInitialOffset: PropTypes.string,
396
  /**
397
   * If `false`, the system scrollbar will be hidden.
398
   */
399
  scrollbar: PropTypes.bool,
400
  /**
401
   * If `true`, display scrolling shadows.
402
   */
403
  shadows: PropTypes.bool,
404
  /**
405
   * Custom background of the start scrolling shadow. Can be a CSS gradient or an image `url()`.
406
   */
407
  startShadowBackground: PropTypes.string,
408
  /**
409
   * Initial offset of the start scrolling shadow (transitioned). If set, the start scrolling shadow slides in
410
   * by this distance.
411
   */
412
  startShadowInitialOffset: PropTypes.string,
413
  /**
414
   * Size of the start scrolling shadow. Accepts any valid CSS length value.
415
   */
416
  startShadowSize: PropTypes.string,
417
};
418

419
export const ScrollViewWithGlobalProps = withGlobalProps(ScrollView, 'ScrollView');
8✔
420

421
export default ScrollViewWithGlobalProps;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc