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:
co-authored by
Claude Opus 5
parent
44ae9ecea4
commit
7e4e7b44c6
@@ -321,6 +321,8 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
|||||||
|
|
||||||
`<x-fab icon="add" tooltip="New share" />` — `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 (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — 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).
|
`<x-fab icon="add" tooltip="New share" />` — `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 (`<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">` — 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` (`<x-fab icon="edit" label="Compose" collapse-on-scroll />`) 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.
|
||||||
|
|
||||||
### `<x-fab-menu>`, `<x-fab-menu-item>`
|
### `<x-fab-menu>`, `<x-fab-menu-item>`
|
||||||
|
|
||||||
```blade
|
```blade
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* The transitions an action runs between its states — buttons, connected segments, menu items,
|
* 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,
|
* 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
|
* size, rotation, rounded corners) and overshoots by 9% on the way, effects changes how things
|
||||||
@@ -84,3 +84,65 @@
|
|||||||
translate: 0 0.5rem;
|
translate: 0 0.5rem;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An extended FAB that collapses to a FAB while the page scrolls down (`<x-fab
|
||||||
|
* collapse-on-scroll>`, 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -11,6 +11,7 @@ import './theme.js'
|
|||||||
import './figure.js'
|
import './figure.js'
|
||||||
import './tooltip.js'
|
import './tooltip.js'
|
||||||
import './menu.js'
|
import './menu.js'
|
||||||
|
import './fab.js'
|
||||||
import './snackbar.js'
|
import './snackbar.js'
|
||||||
import './rich-tooltip.js'
|
import './rich-tooltip.js'
|
||||||
import './progress.js'
|
import './progress.js'
|
||||||
|
|||||||
@@ -20,6 +20,14 @@
|
|||||||
`data-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation
|
`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.
|
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
|
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
|
||||||
(androidx Compose Material 3, Apache-2.0). --}}
|
(androidx Compose Material 3, Apache-2.0). --}}
|
||||||
|
|
||||||
@@ -33,12 +41,14 @@
|
|||||||
'external' => false,
|
'external' => false,
|
||||||
'tooltip' => null,
|
'tooltip' => null,
|
||||||
'type' => 'button',
|
'type' => 'button',
|
||||||
|
'collapseOnScroll' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm';
|
$size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm';
|
||||||
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
|
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
|
||||||
$extended = filled($label) || $slot->isNotEmpty();
|
$extended = filled($label) || $slot->isNotEmpty();
|
||||||
|
$collapsing = $collapseOnScroll && $extended && filled($icon);
|
||||||
$isLink = filled($link);
|
$isLink = filled($link);
|
||||||
|
|
||||||
$colours = $variant === 'filled'
|
$colours = $variant === 'filled'
|
||||||
@@ -68,6 +78,10 @@
|
|||||||
'type' => $isLink ? null : $type,
|
'type' => $isLink ? null : $type,
|
||||||
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
|
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
|
||||||
'data-fab' => true,
|
'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,
|
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
|
||||||
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
||||||
], fn ($value): bool => $value !== null));
|
], fn ($value): bool => $value !== null));
|
||||||
@@ -78,7 +92,9 @@
|
|||||||
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
|
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($extended)
|
@if ($collapsing)
|
||||||
|
<span data-fab-label><span>{{ $label ?? $slot }}</span></span>
|
||||||
|
@elseif ($extended)
|
||||||
<span>{{ $label ?? $slot }}</span>
|
<span>{{ $label ?? $slot }}</span>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,11 @@
|
|||||||
<x-fab icon="add" label="New share" />
|
<x-fab icon="add" label="New share" />
|
||||||
<x-fab icon="upload" label="Upload" size="md" variant="filled" />
|
<x-fab icon="upload" label="Upload" size="md" variant="filled" />
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'An extended FAB that collapses on scroll (scroll the page)' => <<<'BLADE'
|
||||||
|
<x-fab icon="edit" label="Compose" collapse-on-scroll />
|
||||||
|
<x-fab icon="upload" label="Upload" size="md" color="secondary" collapse-on-scroll />
|
||||||
|
<x-fab icon="add" label="New share" size="lg" color="tertiary" collapse-on-scroll />
|
||||||
|
BLADE,
|
||||||
'FAB menu' => <<<'BLADE'
|
'FAB menu' => <<<'BLADE'
|
||||||
<div class="flex h-72 w-full items-end justify-end">
|
<div class="flex h-72 w-full items-end justify-end">
|
||||||
<x-fab-menu label="New">
|
<x-fab-menu label="New">
|
||||||
|
|||||||
@@ -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'],
|
'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('<x-fab icon="add" label="New share" size="lg" collapse-on-scroll />');
|
||||||
|
|
||||||
|
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('<span data-fab-label><span>New share</span></span>');
|
||||||
|
|
||||||
|
// A plain FAB has nothing to collapse, and an extended FAB without a glyph would collapse to nothing.
|
||||||
|
expect((string) $this->blade('<x-fab icon="add" aria-label="New" collapse-on-scroll />'))->not->toContain('materialFab')
|
||||||
|
->and((string) $this->blade('<x-fab label="New share" collapse-on-scroll />'))->not->toContain('materialFab')
|
||||||
|
->and((string) $this->blade('<x-fab icon="add" label="New share" />'))->not->toContain('materialFab')->toContain('<span>New share</span>');
|
||||||
|
});
|
||||||
|
|
||||||
it('marks its root for the places that draw a nested FAB their own way', function () {
|
it('marks its root for the places that draw a nested FAB their own way', function () {
|
||||||
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain('data-fab');
|
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain('data-fab');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user