diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 3dbe379f..59d0600d 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -321,6 +321,8 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d `` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`
` — M3's 16dp margin, 24dp from `large`). `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0). +`collapse-on-scroll` on an extended FAB with an `icon` (``) is M3's scroll behaviour: it shrinks to the FAB of its size while the window scrolls down and extends again on scroll-up or at the top of the page. The width morphs on the spatial spring and the label fades; under reduced motion it swaps outright. The label stays in the page, clipped, so the collapsed FAB keeps its accessible name. It watches the window, so it is for a FAB pinned over a scrolling page, not one inside a scrolling pane. + ### ``, `` ```blade diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css index 4018d2d3..0f96f924 100644 --- a/resources/css/components/actions.css +++ b/resources/css/components/actions.css @@ -1,6 +1,6 @@ /* * The transitions an action runs between its states — buttons, connected segments, menu items, - * the FAB menu's trigger. + * the FAB menu's trigger, and an extended FAB collapsing to a FAB on scroll. * * M3 has two spring styles and they are not interchangeable: spatial moves things (position, * size, rotation, rounded corners) and overshoots by 9% on the way, effects changes how things @@ -84,3 +84,65 @@ translate: 0 0.5rem; opacity: 0; } + +/* + * An extended FAB that collapses to a FAB while the page scrolls down (``, resources/js/fab.js sets `data-collapsed`). M3's extended FAB page: it + * "can collapse to a FAB on scroll-down and re-expand to extended on scroll-up — when switching + * between FAB↔extended FAB, shape changes, the icon moves left, and the text label fades in/out". + * + * `width: auto` cannot transition, so the label sits in a one-column grid whose track closes to + * zero; that track is what animates, and the button lays itself out around it every frame. The + * gap and the minimum width go with it, all on the default spatial spring — the size of morph + * `state-transition-default` is for — while the label fades on the effects spring. The collapsed + * sizes are the FAB's own (FabBaseline/Medium/LargeTokens: 56/80/96px). The extended FAB's + * padding already centres the glyph at `sm` (16px + 24px + 16px) and `md` (26 + 28 + 26); at + * `lg` its 28px is 4px short of the 32px that centres a 32px glyph in 96px, so that one moves. + * Corners do not change: each size's FAB and extended FAB share one. + * + * The transition list repeats the FAB's own shadow and colour transitions, which this rule + * replaces. Under reduced motion tokens/motion.css takes every duration to zero, so it swaps. + */ +[data-fab-collapsible] { + --fab-collapsed-size: 3.5rem; + + transition-property: min-inline-size, padding-inline, gap, box-shadow, background-color, color; + transition-duration: + var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), + var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration); + transition-timing-function: + var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default), var(--md-sys-motion-spatial-default), + var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast); +} + +[data-fab-collapsible][data-fab-size='md'] { --fab-collapsed-size: 5rem; } +[data-fab-collapsible][data-fab-size='lg'] { --fab-collapsed-size: 6rem; } + +[data-fab-label] { + display: grid; + grid-template-columns: 1fr; + transition-property: grid-template-columns, opacity; + transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration); + transition-timing-function: var(--md-sys-motion-spatial-default), var(--md-sys-motion-effects-default); +} + +[data-fab-label] > span { + overflow: hidden; + white-space: nowrap; +} + +[data-fab-collapsible][data-collapsed] { + min-inline-size: var(--fab-collapsed-size); + gap: 0; +} + +[data-fab-collapsible][data-fab-size='lg'][data-collapsed] { + padding-inline: 2rem; +} + +/* The label stays in the accessibility tree — clipped and transparent, never `display: none` — so + the collapsed FAB keeps the name the extended one had. */ +[data-fab-collapsible][data-collapsed] [data-fab-label] { + grid-template-columns: 0fr; + opacity: 0; +} diff --git a/resources/js/fab.js b/resources/js/fab.js new file mode 100644 index 00000000..b4578d45 --- /dev/null +++ b/resources/js/fab.js @@ -0,0 +1,74 @@ +/** + * `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/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()) + }, + })) +}) diff --git a/resources/js/material.js b/resources/js/material.js index aa6a83f5..2b780e2a 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -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' diff --git a/resources/views/components/fab.blade.php b/resources/views/components/fab.blade.php index 647336f1..c9aefaea 100644 --- a/resources/views/components/fab.blade.php +++ b/resources/views/components/fab.blade.php @@ -20,6 +20,14 @@ `data-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation rail flattens a nested FAB to elevation 0. + `collapse-on-scroll` is M3's extended FAB that "can collapse to a FAB on scroll-down and + re-expand to extended on scroll-up": while the window scrolls down it shrinks to the FAB of + its size, and it extends again when the page scrolls back up or reaches the top. The label + closes and fades while the width follows it on the spatial spring (resources/css/components/ + actions.css); under reduced motion the two swap outright. The label stays in the page, + clipped rather than removed, so the collapsed FAB keeps its accessible name. It needs an + `icon` — a FAB with no glyph is no FAB — and does nothing on a plain FAB. + Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens (androidx Compose Material 3, Apache-2.0). --}} @@ -33,12 +41,14 @@ 'external' => false, 'tooltip' => null, 'type' => 'button', + 'collapseOnScroll' => false, ]) @php $size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm'; $color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'; $extended = filled($label) || $slot->isNotEmpty(); + $collapsing = $collapseOnScroll && $extended && filled($icon); $isLink = filled($link); $colours = $variant === 'filled' @@ -68,6 +78,10 @@ 'type' => $isLink ? null : $type, // The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp. 'data-fab' => true, + 'x-data' => $collapsing ? 'materialFab' : null, + 'x-bind:data-collapsed' => $collapsing ? "collapsed ? '' : null" : null, + 'data-fab-collapsible' => $collapsing ? true : null, + 'data-fab-size' => $collapsing ? $size : null, 'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null, 'style' => $anchor ? "anchor-name: {$anchor}" : null, ], fn ($value): bool => $value !== null)); @@ -78,7 +92,9 @@ @endif - @if ($extended) + @if ($collapsing) + {{ $label ?? $slot }} + @elseif ($extended) {{ $label ?? $slot }} @endif diff --git a/resources/views/showcase/sections/buttons.blade.php b/resources/views/showcase/sections/buttons.blade.php index 7bf4248a..84f77be6 100644 --- a/resources/views/showcase/sections/buttons.blade.php +++ b/resources/views/showcase/sections/buttons.blade.php @@ -155,6 +155,11 @@ BLADE, + 'An extended FAB that collapses on scroll (scroll the page)' => <<<'BLADE' + + + + BLADE, 'FAB menu' => <<<'BLADE'
diff --git a/tests/Feature/Components/FabTest.php b/tests/Feature/Components/FabTest.php index eada234f..50a3e82c 100644 --- a/tests/Feature/Components/FabTest.php +++ b/tests/Feature/Components/FabTest.php @@ -41,6 +41,23 @@ it('gives the extended FAB M3\'s gaps and its 80px minimum width', function (str 'lg' => ['lg', 'h-24 min-w-24 gap-5'], ]); +it('collapses an extended FAB to a FAB on scroll when asked', function () { + $html = (string) $this->blade(''); + + expect($html) + ->toContain('x-data="materialFab"') + ->toContain('x-bind:data-collapsed="collapsed ? '' : null"') + ->toContain('data-fab-collapsible') + ->toContain('data-fab-size="lg"') + // The label is clipped, never removed, so the collapsed FAB keeps its name. + ->toContain('New share'); + + // A plain FAB has nothing to collapse, and an extended FAB without a glyph would collapse to nothing. + expect((string) $this->blade(''))->not->toContain('materialFab') + ->and((string) $this->blade(''))->not->toContain('materialFab') + ->and((string) $this->blade(''))->not->toContain('materialFab')->toContain('New share'); +}); + it('marks its root for the places that draw a nested FAB their own way', function () { expect((string) $this->blade(''))->toContain('data-fab'); });