Plan step 22, actions.md § Missing (FAB to extended FAB scroll collapse): M3 has an extended FAB collapse to a FAB on scroll-down and re-extend on scroll-up, and `<x-button fab>` only swapped on window width. `<x-fab collapse-on-scroll>` (extended, with an icon) takes a `materialFab` flag from the new resources/js/fab.js, which reads the window's scroll once a frame, ignores moves under 8px and extends again near the top. The morph is CSS in actions.css: the label sits in a grid track that closes to zero, the gap and minimum width follow on the default spatial spring, and the label fades on the effects one; reduced motion swaps outright through the motion tokens. The label is clipped, not removed, so the collapsed FAB keeps its accessible name. 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/actions.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())
|
|
},
|
|
}))
|
|
})
|