Skip to content

Commit e7fe355

Browse files
authored
Scroll lock refactor (#1497)
* Add scrollbar offset to useScrollLock * Create ScrollLockProvider * Move useScrollLock test * Story improvements * Break up ScrollLockProvider * Move ScrollLockProvider in a bit * Add doc strings to ScrollLockContext
1 parent 895b1d1 commit e7fe355

File tree

18 files changed

+547
-144
lines changed

18 files changed

+547
-144
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"nomarker",
3131
"polarbrightness",
3232
"popperjs",
33+
"scrollbar",
3334
"spacebar",
3435
"storyshots",
3536
"stylelint",

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020

2121
### Changed
2222

23+
- `ComponentsProvider` now includes `ScrollLockContext` to manage all scroll locks for `Dialog` and `Popover`
24+
- Where previously `DialogContext` properties `enableScrollLock`, `disableScrollLock`, and `scrollLockEnabled` could previously be used to take control of a scroll lock, now use `ScrollLockContext` properties `enableCurrentLock`, `disableCurrentLock`, and `activeLockRef` to do so.
2325
- `AccordionDisclosure` "indicator" now matches color of container rather than preserving it's initial color
2426
- Storybook configuration improvements
2527
- `addons-essentials` now used
2628
- Replace `withKnobs` with `Controls` & `Args`
2729

30+
### Fixed
2831

32+
- Page "jumps" when opening a `Popover` due to the scrollbar disappearing
2933

3034
## [0.9.15] - 2020-09-21
3135

packages/components-providers/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
"access": "public"
1717
},
1818
"dependencies": {
19-
"@looker/design-tokens": "^0.9.15"
19+
"@looker/design-tokens": "^0.9.15",
20+
"lodash": "^4.17.20"
2021
},
2122
"devDependencies": {
23+
"@types/lodash": "^4.14.161",
2224
"@types/react": "^16.9.49",
2325
"@types/styled-components": "^4.4.1",
2426
"react": "^16.13.1",

packages/components-providers/src/ComponentsProvider.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
ThemeCustomizations,
3434
} from '@looker/design-tokens'
3535
import React, { FC, useMemo } from 'react'
36+
import { ScrollLockProvider } from './ScrollLock'
3637
import { ThemeProvider, ThemeProviderProps } from './ThemeProvider'
3738

