Redon

一心的小屋

useFixedHeader

发布于 # Vue # hooks

演示

用法

<script setup>
import { ref } from 'vue'
import { useFixedHeader } from './useFixedHeader'

const headerRef = ref(null)

const { styles } = useFixedHeader(headerRef)
</script>

<template>
   <header class="Header" ref="headerRef" :style="styles">
      
   </header>
</template>

<style scoped>
.Header {
   position: fixed;
   top: 0;
}
</style>

代码

useFixedHeader.ts

import {
   shallowRef,
   ref,
   unref,
   watch,
   computed,
   readonly,
   type ComputedRef,
   type CSSProperties as CSS,
} from 'vue'

import { useReducedMotion, isBrowser } from './utils'
import { TRANSITION_STYLES } from './constants'

import type { UseFixedHeaderOptions, MaybeTemplateRef } from './types'

enum State {
   READY,
   ENTER,
   LEAVE,
}

export function useFixedHeader(
   target: MaybeTemplateRef,
   options: Partial<UseFixedHeaderOptions> = {},
): {
   styles: Readonly<CSS>
   isLeave: ComputedRef<boolean>
   isEnter: ComputedRef<boolean>
} {
   // Config

   const { enterStyles, leaveStyles } = TRANSITION_STYLES

   const isReduced = useReducedMotion()

   // State

   const internal = {
      resizeObserver: undefined as ResizeObserver | undefined,
      initResizeObserver: false,
      isListeningScroll: false,
      isHovering: false,
   }

   const styles = shallowRef<CSS>({})
   const state = ref<State>(State.READY)

   const setStyles = (newStyles: CSS) => (styles.value = newStyles)
   const removeStyles = () => (styles.value = {})
   const setState = (newState: State) => (state.value = newState)

   // Utils

   function getRoot() {
      const _root = unref(options.root)
      if (_root != null) return _root

      return document.documentElement
   }

   const getScrollTop = () => getRoot().scrollTop

   function getScrollRoot() {
      const root = getRoot()
      return root === document.documentElement ? document : root
   }

   function isFixed() {
      const el = unref(target)
      if (!el) return false

      const { position, display } = window.getComputedStyle(el)
      return (position === 'fixed' || position === 'sticky') && display !== 'none'
   }

   function getHeaderHeight() {
      const el = unref(target)
      if (!el) return 0

      let headerHeight = el.scrollHeight

      const { marginTop, marginBottom } = window.getComputedStyle(el)
      headerHeight += Number.parseFloat(marginTop) + Number.parseFloat(marginBottom)

      return headerHeight
   }

   // Callbacks

   /**
    * 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() {
      internal.resizeObserver = new ResizeObserver(() => {
         // Skip the initial call
         if (!internal.initResizeObserver) return (internal.initResizeObserver = true)
         toggleListeners()
      })

      internal.resizeObserver.observe(getRoot())
   }

   function onVisible() {
      if (state.value === State.ENTER) return

      removeTransitionListener()

      setStyles({
         ...enterStyles,
         ...(unref(options.transitionOpacity) ? { opacity: 1 } : {}),
         visibility: '' as CSS['visibility'],
      })

      setState(State.ENTER)
   }

   function onHidden() {
      if (state.value === State.LEAVE) return

      setStyles({ ...leaveStyles, ...(unref(options.transitionOpacity) ? { opacity: 0 } : {}) })

      setState(State.LEAVE)

      addTransitionListener()
   }

   // Transition Events

   function onTransitionEnd(e: TransitionEvent) {
      removeTransitionListener()

      if (!unref(target) || e.target !== unref(target) || e.propertyName !== 'transform') 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)!)
      if (transform === 'matrix(1, 0, 0, 1, 0, 0)') return // translateY(0px)

      setStyles({
         ...leaveStyles,
         visibility: 'hidden',
      })
   }

   function addTransitionListener() {
      const el = unref(target)
      if (!el) return

      el.addEventListener('transitionend', onTransitionEnd as EventListener)
   }

   function removeTransitionListener() {
      const el = unref(target)
      if (!el) return

      el.removeEventListener('transitionend', onTransitionEnd as EventListener)
   }

   // Scroll Events

   function createScrollHandler() {
      let prevTop = isBrowser ? getScrollTop() : 0

      return () => {
         const scrollTop = getScrollTop()

         const isTopReached = scrollTop <= getHeaderHeight()
         const isScrollingUp = scrollTop < prevTop
         const isScrollingDown = scrollTop > prevTop

         const step = Math.abs(scrollTop - prevTop)

         if (isTopReached) return onVisible()
         if (step < 10) return

         if (!internal.isHovering) {
            if (isScrollingUp) {
               onVisible()
            } else if (isScrollingDown) {
               onHidden()
            }
         }

         prevTop = scrollTop
      }
   }

   const onScroll = createScrollHandler()

   function addScrollListener() {
      getScrollRoot().addEventListener('scroll', onScroll, { passive: true })
      internal.isListeningScroll = true
   }

   function removeScrollListener() {
      getScrollRoot().removeEventListener('scroll', onScroll)
      internal.isListeningScroll = false
   }

   // Pointer Events

   const onPointerEnter = () => (internal.isHovering = true)
   const onPointerLeave = () => (internal.isHovering = false)

   function addPointerListener() {
      unref(target)?.addEventListener('pointerenter', onPointerEnter)
      unref(target)?.addEventListener('pointerleave', onPointerLeave)
   }

   function removePointerListener() {
      unref(target)?.removeEventListener('pointerenter', onPointerEnter)
      unref(target)?.removeEventListener('pointerleave', onPointerLeave)
   }

   // Listeners

   function toggleListeners() {
      const isValid = isFixed()

      if (internal.isListeningScroll) {
         // If the header is not anymore fixed or sticky
         if (!isValid) {
            removeListeners()
            removeStyles()
         }
         // If was not listening and now is fixed or sticky
      } else {
         if (isValid) {
            addScrollListener()
            addPointerListener()
         }
      }
   }

   function removeListeners() {
      removeScrollListener()
      removePointerListener()
   }

   isBrowser &&
      watch(
         () => [unref(target), getRoot(), isReduced.value, unref(options.watch)],
         ([headerEl, rootEl, isReduced], _, onCleanup) => {
            const shouldInit = !isReduced && headerEl && (rootEl || rootEl === null)

            if (shouldInit) {
               addResizeObserver()
               toggleListeners()
            }

            onCleanup(() => {
               removeListeners()
               removeStyles()
               internal.resizeObserver?.disconnect()
               internal.initResizeObserver = false
            })
         },
         { immediate: true, flush: 'post' },
      )

   return {
      styles: readonly(styles),
      isLeave: computed(() => state.value === State.LEAVE),
      isEnter: computed(() => state.value === State.ENTER),
   }
}

constants.ts

export const EASING = 'cubic-bezier(0.16, 1, 0.3, 1)'

export const TRANSITION_STYLES = {
   enterStyles: {
      transition: `all 0.35s ${EASING} 0s`,
      transform: 'translateY(0px)',
   },
   leaveStyles: {
      transition: `all 0.5s ${EASING} 0s`,
      transform: 'translateY(-101%)',
   },
}

utils.ts

import { onBeforeUnmount, onMounted, ref } from 'vue'

export const isBrowser = typeof window !== 'undefined'

export function useReducedMotion() {
   const isReduced = ref(false)

   if (!isBrowser) return isReduced

   const query = window.matchMedia('(prefers-reduced-motion: reduce)')

   const onMatch = () => (isReduced.value = query.matches)

   onMounted(() => {
      onMatch()
      query.addEventListener?.('change', onMatch)
   })

   onBeforeUnmount(() => {
      query.removeEventListener?.('change', onMatch)
   })

   return isReduced
}

types.ts

import type { Ref, ComputedRef } from 'vue'

export type MaybeTemplateRef = HTMLElement | null | Ref<HTMLElement | null>

export interface UseFixedHeaderOptions<T = any> {
   /**
    * Scrolling container. Matches `document.documentElement` if `null`.
    *
    * @default null
    */
   root: MaybeTemplateRef
   /**
    * Signal without `.value` (ref or computed) to be watched
    * for automatic behavior toggling.
    *
    * @default null
    */
   watch: Ref<T> | ComputedRef<T>
   /**
    * Whether to transition `opacity` property from 0 to 1
    * and vice versa along with the `transform` property
    *
    * @default false
    */
   transitionOpacity: boolean | Ref<boolean> | ComputedRef<boolean>
}