<x-fab-menu> renders data-md-fab-menu with its trigger data-md-fab-menu-trigger, data-md-color and data-md-position; a close icon swaps in on aria-expanded instead of Tailwind's group variant. fab-menu.css draws FabMenuBaselineTokens' 56px trigger, its round-to-full morph on the default springs (ACT-19), the 8px list padding and its calc(100dvh-128px) scroll cap behind the fixed close button (ACT-32), and the [data-md-fab-menu-popover]:not(:popover-open) rule that drives fab-menu-item's exit (ACT-33) — actions.css's last two rules, moved here, empty it, so it and its tailwind.css import are deleted; fab.js's stale reference to it is corrected to fab.css (plan step 36, actions — the group is now fully off Tailwind). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
75 lines
2.5 KiB
JavaScript
75 lines
2.5 KiB
JavaScript
/**
|
|
* `materialFab`: `<x-fab collapse-on-scroll>` — 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,
|
|
ticking: false,
|
|
listeners: [],
|
|
|
|
init() {
|
|
this.lastY = Math.max(window.scrollY, 0)
|
|
this.listen(window, 'scroll', () => this.queue(), { passive: true })
|
|
},
|
|
|
|
/** One reading a frame: `scroll` fires far more often than anything can be drawn. */
|
|
queue() {
|
|
if (this.ticking) {
|
|
return
|
|
}
|
|
|
|
this.ticking = true
|
|
|
|
requestAnimationFrame(() => {
|
|
this.ticking = false
|
|
this.measure()
|
|
})
|
|
},
|
|
|
|
measure() {
|
|
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
|
|
}
|
|
},
|
|
|
|
listen(target, type, handler, options) {
|
|
target.addEventListener(type, handler, options)
|
|
this.listeners.push(() => target.removeEventListener(type, handler, options))
|
|
},
|
|
|
|
destroy() {
|
|
this.listeners.forEach((remove) => remove())
|
|
},
|
|
}))
|
|
})
|