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

react-ui-org / react-ui / 16140343605

08 Jul 2025 10:10AM UTC coverage: 91.76%. Remained the same
16140343605

Pull #656

github

web-flow
Merge aa9b22fc9 into 3c7e5b4c2
Pull Request #656: Add custom properties for the border of disabled cards

762 of 836 branches covered (91.15%)

Branch coverage included in aggregate %.

719 of 778 relevant lines covered (92.42%)

77.33 hits per line

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

54.55
/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
} from 'react';
9
import { TranslationsContext } from '../../providers/translations';
10
import { withGlobalProps } from '../../providers/globalProps';
11
import { classNames } from '../../helpers/classNames/classNames';
12
import { transferProps } from '../../helpers/transferProps';
13
import { getElementsPositionDifference } from './_helpers/getElementsPositionDifference';
14
import { useLoadResize } from './_hooks/useLoadResizeHook';
15
import { useScrollPosition } from './_hooks/useScrollPositionHook';
16
import styles from './ScrollView.module.scss';
17

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

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

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

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

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

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

67
    if (isScrolledAtStartActive !== isScrolledAtStart) {
30!
68
      setIsScrolledAtStart(isScrolledAtStartActive);
×
69
    }
70

71
    if (isScrolledAtEndActive !== isScrolledAtEnd) {
30!
72
      setIsScrolledAtEnd(isScrolledAtEndActive);
×
73
    }
74
  };
75

76
  /**
77
   * It handles scroll event fired on `scrollViewViewportEl` element. If autoScroll is in progress,
78
   * and element it scrolled to the end of viewport, `isAutoScrollInProgress` is set to `false`.
79
   */
80
  const handleScrollWhenAutoScrollIsInProgress = () => {
30✔
81
    const currentPosition = getElementsPositionDifference(
×
82
      scrollViewContentEl,
83
      scrollViewViewportEl,
84
    );
85

86
    if (currentPosition[scrollPositionEnd] <= EDGE_DETECTION_INACCURACY_PX) {
×
87
      setIsAutoScrollInProgress(false);
×
88
      scrollViewViewportEl.current.removeEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
×
89
    }
90
  };
91

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

103
    const scrollViewContentElement = scrollViewContentEl.current;
×
104
    const scrollViewViewportElement = scrollViewViewportEl.current;
×
105

106
    const differenceX = direction === 'horizontal' ? scrollViewContentElement.offsetWidth : 0;
×
107
    const differenceY = direction !== 'horizontal' ? scrollViewContentElement.offsetHeight : 0;
×
108

109
    if (autoScroll === 'always' || forceAutoScroll) {
×
110
      scrollViewViewportElement.scrollBy(differenceX, differenceY);
×
111
    } else if (!isScrolledAtEnd || isAutoScrollInProgress) {
×
112
      setIsAutoScrollInProgress(true);
×
113
      scrollViewViewportElement.scrollBy(differenceX, differenceY);
×
114

115
      // Handler `handleScrollWhenAutoScrollIsInProgress` sets `isAutoScrollInProgress` to `false`
116
      // when viewport element is scrolled to the end of the viewport
117
      scrollViewViewportElement.addEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
×
118

119
      return () => {
×
120
        scrollViewViewportElement.removeEventListener('scroll', handleScrollWhenAutoScrollIsInProgress);
×
121
      };
122
    }
123

124
    return () => {};
×
125
  };
126

127
  useEffect(
30✔
128
    () => {
129
      handleScrollViewState(
30✔
130
        getElementsPositionDifference(scrollViewContentEl, scrollViewViewportEl),
131
      );
132
    },
133
    [], // eslint-disable-line react-hooks/exhaustive-deps
134
  );
135

136
  useLoadResize(
30✔
137
    (currentPosition) => {
138
      handleScrollViewState(currentPosition);
×
139
      handleScrollWhenAutoScrollIsEnabled(true);
×
140
    },
141
    [isScrolledAtStart, isScrolledAtEnd],
142
    scrollViewContentEl,
143
    scrollViewViewportEl,
144
    debounce,
145
  );
146

147
  useScrollPosition(
30✔
148
    (currentPosition) => (handleScrollViewState(currentPosition)),
×
149
    [isScrolledAtStart, isScrolledAtEnd],
150
    scrollViewContentEl,
151
    scrollViewViewportEl,
152
    debounce,
153
  );
154

155
  const autoScrollChildrenKeys = autoScroll !== 'off' && children && React.Children
30!
156
    .map(children, (child) => child.key)
×
157
    .reduce((reducedKeys, childKey) => reducedKeys + childKey, '');
×
158
  const autoScrollChildrenLength = autoScroll !== 'off' && children && children.length;
30!
159

160
  useLayoutEffect(
30✔
161
    handleScrollWhenAutoScrollIsEnabled,
162
    // eslint-disable-next-line react-hooks/exhaustive-deps
163
    [autoScroll, autoScrollChildrenKeys, autoScrollChildrenLength],
164
  );
165