3839
/**
@@ -81,7 +82,7 @@ export const ComponentsProvider: FC<ComponentsProviderProps> = ({
8182
{globalStyle && <GlobalStyle />}
8283
{loadGoogleFonts && <GoogleFontsLoader />}
8384
{ie11Support && <IEGlobalStyle />}
84-
{children}
85+
<ScrollLockProvider>{children}</ScrollLockProvider>
8586
</ThemeProvider>
8687
)
8788
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
3+
MIT License
4+
5+
Copyright (c) 2020 Looker Data Sciences, Inc.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
25+
*/
26+
27+
import { createContext, MutableRefObject } from 'react'
28+
29+
export interface ScrollLockContextProps {
30+
/**
31+
* Stores the element for the active scroll lock (null if none are active)
32+
*/
33+
activeLockRef?: MutableRefObject<HTMLElement | null>
34+
/**
35+
* @private
36+
*/
37+
addLock?: (id: string, element: HTMLElement) => void
38+
/**
39+
* Disables the current scroll lock (no scroll lock will be enabled as a result)
40+
*/
41+
disableCurrentLock?: () => void
42+
/**
43+
* Enables the scroll lock stacked on top
44+
*/
45+
enableCurrentLock?: () => void
46+
/**
47+
* @private
48+
*/
49+
getLock?: (id: string) => HTMLElement | null
50+
/**
51+
* @private
52+
*/
53+
removeLock?: (id: string) => void
54+
}
55+
56+
export const ScrollLockContext = createContext<ScrollLockContextProps>({})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
3+
MIT License
4+
5+
Copyright (c) 2020 Looker Data Sciences, Inc.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
25+
*/
26+
27+
import noop from 'lodash/noop'
28+
import React, { FC, useRef, useMemo } from 'react'
29+
import { ScrollLockContext } from './ScrollLockContext'
30+
import { activateScrollLock, getActiveScrollLock, ScrollLockMap } from './utils'
31+
32+
export const ScrollLockProvider: FC = ({ children }) => {
33+
// stores all available scroll locks
34+
// (map of ids to elements where scrolling is to be allowed)
35+
const registeredLocksRef = useRef<ScrollLockMap>({})
36+
// stores the current element where scrolling is allowed
37+
// null if no scroll lock is active
38+
const activeLockRef = useRef<HTMLElement | null>(null)
39+
// stores the callback to remove the scroll event listener & restore body styles
40+
const deactivateRef = useRef<() => void>(noop)
41+
42+
// useMemo: this component probably won't re-render much, but if it does
43+
// let's not cause unnecessary diffs from useScrollLock in Dialogs and Popovers
44+
// since those components can be finicky if re-rendered at the wrong moment
45+
const value = useMemo(() => {
46+
function getLock(id?: string): HTMLElement | null {
47+
const registeredLocks = registeredLocksRef.current
48+
return id
49+
? registeredLocks[id] || null
50+
: getActiveScrollLock(registeredLocks)
51+
}
52+
53+
function enableCurrentLock() {
54+
const newElement = getLock()
55+
if (newElement !== activeLockRef.current) {
56+
// Disable the existing scroll lock and update the element
57+
activeLockRef.current = newElement
58+
deactivateRef.current()
59+
// If there's a new element, activate a new scroll lock
60+
if (newElement) {
61+
deactivateRef.current = activateScrollLock(newElement)
62+
}
63+
}
64+
}
65+
66+
function disableCurrentLock() {
67+
deactivateRef.current()
68+
deactivateRef.current = noop
69+
activeLockRef.current = null
70+
}
71+
72+
function addLock(id: string, element: HTMLElement) {
73+
registeredLocksRef.current[id] = element
74+
enableCurrentLock()
75+
}
76+
77+
function removeLock(id: string) {
78+
const existingLock = getLock(id)
79+
if (existingLock) {
80+
const registeredLocks = registeredLocksRef.current
81+
delete registeredLocks[id]
82+
enableCurrentLock()
83+
}
84+
}
85+
86+
return {
87+
activeLockRef,
88+
addLock,
89+
disableCurrentLock,
90+
enableCurrentLock,
91+
getLock,
92+
removeLock,
93+
}
94+
}, [])
95+
96+
return (
97+
<ScrollLockContext.Provider value={value}>
98+
{children}
99+
</ScrollLockContext.Provider>
100+
)
101+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
3+
MIT License
4+
5+
Copyright (c) 2020 Looker Data Sciences, Inc.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
25+
*/
26+
27+
export * from './ScrollLockContext'
28+
export * from './ScrollLockProvider'
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/*
2+
3+
MIT License
4+
5+
Copyright (c) 2020 Looker Data Sciences, Inc.
6+
7+
Permission is hereby granted, free of charge, to any person obtaining a copy
8+
of this software and associated documentation files (the "Software"), to deal
9+
in the Software without restriction, including without limitation the rights
10+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
copies of the Software, and to permit persons to whom the Software is
12+
furnished to do so, subject to the following conditions:
13+
14+
The above copyright notice and this permission notice shall be included in all
15+
copies or substantial portions of the Software.
16+
17+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
SOFTWARE.
24+
25+
*/
26+
27+
import pick from 'lodash/pick'
28+
29+
export interface ScrollLockMap {
30+
[key: string]: HTMLElement
31+
}
32+
33+
function getScrollBarWidth() {
34+
// Add a hidden scrolling div to the page to get the width of a scrollbar
35+
const scrollDiv = document.createElement('div')
36+
scrollDiv.style.width = '99px'
37+
scrollDiv.style.height = '99px'
38+
scrollDiv.style.position = 'absolute'
39+
scrollDiv.style.top = '-9999px'
40+
scrollDiv.style.overflow = 'scroll'
41+
42+
document.body.appendChild(scrollDiv)
43+
const scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth
44+
document.body.removeChild(scrollDiv)
45+
46+
return scrollbarWidth
47+
}
48+
49+
// For the purposes of scroll lock, we're only interested in
50+
// the overflow and paddingRight properties
51+
type BodyStyle = Pick<CSSStyleDeclaration, 'overflow' | 'paddingRight'> | null
52+
53+
function getBodyCurrentStyle(): BodyStyle {
54+
return document !== undefined
55+
? pick(document.body.style, ['overflow', 'paddingRight'])
56+
: null
57+
}
58+
59+
function setBodyStyles() {
60+
if (document !== undefined) {
61+
// Is there a vertical scrollbar?
62+
if (window.innerWidth > document.documentElement.clientWidth) {
63+
const scrollbarWidth = getScrollBarWidth()
64+
const curPaddingRight = window
65+
.getComputedStyle(document.body)
66+
.getPropertyValue('padding-right')
67+
if (curPaddingRight.indexOf('calc') === -1) {
68+
document.body.style.paddingRight = `calc(${curPaddingRight} + ${scrollbarWidth}px)`
69+
}
70+
}
71+
document.body.style.overflow = 'hidden'
72+
}
73+
}
74+
75+
function resetBodyStyles(previousStyle: BodyStyle) {
76+
if (previousStyle) {
77+
document.body.style.paddingRight = previousStyle.paddingRight
78+
document.body.style.overflow = previousStyle.overflow
79+
}
80+
}
81+
82+
export function getActiveScrollLock(lockMap: ScrollLockMap) {
83+
// Sort the elements according to dom position and return the last
84+
// which we assume to be stacked on top since all components using Portal
85+
// share a single zIndexFloor and use dom order to determine stacking
86+
const elements = Object.values(lockMap)
87+
if (elements.length === 0) return null
88+
89+
const sortedElements = elements.sort((elementA, elementB) => {
90+
const relationship = elementA.compareDocumentPosition(elementB)
91+
return relationship > 3 ? 1 : -1
92+
})
93+
return sortedElements[0] || null
94+
}
95+
96+
export function activateScrollLock(element: HTMLElement) {
97+
let scrollTop = window.scrollY
98+
let scrollTarget: EventTarget | HTMLElement | null = document
99+
100+
// Handler for scroll event is needed since body overflow: hidden
101+
// won't stop other scroll-able elements from scrolling
102+
function stopScroll(e: Event) {
103+
if (e.target !== null && e.target !== scrollTarget) {
104+
// If the user has started scrolling in a new scroll-able element
105+
// we need to update the stored scroll top position
106+
scrollTarget = e.target
107+
scrollTop =
108+
scrollTarget instanceof Element
109+
? scrollTarget.scrollTop
110+
: window.scrollY
111+
}
112+
if (
113+
scrollTarget instanceof Element &&
114+
!(element && element.contains(scrollTarget))
115+
) {
116+
scrollTarget.scrollTop = scrollTop
117+
} else if (scrollTarget === document) {
118+
// Scroll event can't actually be prevented so instead we
119+
// scroll the window back to the stored position
120+
window.scrollTo({ top: scrollTop })
121+
}
122+
}
123+
124+
const previousStyle = getBodyCurrentStyle()
125+
126+
setBodyStyles()
127+
window.addEventListener('scroll', stopScroll, true)
128+
129+
return () => {
130+
window.removeEventListener('scroll', stopScroll, true)
131+
resetBodyStyles(previousStyle)
132+
}
133+
}

packages/components-providers/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@
2424
2525
*/
2626
export * from './ComponentsProvider'
27+
export * from './ScrollLock'

0 commit comments

Comments
 (0)