|
| 1 | +import type { Ref } from 'vue' |
| 2 | +import { onBeforeUnmount, watch } from 'vue' |
| 3 | +import type VueFinalModal from './VueFinalModal.vue' |
| 4 | + |
| 5 | +type BodyScrollOptions = { |
| 6 | + reserveScrollBarGap?: boolean |
| 7 | + allowTouchMove?: (el?: null | HTMLElement) => boolean |
| 8 | +} |
| 9 | + |
| 10 | +type Lock = { |
| 11 | + targetElement: HTMLElement |
| 12 | + options?: BodyScrollOptions |
| 13 | +} |
| 14 | + |
| 15 | +// stolen from body-scroll-lock |
| 16 | + |
| 17 | +// Older browsers don't support event options, feature detect it. |
| 18 | +let hasPassiveEvents = false |
| 19 | +if (typeof window !== 'undefined') { |
| 20 | + const passiveTestOptions = { |
| 21 | + get passive() { |
| 22 | + hasPassiveEvents = true |
| 23 | + return undefined |
| 24 | + }, |
| 25 | + } |
| 26 | + // eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 27 | + // @ts-expect-error |
| 28 | + window.addEventListener('testPassive', null, passiveTestOptions) |
| 29 | + // eslint-disable-next-line @typescript-eslint/ban-ts-comment |
| 30 | + // @ts-expect-error |
| 31 | + window.removeEventListener('testPassive', null, passiveTestOptions) |
| 32 | +} |
| 33 | + |
| 34 | +const isIosDevice |
| 35 | + = typeof window !== 'undefined' |
| 36 | + && window.navigator |
| 37 | + && window.navigator.platform |
| 38 | + && (/iP(ad|hone|od)/.test(window.navigator.platform) |
| 39 | + || (window.navigator.platform === 'MacIntel' && window.navigator.maxTouchPoints > 1)) |
| 40 | + |
| 41 | +let locks: Lock[] = [] |
| 42 | +let documentListenerAdded = false |
| 43 | +let clientY = 0 |
| 44 | +let initialClientY = -1 |
| 45 | +let previousBodyOverflowSetting: undefined | string |
| 46 | +let previousBodyPaddingRight: undefined | string |
| 47 | + |
| 48 | +const hasScrollbar = (el: HTMLElement) => { |
| 49 | + if (!el || el.nodeType !== Node.ELEMENT_NODE) |
| 50 | + return false |
| 51 | + |
| 52 | + const style = window.getComputedStyle(el) |
| 53 | + return ['auto', 'scroll'].includes(style.overflowY) && el.scrollHeight > el.clientHeight |
| 54 | +} |
| 55 | + |
| 56 | +const shouldScroll = (el: HTMLElement, delta: number) => { |
| 57 | + if (el.scrollTop === 0 && delta < 0) |
| 58 | + return false |
| 59 | + if (el.scrollTop + el.clientHeight + delta >= el.scrollHeight && delta > 0) |
| 60 | + return false |
| 61 | + return true |
| 62 | +} |
| 63 | + |
| 64 | +const composedPath = (el: null | HTMLElement) => { |
| 65 | + const path = [] |
| 66 | + while (el) { |
| 67 | + path.push(el) |
| 68 | + if (el.classList.contains('vfm')) |
| 69 | + return path |
| 70 | + el = el.parentElement |
| 71 | + } |
| 72 | + return path |
| 73 | +} |
| 74 | + |
| 75 | +const hasAnyScrollableEl = (el: HTMLElement | null, delta: number) => { |
| 76 | + let hasAnyScrollableEl = false |
| 77 | + const path = composedPath(el) |
| 78 | + path.forEach((el) => { |
| 79 | + if (hasScrollbar(el) && shouldScroll(el, delta)) |
| 80 | + hasAnyScrollableEl = true |
| 81 | + }) |
| 82 | + return hasAnyScrollableEl |
| 83 | +} |
| 84 | + |
| 85 | +// returns true if `el` should be allowed to receive touchmove events. |
| 86 | +const allowTouchMove = (el: HTMLElement | null) => locks.some(() => hasAnyScrollableEl(el, -clientY)) |
| 87 | + |
| 88 | +const preventDefault = (rawEvent: TouchEvent) => { |
| 89 | + const e = rawEvent || window.event |
| 90 | + |
| 91 | + // For the case whereby consumers adds a touchmove event listener to document. |
| 92 | + // Recall that we do document.addEventListener('touchmove', preventDefault, { passive: false }) |
| 93 | + // in disableBodyScroll - so if we provide this opportunity to allowTouchMove, then |
| 94 | + // the touchmove event on document will break. |
| 95 | + if (allowTouchMove(e.target as HTMLElement | null)) |
| 96 | + return true |
| 97 | + |
| 98 | + // Do not prevent if the event has more than one touch (usually meaning this is a multi touch gesture like pinch to zoom). |
| 99 | + if (e.touches.length > 1) |
| 100 | + return true |
| 101 | + |
| 102 | + if (e.preventDefault) |
| 103 | + e.preventDefault() |
| 104 | + |
| 105 | + return false |
| 106 | +} |
| 107 | + |
| 108 | +const setOverflowHidden = (options?: BodyScrollOptions) => { |
| 109 | + // If previousBodyPaddingRight is already set, don't set it again. |
| 110 | + if (previousBodyPaddingRight === undefined) { |
| 111 | + const reserveScrollBarGap = !!options && options.reserveScrollBarGap === true |
| 112 | + const scrollBarGap = window.innerWidth - document.documentElement.clientWidth |
| 113 | + |
| 114 | + if (reserveScrollBarGap && scrollBarGap > 0) { |
| 115 | + const computedBodyPaddingRight = parseInt(getComputedStyle(document.body).getPropertyValue('padding-right'), 10) |
| 116 | + previousBodyPaddingRight = document.body.style.paddingRight |
| 117 | + document.body.style.paddingRight = `${computedBodyPaddingRight + scrollBarGap}px` |
| 118 | + } |
| 119 | + } |
| 120 | + // If previousBodyOverflowSetting is already set, don't set it again. |
| 121 | + if (previousBodyOverflowSetting === undefined) { |
| 122 | + previousBodyOverflowSetting = document.body.style.overflow |
| 123 | + document.body.style.overflow = 'hidden' |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +const restoreOverflowSetting = () => { |
| 128 | + if (previousBodyPaddingRight !== undefined) { |
| 129 | + document.body.style.paddingRight = previousBodyPaddingRight |
| 130 | + |
| 131 | + // Restore previousBodyPaddingRight to undefined so setOverflowHidden knows it |
| 132 | + // can be set again. |
| 133 | + previousBodyPaddingRight = undefined |
| 134 | + } |
| 135 | + |
| 136 | + if (previousBodyOverflowSetting !== undefined) { |
| 137 | + document.body.style.overflow = previousBodyOverflowSetting |
| 138 | + |
| 139 | + // Restore previousBodyOverflowSetting to undefined |
| 140 | + // so setOverflowHidden knows it can be set again. |
| 141 | + previousBodyOverflowSetting = undefined |
| 142 | + } |
| 143 | +} |
| 144 | +// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#Problems_and_solutions |
| 145 | +const isTargetElementTotallyScrolled = (targetElement: HTMLElement) => |
| 146 | + targetElement ? targetElement.scrollHeight - targetElement.scrollTop <= targetElement.clientHeight : false |
| 147 | + |
| 148 | +const handleScroll = (event: TouchEvent, targetElement: HTMLElement) => { |
| 149 | + clientY = event.targetTouches[0].clientY - initialClientY |
| 150 | + |
| 151 | + if (allowTouchMove(event.target as HTMLElement | null)) |
| 152 | + return false |
| 153 | + |
| 154 | + if (targetElement && targetElement.scrollTop === 0 && clientY > 0) { |
| 155 | + // element is at the top of its scroll. |
| 156 | + return preventDefault(event) |
| 157 | + } |
| 158 | + |
| 159 | + if (isTargetElementTotallyScrolled(targetElement) && clientY < 0) { |
| 160 | + // element is at the bottom of its scroll. |
| 161 | + return preventDefault(event) |
| 162 | + } |
| 163 | + |
| 164 | + event.stopPropagation() |
| 165 | + return true |
| 166 | +} |
| 167 | + |
| 168 | +export const disableBodyScroll = (targetElement?: HTMLElement, options?: BodyScrollOptions) => { |
| 169 | + // targetElement must be provided |
| 170 | + if (!targetElement) { |
| 171 | + console.error( |
| 172 | + 'disableBodyScroll unsuccessful - targetElement must be provided when calling disableBodyScroll on IOS devices.', |
| 173 | + ) |
| 174 | + return |
| 175 | + } |
| 176 | + |
| 177 | + // disableBodyScroll must not have been called on this targetElement before |
| 178 | + if (locks.some(lock => lock.targetElement === targetElement)) |
| 179 | + return |
| 180 | + |
| 181 | + const lock = { |
| 182 | + targetElement, |
| 183 | + options: options || {}, |
| 184 | + } |
| 185 | + |
| 186 | + locks = [...locks, lock] |
| 187 | + |
| 188 | + if (isIosDevice) { |
| 189 | + targetElement.ontouchstart = (event: TouchEvent) => { |
| 190 | + if (event.targetTouches.length === 1) { |
| 191 | + // detect single touch. |
| 192 | + initialClientY = event.targetTouches[0].clientY |
| 193 | + } |
| 194 | + } |
| 195 | + targetElement.ontouchmove = (event: TouchEvent) => { |
| 196 | + if (event.targetTouches.length === 1) { |
| 197 | + // detect single touch. |
| 198 | + handleScroll(event, targetElement) |
| 199 | + } |
| 200 | + } |
| 201 | + |
| 202 | + if (!documentListenerAdded) { |
| 203 | + document.addEventListener('touchmove', preventDefault, hasPassiveEvents ? { passive: false } : undefined) |
| 204 | + documentListenerAdded = true |
| 205 | + } |
| 206 | + } |
| 207 | + else { |
| 208 | + setOverflowHidden(options) |
| 209 | + } |
| 210 | +} |
| 211 | + |
| 212 | +export const enableBodyScroll = (targetElement?: HTMLElement) => { |
| 213 | + if (!targetElement) { |
| 214 | + console.error( |
| 215 | + 'enableBodyScroll unsuccessful - targetElement must be provided when calling enableBodyScroll on IOS devices.', |
| 216 | + ) |
| 217 | + return |
| 218 | + } |
| 219 | + |
| 220 | + locks = locks.filter(lock => lock.targetElement !== targetElement) |
| 221 | + |
| 222 | + if (isIosDevice) { |
| 223 | + targetElement.ontouchstart = null |
| 224 | + targetElement.ontouchmove = null |
| 225 | + |
| 226 | + if (documentListenerAdded && locks.length === 0) { |
| 227 | + document.removeEventListener('touchmove', preventDefault, (hasPassiveEvents ? { passive: false } : undefined) as any) |
| 228 | + documentListenerAdded = false |
| 229 | + } |
| 230 | + } |
| 231 | + else if (!locks.length) { |
| 232 | + restoreOverflowSetting() |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +export function useLockScroll(props: InstanceType<typeof VueFinalModal>['$props'], options: { |
| 237 | + lockScrollEl: Ref<undefined | HTMLElement> |
| 238 | + modelValueLocal: Ref<boolean> |
| 239 | +}) { |
| 240 | + const { lockScrollEl, modelValueLocal } = options |
| 241 | + |
| 242 | + let _lockScrollEl: HTMLElement |
| 243 | + watch(lockScrollEl, (val) => { |
| 244 | + if (val) |
| 245 | + _lockScrollEl = val |
| 246 | + }, { immediate: true }) |
| 247 | + |
| 248 | + watch(() => props.lockScroll, (val) => { |
| 249 | + val ? _disableBodyScroll() : _enableBodyScroll() |
| 250 | + }) |
| 251 | + |
| 252 | + onBeforeUnmount(() => { |
| 253 | + _enableBodyScroll() |
| 254 | + }) |
| 255 | + |
| 256 | + function _enableBodyScroll() { |
| 257 | + _lockScrollEl && enableBodyScroll(_lockScrollEl) |
| 258 | + } |
| 259 | + |
| 260 | + function _disableBodyScroll() { |
| 261 | + if (!modelValueLocal.value) |
| 262 | + return |
| 263 | + props.lockScroll && _lockScrollEl |
| 264 | + && disableBodyScroll(_lockScrollEl, { |
| 265 | + reserveScrollBarGap: props.reserveScrollBarGap, |
| 266 | + allowTouchMove: (el) => { |
| 267 | + while (el && el !== document.body) { |
| 268 | + if (el.getAttribute('vfm-scroll-lock-ignore') !== null) |
| 269 | + return true |
| 270 | + |
| 271 | + el = el.parentElement |
| 272 | + } |
| 273 | + return false |
| 274 | + }, |
| 275 | + }) |
| 276 | + } |
| 277 | + |
| 278 | + return { |
| 279 | + enableBodyScroll: _enableBodyScroll, |
| 280 | + disableBodyScroll: _disableBodyScroll, |
| 281 | + } |
| 282 | +} |
0 commit comments