UNPKG

5.88 kBPlain TextView Raw
1/*
2 * Copyright 2020 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13import {getScrollParents} from './getScrollParents';
14
15interface ScrollIntoViewportOpts {
16 /** The optional containing element of the target to be centered in the viewport. */
17 containingElement?: Element | null
18}
19
20/**
21 * Scrolls `scrollView` so that `element` is visible.
22 * Similar to `element.scrollIntoView({block: 'nearest'})` (not supported in Edge),
23 * but doesn't affect parents above `scrollView`.
24 */
25export function scrollIntoView(scrollView: HTMLElement, element: HTMLElement) {
26 let offsetX = relativeOffset(scrollView, element, 'left');
27 let offsetY = relativeOffset(scrollView, element, 'top');
28 let width = element.offsetWidth;
29 let height = element.offsetHeight;
30 let x = scrollView.scrollLeft;
31 let y = scrollView.scrollTop;
32
33 // Account for top/left border offsetting the scroll top/Left + scroll padding
34 let {
35 borderTopWidth,
36 borderLeftWidth,
37 scrollPaddingTop,
38 scrollPaddingRight,
39 scrollPaddingBottom,
40 scrollPaddingLeft
41 } = getComputedStyle(scrollView);
42
43 let borderAdjustedX = x + parseInt(borderLeftWidth, 10);
44 let borderAdjustedY = y + parseInt(borderTopWidth, 10);
45 // Ignore end/bottom border via clientHeight/Width instead of offsetHeight/Width
46 let maxX = borderAdjustedX + scrollView.clientWidth;
47 let maxY = borderAdjustedY + scrollView.clientHeight;
48
49 // Get scroll padding values as pixels - defaults to 0 if no scroll padding
50 // is used.
51 let scrollPaddingTopNumber = parseInt(scrollPaddingTop, 10) || 0;
52 let scrollPaddingBottomNumber = parseInt(scrollPaddingBottom, 10) || 0;
53 let scrollPaddingRightNumber = parseInt(scrollPaddingRight, 10) || 0;
54 let scrollPaddingLeftNumber = parseInt(scrollPaddingLeft, 10) || 0;
55
56 if (offsetX <= x + scrollPaddingLeftNumber) {
57 x = offsetX - parseInt(borderLeftWidth, 10) - scrollPaddingLeftNumber;
58 } else if (offsetX + width > maxX - scrollPaddingRightNumber) {
59 x += offsetX + width - maxX + scrollPaddingRightNumber;
60 }
61 if (offsetY <= borderAdjustedY + scrollPaddingTopNumber) {
62 y = offsetY - parseInt(borderTopWidth, 10) - scrollPaddingTopNumber;
63 } else if (offsetY + height > maxY - scrollPaddingBottomNumber) {
64 y += offsetY + height - maxY + scrollPaddingBottomNumber;
65 }
66
67 scrollView.scrollLeft = x;
68 scrollView.scrollTop = y;
69}
70
71/**
72 * Computes the offset left or top from child to ancestor by accumulating
73 * offsetLeft or offsetTop through intervening offsetParents.
74 */
75function relativeOffset(ancestor: HTMLElement, child: HTMLElement, axis: 'left'|'top') {
76 const prop = axis === 'left' ? 'offsetLeft' : 'offsetTop';
77 let sum = 0;
78 while (child.offsetParent) {
79 sum += child[prop];
80 if (child.offsetParent === ancestor) {
81 // Stop once we have found the ancestor we are interested in.
82 break;
83 } else if (child.offsetParent.contains(ancestor)) {
84 // If the ancestor is not `position:relative`, then we stop at
85 // _its_ offset parent, and we subtract off _its_ offset, so that
86 // we end up with the proper offset from child to ancestor.
87 sum -= ancestor[prop];
88 break;
89 }
90 child = child.offsetParent as HTMLElement;
91 }
92 return sum;
93}
94
95/**
96 * Scrolls the `targetElement` so it is visible in the viewport. Accepts an optional `opts.containingElement`
97 * that will be centered in the viewport prior to scrolling the targetElement into view. If scrolling is prevented on
98 * the body (e.g. targetElement is in a popover), this will only scroll the scroll parents of the targetElement up to but not including the body itself.
99 */
100export function scrollIntoViewport(targetElement: Element | null, opts?: ScrollIntoViewportOpts) {
101 if (targetElement && document.contains(targetElement)) {
102 let root = document.scrollingElement || document.documentElement;
103 let isScrollPrevented = window.getComputedStyle(root).overflow === 'hidden';
104 // If scrolling is not currently prevented then we aren’t in a overlay nor is a overlay open, just use element.scrollIntoView to bring the element into view
105 if (!isScrollPrevented) {
106 let {left: originalLeft, top: originalTop} = targetElement.getBoundingClientRect();
107
108 // use scrollIntoView({block: 'nearest'}) instead of .focus to check if the element is fully in view or not since .focus()
109 // won't cause a scroll if the element is already focused and doesn't behave consistently when an element is partially out of view horizontally vs vertically
110 targetElement?.scrollIntoView?.({block: 'nearest'});
111 let {left: newLeft, top: newTop} = targetElement.getBoundingClientRect();
112 // Account for sub pixel differences from rounding
113 if ((Math.abs(originalLeft - newLeft) > 1) || (Math.abs(originalTop - newTop) > 1)) {
114 opts?.containingElement?.scrollIntoView?.({block: 'center', inline: 'center'});
115 targetElement.scrollIntoView?.({block: 'nearest'});
116 }
117 } else {
118 let scrollParents = getScrollParents(targetElement);
119 // If scrolling is prevented, we don't want to scroll the body since it might move the overlay partially offscreen and the user can't scroll it back into view.
120 for (let scrollParent of scrollParents) {
121 scrollIntoView(scrollParent as HTMLElement, targetElement as HTMLElement);
122 }
123 }
124 }
125}