Collapse an extended FAB to a FAB while the page scrolls

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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 07:15:56 +02:00
co-authored by Claude Opus 5
parent 44ae9ecea4
commit 7e4e7b44c6
7 changed files with 179 additions and 2 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* `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())
},
}))
})
+1
View File
@@ -11,6 +11,7 @@ import './theme.js'
import './figure.js'
import './tooltip.js'
import './menu.js'
import './fab.js'
import './snackbar.js'
import './rich-tooltip.js'
import './progress.js'