166
  const arrowHandler = (contentEl, viewportEl, scrollViewDirection, shiftDirection, step) => {
30✔
167
    const offset = shiftDirection === 'next' ? step : -1 * step;
×
168
    const differenceX = scrollViewDirection === 'horizontal' ? offset : 0;
×
169
    const differenceY = scrollViewDirection !== 'horizontal' ? offset : 0;
×
170

171
    viewportEl.current.scrollBy(differenceX, differenceY);
×
172
  };
173

174
  return (
30✔
175
    <div
176
      {...transferProps(restProps)}
177
      className={classNames(
178
        styles.root,
179
        isScrolledAtStart && styles.isRootScrolledAtStart,
15!
180
        isScrolledAtEnd && styles.isRootScrolledAtEnd,
15!
181
        !scrollbar && styles.hasRootScrollbarDisabled,
16✔
182
        direction === 'horizontal' ? styles.isRootHorizontal : styles.isRootVertical,
15✔
183
      )}
184
      id={id}
185
      style={{
186
        '--rui-local-end-shadow-background': endShadowBackground,
187
        '--rui-local-end-shadow-direction': direction === 'horizontal' ? 'to left' : 'to top',
15✔
188
        '--rui-local-end-shadow-initial-offset': endShadowInitialOffset,
189
        '--rui-local-end-shadow-size': endShadowSize,
190
        '--rui-local-next-arrow-color': nextArrowColor,
191
        '--rui-local-next-arrow-initial-offset': nextArrowInitialOffset,
192
        '--rui-local-prev-arrow-color': prevArrowColor,
193
        '--rui-local-prev-arrow-initial-offset': prevArrowInitialOffset,
194
        '--rui-local-start-shadow-background': startShadowBackground,
195
        '--rui-local-start-shadow-direction': direction === 'horizontal' ? 'to right' : 'to bottom',
15✔
196
        '--rui-local-start-shadow-initial-offset': startShadowInitialOffset,
197
        '--rui-local-start-shadow-size': startShadowSize,
198
      }}
199
    >
200
      <div
201
        className={styles.viewport}
202
        ref={scrollViewViewportEl}
203
      >
204
        <div
205
          className={styles.content}
206
          id={id && `${id}__content`}
17✔
207
          ref={scrollViewContentEl}
208
        >
209
          {children}
210
        </div>
211
      </div>
212
      {shadows && (
30✔
213
        <div
214
          aria-hidden
215
          className={styles.scrollingShadows}
216
        />
217
      )}
218
      {arrows && (
20✔
219
        <>
220
          <button
221
            className={styles.arrowPrev}
222
            id={id && `${id}__arrowPrevButton`}
6✔
223
            onClick={() => arrowHandler(
×
224
              scrollViewContentEl,
225
              scrollViewViewportEl,
226
              direction,
227
              'prev',
228
              arrowsScrollStep,
229
            )}
230
            title={translations.ScrollView.previous}
231
            type="button"
232
          >
233
            {prevArrowElement || (
9✔
234
              <span
235
                aria-hidden
236
                className={styles.arrowIcon}
237
              />
238
            )}
239
          </button>
240
          <button
241
            className={styles.arrowNext}
242
            id={id && `${id}__arrowNextButton`}
6✔
243
            onClick={() => arrowHandler(
×
244
              scrollViewContentEl,
245
              scrollViewViewportEl,
246
              direction,
247
              'next',
248
              arrowsScrollStep,
249
            )}
250
            title={translations.ScrollView.next}
251
            type="button"
252
          >
253
            {nextArrowElement || (
9✔
254
              <span
255
                aria-hidden
256
                className={styles.arrowIcon}
257
              />
258
            )}
259
          </button>
260
        </>
261
      )}
262
    </div>
263
  );
264
});
265

266
ScrollView.defaultProps = {
8✔
267
  arrows: false,
268
  arrowsScrollStep: 200,
269
  autoScroll: 'off',
270
  children: null,
271
  debounce: 50,
272
  direction: 'vertical',
273
  endShadowBackground: 'linear-gradient(var(--rui-local-end-shadow-direction), rgba(255 255 255 / 1), rgba(255 255 255 / 0))',
274
  endShadowInitialOffset: '-1rem',
275
  endShadowSize: '2em',
276
  id: undefined,
277
  nextArrowColor: undefined,
278
  nextArrowElement: null,
279
  nextArrowInitialOffset: '-0.5rem',
280
  prevArrowColor: undefined,
281
  prevArrowElement: null,
282
  prevArrowInitialOffset: '-0.5rem',
283
  scrollbar: true,
284
  shadows: true,
285
  startShadowBackground: 'linear-gradient(var(--rui-local-start-shadow-direction), rgba(255 255 255 / 1), rgba(255 255 255 / 0))',
286
  startShadowInitialOffset: '-1rem',
287
  startShadowSize: '2em',
288
};
289

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

389
export const ScrollViewWithGlobalProps = withGlobalProps(ScrollView, 'ScrollView');
8✔
390

391
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