From 5bc0194a5c47b24a33d17007e3cf2c46cdaedb10 Mon Sep 17 00:00:00 2001 From: smastrom Date: Tue, 14 Nov 2023 18:28:53 +0100 Subject: [PATCH] BREAKING - Remove scrollDelta logic, refactor useFixedHeader --- src/constants.ts | 23 ++-- src/types.ts | 46 +++---- src/useFixedHeader.ts | 289 ++++++++++++++++++------------------------ src/utils.ts | 29 +++-- 4 files changed, 166 insertions(+), 221 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index 229ba41..9e3cec5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,26 +1,19 @@ import type { Ref } from 'vue' import type { UseFixedHeaderOptions } from './types' -export const CAPTURE_DELTA_FRAME_COUNT = 10 - -export const DEFAULT_ENTER_DELTA = 0.5 - -export const DEFAULT_LEAVE_DELTA = 0.15 - -const easing = 'cubic-bezier(0.16, 1, 0.3, 1)' - -export const defaultOptions: Required = { - enterDelta: DEFAULT_ENTER_DELTA, - leaveDelta: DEFAULT_LEAVE_DELTA, - root: null, - toggleVisibility: true, +export const TRANSITION_STYLES = { enterStyles: { - transition: `transform 0.3s ${easing} 0s`, + transition: `transform 0.4s cubic-bezier(0.16, 1, 0.3, 1) 0s`, transform: 'translateY(0px)', }, leaveStyles: { - transition: `transform 0.5s ${easing} 0s`, + transition: `transform 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0s`, transform: 'translateY(-101%)', }, +} + +export const defaultOptions: UseFixedHeaderOptions = { + root: null, watch: (() => null) as unknown as Ref, + transitionOpacity: false, } diff --git a/src/types.ts b/src/types.ts index dfaba3a..a7fabfd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,37 +1,27 @@ -import type { Ref, ComputedRef, CSSProperties, ShallowRef } from 'vue' +import type { Ref, ComputedRef } from 'vue' export type MaybeTemplateRef = HTMLElement | null | Ref export interface UseFixedHeaderOptions { /** - * Use `null` if content is scrolled by the window (default), - * otherwise pass a custom scrolling container template ref */ - root?: MaybeTemplateRef - /** - * Whether to toggle `visibility: hidden` on leave. - * Set this to `false` if you want to keep the header - * visible. + * Scrolling container, defaults to `document.documentElement` + * when `null`. + * + * @default null */ - toggleVisibility?: boolean - /** - * ref or computed to watch for automatic behavior toggling */ - watch?: Ref | ComputedRef - /** - * Minimum acceleration delta required to hide the header */ - leaveDelta?: number - /** - * Minimum acceleration delta required to show the header */ - enterDelta?: number + root: MaybeTemplateRef /** - * Custom enter transition styles */ - enterStyles?: CSSProperties + * Signal without `.value` (ref or computed) to be watched + * for automatic behavior toggling. + * + * @default null + */ + watch: Ref | ComputedRef /** - * Custom leave transition styles */ - leaveStyles?: CSSProperties -} - -export interface UseFixedHeaderReturn { - styles: ShallowRef - isLeave: ComputedRef - isEnter: ComputedRef + * Whether to transition `opacity` propert from 0 to 1 + * and vice versa along with the `transform` property + * + * @default false + */ + transitionOpacity: boolean } diff --git a/src/useFixedHeader.ts b/src/useFixedHeader.ts index bc57055..9108b98 100644 --- a/src/useFixedHeader.ts +++ b/src/useFixedHeader.ts @@ -1,18 +1,9 @@ -import { - onBeforeUnmount, - shallowRef, - ref, - unref, - watch, - computed, - readonly, - type CSSProperties as CSS, -} from 'vue' - -import { mergeDefined, isSSR, isReducedMotion } from './utils' -import { CAPTURE_DELTA_FRAME_COUNT, defaultOptions } from './constants' - -import type { UseFixedHeaderOptions, MaybeTemplateRef, UseFixedHeaderReturn } from './types' +import { shallowRef, ref, unref, watch, computed, readonly, type CSSProperties as CSS } from 'vue' + +import { isSSR, useReducedMotion } from './utils' +import { defaultOptions, TRANSITION_STYLES } from './constants' + +import type { UseFixedHeaderOptions, MaybeTemplateRef } from './types' enum State { READY, @@ -22,36 +13,38 @@ enum State { export function useFixedHeader( target: MaybeTemplateRef, - options: UseFixedHeaderOptions = defaultOptions, -): UseFixedHeaderReturn { - const mergedOptions = mergeDefined(defaultOptions, options) + options: Partial = {}, +) { + // Config + + const config = { ...defaultOptions, ...options } + + const { enterStyles, leaveStyles } = TRANSITION_STYLES let resizeObserver: ResizeObserver | undefined = undefined - let isListeningScroll = false - let isHovering = false + const isReduced = useReducedMotion() // Internal state + const internals = { + skipInitialObserverCb: true, + isListeningScroll: false, + isHovering: false, + isMount: true, + } + const styles = shallowRef({}) const state = ref(State.READY) - function setStyles(newStyles: CSS) { - styles.value = newStyles - } + const setStyles = (newStyles: CSS) => (styles.value = newStyles) + const removeStyles = () => (styles.value = {}) + const setState = (newState: State) => (state.value = newState) - function removeStyles() { - styles.value = {} - } - - function setState(newState: State) { - state.value = newState - } - - // Target utils + // Utils function getRoot() { - const root = unref(mergedOptions.root) + const root = unref(config.root) if (root != null) return root return document.documentElement @@ -93,29 +86,46 @@ export function useFixedHeader( */ function onScrollRestoration() { requestAnimationFrame(() => { + if (!internals.isMount || !isFixed()) return + const isInstant = getScrollTop() > getHeaderHeight() * 1.2 // Resolves to false if scroll is smooth if (isInstant) { setStyles({ - ...mergedOptions.leaveStyles, - ...(mergedOptions.toggleVisibility ? { visibility: 'hidden' } : {}), - transition: '', // We don't want transitions to play on page load... + transform: leaveStyles.transform, + visibility: 'hidden', }) } else { - setStyles({ ...mergedOptions.enterStyles, transition: '' }) //...same here + setStyles({ transform: enterStyles.transform }) } + + internals.isMount = false }) } + /** + * Resize observer is added wheter or not the header is fixed/sticky + * as it is in charge of toggling scroll/pointer listeners if it + * turns from fixed/sticky to something else and vice-versa. + */ + function addResizeObserver() { + resizeObserver = new ResizeObserver(() => { + if (internals.skipInitialObserverCb) return (internals.skipInitialObserverCb = false) + toggleListeners() + }) + + const root = getRoot() + if (root) resizeObserver.observe(root) + } + function onVisible() { if (state.value === State.ENTER) return - toggleTransitionListener(true) + removeTransitionListener() setStyles({ - ...mergedOptions.enterStyles, - ...(mergedOptions.toggleVisibility ? { visibility: '' as CSS['visibility'] } : {}), - ...(isReducedMotion() ? { transition: 'none' } : {}), + ...enterStyles, + visibility: '' as CSS['visibility'], }) setState(State.ENTER) @@ -124,97 +134,70 @@ export function useFixedHeader( function onHidden() { if (state.value === State.LEAVE) return - setStyles({ - ...mergedOptions.leaveStyles, - ...(isReducedMotion() ? { transition: 'none' } : {}), - }) + setStyles(leaveStyles) setState(State.LEAVE) - toggleTransitionListener() + + addTransitionListener() } - // Transition + // Transition Events function onTransitionEnd(e: TransitionEvent) { - toggleTransitionListener(true) + removeTransitionListener() + + if (!unref(target) || e.target !== unref(target)) return - if (e.target !== unref(target)) return + /** + * In some edge cases this might be called when the header + * is visible, so we need to check the transform value. + */ + const { transform } = window.getComputedStyle(unref(target)!) + // console.log('transform@transitionEnd', transform) + if (transform === 'matrix(1, 0, 0, 1, 0, 0)') return // translateY(0px) setStyles({ - ...mergedOptions.leaveStyles, - ...(mergedOptions.toggleVisibility ? { visibility: 'hidden' } : {}), + ...leaveStyles, + visibility: 'hidden', }) } - function toggleTransitionListener(isRemove = false) { + function addTransitionListener() { const el = unref(target) if (!el) return - const method = isRemove ? 'removeEventListener' : ('addEventListener' as const) - el[method]('transitionend', onTransitionEnd as EventListener) + el.addEventListener('transitionend', onTransitionEnd as EventListener) } - // Scroll - - function createScrollHandler() { - let captureEnterDelta = true - let captureLeaveDelta = true - - let prevTop = 0 + function removeTransitionListener() { + const el = unref(target) + if (!el) return - function captureDelta(onCaptured: (value: number) => void) { - let rafId: DOMHighResTimeStamp | undefined = undefined - let frameCount = 0 + el.removeEventListener('transitionend', onTransitionEnd as EventListener) + } - const startMs = performance.now() - const startY = getScrollTop() + // Scroll Events - function rafDelta() { - const nextY = getScrollTop() + function createScrollHandler() { + let prevTop = 0 - if (frameCount === CAPTURE_DELTA_FRAME_COUNT) { - onCaptured(Math.abs(startY - nextY) / (performance.now() - startMs)) - cancelAnimationFrame(rafId as DOMHighResTimeStamp) - } else { - frameCount++ - requestAnimationFrame(rafDelta) - } - } + return () => { + const scrollTop = getScrollTop() - rafId = requestAnimationFrame(rafDelta) - } + const isTopReached = scrollTop <= getHeaderHeight() + const isScrollingUp = scrollTop < prevTop + const isScrollingDown = scrollTop > prevTop - return () => { - const isTopReached = getScrollTop() <= getHeaderHeight() + const step = Math.abs(scrollTop - prevTop) - const isScrollingUp = getScrollTop() < prevTop - const isScrollingDown = getScrollTop() > prevTop + if (isTopReached) return onVisible() + if (step < 10) return - if (isTopReached) { - onVisible() - } else { - if (!isHovering && prevTop > 0) { - if (isScrollingUp && captureEnterDelta) { - captureEnterDelta = false - - captureDelta((value) => { - if (value >= mergedOptions.enterDelta) { - onVisible() - } - - captureEnterDelta = true - }) - } else if (isScrollingDown && captureLeaveDelta) { - captureLeaveDelta = false - - captureDelta((value) => { - if (value >= mergedOptions.leaveDelta) { - onHidden() - } - - captureLeaveDelta = true - }) - } + if (!internals.isHovering) { + if (isScrollingUp) { + onVisible() + } else if (isScrollingDown) { + onHidden() } } @@ -224,41 +207,46 @@ export function useFixedHeader( const onScroll = createScrollHandler() - function toggleScroll(isRemove = false) { + function addScrollListener() { const root = getRoot() if (!root) return const scrollRoot = root === document.documentElement ? document : root - const method = isRemove ? 'removeEventListener' : 'addEventListener' - - scrollRoot[method]('scroll', onScroll, { passive: true }) - isListeningScroll = !isRemove + scrollRoot.addEventListener('scroll', onScroll, { passive: true }) + internals.isListeningScroll = true } - // Pointer + function removeScrollListener() { + const root = getRoot() + if (!root) return + + const scrollRoot = root === document.documentElement ? document : root - function setPointer(e: PointerEvent) { - isHovering = unref(target)?.contains(e.target as Node) ?? false + scrollRoot.removeEventListener('scroll', onScroll) + internals.isListeningScroll = false } - function togglePointer(isRemove = false) { - const method = isRemove ? 'removeEventListener' : 'addEventListener' + // Pointer Events - document[method]('pointermove', setPointer as EventListener) + function onPointerMove(e: PointerEvent) { + internals.isHovering = unref(target)?.contains(e.target as Node) ?? false } - // Listeners + function addPointerListener() { + document.addEventListener('pointermove', onPointerMove) + } - function removeListeners() { - toggleScroll(true) - togglePointer(true) + function removePointerListener() { + document.removeEventListener('pointermove', onPointerMove) } + // Listeners + function toggleListeners() { const isValid = isFixed() - if (isListeningScroll) { + if (internals.isListeningScroll) { // If the header is not anymore fixed or sticky if (!isValid) { removeListeners() @@ -267,66 +255,37 @@ export function useFixedHeader( // If was not listening and now is fixed or sticky } else { if (isValid) { - toggleScroll() - togglePointer() + addScrollListener() + addPointerListener() } } } - function _onCleanup() { - removeListeners() - resizeObserver?.disconnect() - } - - // Resize observer - - let skipInitial = true - - function addResizeObserver() { - resizeObserver = new ResizeObserver(() => { - if (skipInitial) return (skipInitial = false) - toggleListeners() - }) - - const root = getRoot() - if (root) resizeObserver.observe(root) + function removeListeners() { + removeScrollListener() + removePointerListener() } - // Watchers - - /** - * Using this instead of 'onMounted' allows to toggle resize - * observer and scroll listener also in case the header is - * somehow removed from the DOM and the parent component that - * calls `useFixedHeader` is not unmounted. - */ watch( - () => [unref(target), unref(mergedOptions.root)], - ([targetEl, rootEl], _, onCleanup) => { - const shouldInit = !isSSR && targetEl && (rootEl || rootEl === null) + () => [unref(target), unref(config.root), isReduced.value, config.watch], + ([headerEl, rootEl, isReduced], _, onCleanup) => { + const shouldInit = !isReduced && !isSSR && headerEl && (rootEl || rootEl === null) if (shouldInit) { - /** - * Resize observer is added in any case as it is - * in charge of toggling scroll/pointer listeners if the header - * turns from fixed/sticky to something else and vice-versa. - */ addResizeObserver() - if (!isFixed()) return - onScrollRestoration() toggleListeners() } - onCleanup(_onCleanup) + onCleanup(() => { + removeListeners() + resizeObserver?.disconnect() + removeStyles() + }) }, { immediate: true, flush: 'post' }, ) - watch(mergedOptions.watch, toggleListeners, { flush: 'post' }) - - onBeforeUnmount(_onCleanup) - return { styles: readonly(styles), isLeave: computed(() => state.value === State.LEAVE), diff --git a/src/utils.ts b/src/utils.ts index e484c9e..6e9bca2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,19 +1,22 @@ -export function mergeDefined(defaults: Required, options: T): Required { - const result = { ...defaults } +import { onBeforeUnmount, onMounted, ref } from 'vue' - for (const key in options) { - if (typeof options[key] !== 'undefined') { - result[key] = options[key] - } - } +export const isSSR = typeof window === 'undefined' - return result as Required -} +export function useReducedMotion() { + const isReduced = ref(false) -export const isSSR = typeof window === 'undefined' + const query = window.matchMedia('(prefers-reduced-motion: reduce)') + + const onMatch = () => (isReduced.value = query.matches) + + onMounted(() => { + onMatch() + query.addEventListener?.('change', onMatch) + }) -export function isReducedMotion() { - if (isSSR) return false + onBeforeUnmount(() => { + query.removeEventListener?.('change', onMatch) + }) - return window.matchMedia('(prefers-reduced-motion: reduce)').matches + return isReduced }