/** * `materialFab`: `` — M3's extended FAB that collapses to a FAB while the * page scrolls down and extends again on the way back up, or once the page is at the top. * * Only the flag lives here. The morph itself is CSS (resources/css/components/fab.css), which is * how it stays on the spatial spring and how reduced motion makes it instant without a second * path through this file. * * It watches the window, which is what a FAB pinned to the corner of the page scrolls against. A * FAB inside a scrolling pane of its own is not this. */ // Scrolling is noisy — a wheel's own wobble, a rubber-band bounce at either end — and a FAB that // flipped on every pixel would never be still. A move has to be worth this much to count as a // direction, and one worth less is kept and added to the next. const STEP_PX = 8 // Within this much of the top the FAB is extended whichever way the page was last going: M3 // re-expands it "at the bottom of the view", which on a web page is where the page begins. const TOP_PX = 24 document.addEventListener('alpine:init', () => { window.Alpine.data('materialFab', () => ({ collapsed: false, lastY: 0, frame: null, // Set in init(). Declared here, or Alpine writes them to the outermost x-data scope, // where a second FAB in the same page scope would take the first one's listener. schedule: null, init() { this.lastY = Math.max(window.scrollY, 0) this.schedule = () => { this.frame ??= requestAnimationFrame(() => this.measure()) } window.addEventListener('scroll', this.schedule, { passive: true }) }, destroy() { window.removeEventListener('scroll', this.schedule) cancelAnimationFrame(this.frame) }, measure() { this.frame = null const y = Math.max(window.scrollY, 0) const moved = y - this.lastY if (y <= TOP_PX) { this.collapsed = false } else if (moved > STEP_PX) { this.collapsed = true } else if (moved < -STEP_PX) { this.collapsed = false } if (Math.abs(moved) > STEP_PX || y <= TOP_PX) { this.lastY = y } }, })) })