← back to Dw Theme Compact Toolbar

snippets/layout.sticky-scroller.liquid

153 lines

{%- doc -%}
  A sticky scroller component that wraps an element. This allows for an
  element to be scrollable so that if it's taller than the viewport, it will
  scroll as the page scrolls, while also remaining sticky.

  @param slot {string} The slot content to wrap

  @example
  {% render 'layout.sticky-scroller', slot: collection_sidebar %}
{%- enddoc -%}

{% stylesheet %}
  :root {
    --layout-sticky-scroller-top: 20px;
  }

	sticky-scroller {
	  --_top: var(--layout-sticky-scroller-top);
	
	  display: block;
	  position: sticky;
	  top: var(--_top);
	}
{% endstylesheet %}

<sticky-scroller>
  {{ slot }}
</sticky-scroller>
 
{% javascript %}
  class StickyScroller extends HTMLElement {
    constructor() {
      super()
      this.resizeObserver = new ResizeObserver(() => this.recalculateStyles())
      this.abortController = new AbortController()
      this.isScrolling = false
      this.position = 'relative'
      this.lastKnownScrollY = 0
      this.initialTop = 0
      this.currentTop = 0
      this.boundOnScroll = this.onScroll.bind(this)
    }

    connectedCallback() {
      this.inView(this, this.setupObservers.bind(this), { margin: '500px' })
    }

    disconnectedCallback() {
      this.cleanupObservers()
    }

    setupObservers() {
      this.resizeObserver.observe(this)
      window.addEventListener('scroll', this.boundOnScroll, { passive: true, signal: this.abortController.signal })
      return this.cleanupObservers.bind(this)
    }

    cleanupObservers() {
      this.abortController.abort()
      this.resizeObserver.disconnect()
    }

    onScroll() {
      if (!this.isScrolling) {
        requestAnimationFrame(() => {
          this.updatePosition()
          this.isScrolling = false
        })
        this.isScrolling = true
      }
    }

    recalculateStyles() {
      this.style.removeProperty('top')
      const { top, position } = getComputedStyle(this)
      this.initialTop = parseInt(top, 10)
      this.position = position
      this.updatePosition()
    }

    updatePosition() {
      if (this.position !== 'sticky') {
        this.style.removeProperty('top')
        return
      }

      const { top, height } = this.getBoundingClientRect()
      const maxTop = top + window.scrollY - this.offsetTop + this.initialTop
      const minTop = height - window.innerHeight + 20

      this.currentTop += this.lastKnownScrollY - window.scrollY
      this.currentTop = Math.min(Math.max(this.currentTop, -minTop), maxTop, this.initialTop)

      this.lastKnownScrollY = window.scrollY
      this.style.top = `${Math.round(this.currentTop)}px`
    }
    
    /**
     * Original source from https://www.npmjs.com/package/@motionone/dom
     * Usage: https://motion.dev/dom/in-view
     */
    inView(elementOrSelector, onStart, { root, margin: rootMargin, amount = 'any' } = {}) {
      const thresholds = {
        any: 0,
        all: 1
      }

      if (typeof IntersectionObserver === 'undefined') {
        return () => {}
      }

      let elements
      if (typeof elementOrSelector === 'string') {
        elements = document.querySelectorAll(elementOrSelector)
      } else if (elementOrSelector instanceof Element) {
        elements = [elementOrSelector]
      } else {
        elements = Array.from(elementOrSelector || [])
      }

      const activeIntersections = new WeakMap()
      const onIntersectionChange = (entries) => {
        entries.forEach((entry) => {
          const onEnd = activeIntersections.get(entry.target)
          if (entry.isIntersecting === Boolean(onEnd)) return
          if (entry.isIntersecting) {
            const newOnEnd = onStart(entry)
            if (typeof newOnEnd === 'function') {
              activeIntersections.set(entry.target, newOnEnd)
            } else {
              observer.unobserve(entry.target)
            }
          } else if (onEnd) {
            onEnd(entry)
            activeIntersections.delete(entry.target)
          }
        })
      }

      const observer = new IntersectionObserver(onIntersectionChange, {
        root,
        rootMargin,
        threshold: typeof amount === 'number' ? amount : thresholds[amount]
      })

      elements.forEach((element) => observer.observe(element))
      return () => observer.disconnect()
    }
  }

  customElements.define('sticky-scroller', StickyScroller)
{% endjavascript %}