From c065d940af89bf4e1009980ac792045fd53aac21 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 16:20:45 +0200 Subject: [PATCH] Draw the FAB menu without Tailwind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/actions.css | 45 ----- resources/css/components/fab-menu.css | 185 ++++++++++++++++++ resources/css/tailwind.css | 2 - resources/js/fab.js | 4 +- resources/views/components/fab-menu.blade.php | 68 +++---- .../Components/ActionStylesheetsTest.php | 1 + tests/Feature/Components/FabMenuTest.php | 55 +++++- 8 files changed, 260 insertions(+), 101 deletions(-) delete mode 100644 resources/css/components/actions.css create mode 100644 resources/css/components/fab-menu.css diff --git a/resources/css/components.css b/resources/css/components.css index 0e9330b0..b7b66a8a 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -25,6 +25,7 @@ @import './components/menu-item.css'; @import './components/menu.css'; @import './components/fab-menu-item.css'; +@import './components/fab-menu.css'; /* Inputs, selection and data */ @import './components/form.css'; diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css deleted file mode 100644 index f8bc535b..00000000 --- a/resources/css/components/actions.css +++ /dev/null @@ -1,45 +0,0 @@ -/* - * What is left of the Tailwind-era actions utilities, waiting on the FAB menu's own rewrite (plan - * step 36, actions): ``'s trigger still names `state-transition-default`, and its - * list still selects `[data-fab-menu]`. Once that component moves into fab-menu.css, in - * `material.components`, this file empties and leaves tailwind.css. - * - * 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 - * look (colour, opacity, elevation) and is critically damped, because a colour must never - * overshoot — tokens/motion.css says so in the library's own words. An element whose corners and - * colours both change on a press therefore needs two springs at once, which Tailwind cannot write - * (`duration-*` and `ease-*` each emit one value); CSS can, by giving `transition-property` a - * list and matching it position for position. - * - * `state-transition-default` is the FAB menu trigger's morph into a close button: shape, size and - * position on the default spatial spring, colour and elevation on the default effects spring - * beside it. - * - * This is an `@utility` declaration, so it compiles into Tailwind's utilities layer like any - * other class: a `transition-*` utility written beside it on the call site will fight it, and - * whichever Tailwind emits last wins. Pass neither. - */ - -@utility state-transition-default { - transition-property: border-radius, padding, margin, background-color, color, box-shadow; - 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-default-duration), var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-effects-default-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-default), var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default); -} - -/* - * A FAB menu's items rise into place as the list opens and sink back as it closes. The entry is - * `@starting-style` on the item itself; the exit needs the state to be readable while the popover - * is on its way out, which is what `:not(:popover-open)` is — the popover keeps `display: flex` - * for the length of its own discrete transition, so the items have somewhere to animate to. - * - * Unlayered (a plain rule, not an `@utility`), so it beats the item's own utilities. - */ -[data-fab-menu]:not(:popover-open) > * { - translate: 0 0.5rem; - opacity: 0; -} diff --git a/resources/css/components/fab-menu.css b/resources/css/components/fab-menu.css new file mode 100644 index 00000000..294de5af --- /dev/null +++ b/resources/css/components/fab-menu.css @@ -0,0 +1,185 @@ +/* + * : an M3 Expressive FAB that opens into a short list of related actions. + * + * FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): the trigger is a 56px FAB, + * `data-md-color`'s container, large corner, elevation 3, rising to elevation 4 under a hovering + * pointer; while its list is open (`aria-expanded`) it turns fully round and paints the colour + * itself, on the default spatial and effects springs (ACT-19 splits the two). The list sits + * `data-md-position`'s corner of the trigger, 8px padding — the room the close button keeps below + * the first item — and scrolls past `calc(100dvh - 128px)` on a short window, behind the close + * button, which stays fixed (ACT-32). + * + * The items (fab-menu-item.css) rise into place as the list opens and sink back as it closes: the + * entry is `@starting-style` on the item itself; the exit needs the state to be readable while the + * popover is on its way out, which `[data-md-fab-menu-popover]:not(:popover-open) > *` is — the + * popover keeps `display: flex` for the length of its own discrete transition, so the items have + * somewhere to animate to (ACT-33). Unlayered would beat an item's own declarations, but nothing + * here needs to: the item sets no `translate`/`opacity` of its own once it has entered, so this + * rule, inside the layer, is what always wins while the popover is closed. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; + +@layer material.components { + [data-md-fab-menu] { + position: relative; + display: inline-flex; + } + + [data-md-fab-menu-anchor] { + display: inline-flex; + } + + [data-md-fab-menu-trigger] { + --md-fab-menu-container: var(--md-sys-color-primary-container); + --md-fab-menu-on-container: var(--md-sys-color-on-primary-container); + --md-fab-menu-color: var(--md-sys-color-primary); + --md-fab-menu-on-color: var(--md-sys-color-on-primary); + + position: relative; + isolation: isolate; + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + inline-size: var(--md-sys-measurement-space700); + block-size: var(--md-sys-measurement-space700); + border-radius: var(--md-sys-shape-corner-lg); + background-color: var(--md-fab-menu-container); + color: var(--md-fab-menu-on-container); + box-shadow: var(--md-sys-elevation-3); + cursor: pointer; + outline: none; + -webkit-user-select: none; + user-select: none; + transition-property: border-radius, background-color, color, box-shadow; + transition-duration: + var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration), + var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-effects-default-duration); + transition-timing-function: + var(--md-sys-motion-spatial-default), var(--md-sys-motion-effects-default), + var(--md-sys-motion-effects-default), var(--md-sys-motion-effects-default); + + &::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background-color: currentColor; + opacity: 0; + pointer-events: none; + transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast); + } + + @media (hover: hover) { + &:hover { + box-shadow: var(--md-sys-elevation-4); + } + + &:hover::before { + opacity: var(--md-sys-state-hover-state-layer-opacity); + } + } + + &:focus-visible { + outline: 3px solid var(--md-sys-color-secondary); + outline-offset: 2px; + } + + &:focus-visible::before { + opacity: var(--md-sys-state-focus-state-layer-opacity); + } + + &:active::before { + opacity: var(--md-sys-state-pressed-state-layer-opacity); + } + + &[aria-expanded='true'] { + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-fab-menu-color); + color: var(--md-fab-menu-on-color); + } + } + + [data-md-fab-menu-trigger][data-md-color='secondary'] { + --md-fab-menu-container: var(--md-sys-color-secondary-container); + --md-fab-menu-on-container: var(--md-sys-color-on-secondary-container); + --md-fab-menu-color: var(--md-sys-color-secondary); + --md-fab-menu-on-color: var(--md-sys-color-on-secondary); + } + + [data-md-fab-menu-trigger][data-md-color='tertiary'] { + --md-fab-menu-container: var(--md-sys-color-tertiary-container); + --md-fab-menu-on-container: var(--md-sys-color-on-tertiary-container); + --md-fab-menu-color: var(--md-sys-color-tertiary); + --md-fab-menu-on-color: var(--md-sys-color-on-tertiary); + } + + [data-md-fab-menu-close-icon] { + display: none; + } + + [data-md-fab-menu-trigger][aria-expanded='true'] [data-md-fab-menu-icon] { + display: none; + } + + [data-md-fab-menu-trigger][aria-expanded='true'] [data-md-fab-menu-close-icon] { + display: inline-flex; + } + + [data-md-fab-menu-popover] { + inset: auto; + margin: 0; + display: none; + flex-direction: column; + gap: var(--md-sys-measurement-space50); + max-block-size: calc(100dvh - 128px); + overflow-y: auto; + border-width: 0; + background-color: transparent; + padding: var(--md-sys-measurement-space100); + transition-property: display, overlay; + transition-duration: var(--md-sys-motion-spatial-fast-duration); + transition-behavior: allow-discrete; + + &:popover-open { + display: flex; + } + } + + [data-md-fab-menu-popover][data-md-position='top-end'] { + margin-inline: calc(-1 * var(--md-sys-measurement-space100)); + margin-block-start: calc(-1 * var(--md-sys-measurement-space100)); + align-items: flex-end; + position-area: top span-left; + } + + [data-md-fab-menu-popover][data-md-position='top-start'] { + margin-inline: calc(-1 * var(--md-sys-measurement-space100)); + margin-block-start: calc(-1 * var(--md-sys-measurement-space100)); + align-items: flex-start; + position-area: top span-right; + } + + [data-md-fab-menu-popover][data-md-position='bottom-end'] { + margin-inline: calc(-1 * var(--md-sys-measurement-space100)); + margin-block-end: calc(-1 * var(--md-sys-measurement-space100)); + align-items: flex-end; + position-area: bottom span-left; + } + + [data-md-fab-menu-popover][data-md-position='bottom-start'] { + margin-inline: calc(-1 * var(--md-sys-measurement-space100)); + margin-block-end: calc(-1 * var(--md-sys-measurement-space100)); + align-items: flex-start; + position-area: bottom span-right; + } + + [data-md-fab-menu-popover]:not(:popover-open) > * { + translate: 0 var(--md-sys-measurement-space100); + opacity: 0; + } +} diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css index eef6db2f..298f5408 100644 --- a/resources/css/tailwind.css +++ b/resources/css/tailwind.css @@ -16,8 +16,6 @@ @import './tokens/theme.css'; @import './tokens/utilities.css'; -/* Actions and communication — leaving in step 36 */ -@import './components/actions.css'; /* Inputs, selection and data — leaving in step 36 */ @import './components/datepicker.css'; diff --git a/resources/js/fab.js b/resources/js/fab.js index b4578d45..bec2c7ba 100644 --- a/resources/js/fab.js +++ b/resources/js/fab.js @@ -2,8 +2,8 @@ * `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 + * 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 diff --git a/resources/views/components/fab-menu.blade.php b/resources/views/components/fab-menu.blade.php index 69798c96..7ab3e603 100644 --- a/resources/views/components/fab-menu.blade.php +++ b/resources/views/components/fab-menu.blade.php @@ -1,6 +1,6 @@ {{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions. -
+
@@ -10,18 +10,24 @@ Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a round close button in the colour itself while the list is open above it, end-aligned; the list is a `popover="auto"` menu with the menu keyboard of ``. `label` names the FAB - and the menu for screen readers, and defaults to "Toggle menu" — the icons inside the button - are aria-hidden, so without it the trigger has no name at all. Give the items the same - `color`. Like ``'s, the list is keyed for Livewire, so it stays open through a render - of the component around it. + and the menu for screen readers, and defaults to "Toggle menu" (ACT-21) — the icons inside + the button are aria-hidden, so without it the trigger has no name at all. Give the items the + same `color`. Like ``'s, the list is keyed for Livewire, so it stays open through a + render of the component around it. - It does not place itself. M3's margin from the edge of the window is 16dp, and 24dp from the - `large` class up: `fixed end-4 bottom-4 large:end-6 large:bottom-6`. A window too short for - the list scrolls it; the items rise into place as it opens and sink back as it closes. + It does not place itself. M3's margin from the edge of the window is 16px, 24px from `large` + (1200px) up — grow it in the caller's own stylesheet: - FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px - above the close button. The FAB's corner and colours morph on the two default springs at once - (`state-transition-default`, resources/css/components/actions.css). --}} + @media (width >= 1200px) { .my-fab-menu { inset-inline-end: 24px; bottom: 24px } } + + A window too short for the list scrolls it, behind the close button, which stays fixed + (ACT-32); the items rise into place as it opens and sink back as it closes (ACT-33). + + FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px close button, full + corner when open, 20px close icon, 8px to the first item, 56px items 4px apart. + + Drawn by resources/css/components/fab-menu.css from `data-md-fab-menu`, `data-md-color` and + `data-md-position`. --}} @props([ 'icon' => 'add', @@ -36,33 +42,20 @@ // fallback wording for the close button. $label = filled($label) ? $label : __('Toggle menu'); $color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'; + $position = in_array($position, ['top-start', 'top-end', 'bottom-start', 'bottom-end'], true) ? $position : 'top-end'; $key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)); $anchor = "--material-fab-menu-{$key}"; - - $colours = [ - 'primary' => 'bg-primary-container text-on-primary-container aria-expanded:bg-primary aria-expanded:text-on-primary', - 'secondary' => 'bg-secondary-container text-on-secondary-container aria-expanded:bg-secondary aria-expanded:text-on-secondary', - 'tertiary' => 'bg-tertiary-container text-on-tertiary-container aria-expanded:bg-tertiary aria-expanded:text-on-tertiary', - ][$color]; @endphp -
class('relative inline-flex') }}> - + - @@ -72,24 +65,13 @@ id="material-fab-menu-{{ $key }}" popover="auto" role="menu" - data-fab-menu + data-md-fab-menu-popover + data-md-position="{{ $position }}" aria-label="{{ $label }}" tabindex="-1" style="position-anchor: {{ $anchor }}" x-on:keydown="navigate($event)" x-on:click="activate($event)" - @class([ - 'm-0 flex-col gap-1 border-0 bg-transparent p-2 open:flex [inset:auto]', - // A landscape phone has no room for six 56px items: the list scrolls, the FAB below it - // does not. The 8px padding is the gap M3 leaves above the FAB and the room the items' - // elevation needs inside the scroll box; the negative margins put it back where it was. - 'max-h-[calc(100dvh-8rem)] overflow-y-auto', - 'transition-[display,overlay] transition-discrete duration-(--md-sys-motion-spatial-fast-duration)', - '-mx-2 -mt-2 items-end [position-area:top_span-left]' => $position === 'top-end', - '-mx-2 -mt-2 items-start [position-area:top_span-right]' => $position === 'top-start', - '-mx-2 -mb-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end', - '-mx-2 -mb-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start', - ]) > {{ $slot }}
diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index efc45b3f..00ecbd50 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -24,6 +24,7 @@ dataset('action components', [ 'menu-item', 'menu', 'fab-menu-item', + 'fab-menu', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { diff --git a/tests/Feature/Components/FabMenuTest.php b/tests/Feature/Components/FabMenuTest.php index f0e875e6..4c10af04 100644 --- a/tests/Feature/Components/FabMenuTest.php +++ b/tests/Feature/Components/FabMenuTest.php @@ -11,21 +11,25 @@ it('opens a FAB menu of end-aligned actions above it', function () { expect($html) ->toContain('x-data="materialMenu"') + ->toContain('data-md-fab-menu') ->toContain('aria-label="New"') - ->toContain('bg-secondary-container text-on-secondary-container aria-expanded:bg-secondary aria-expanded:text-on-secondary') - ->toContain('aria-expanded:rounded-corner-full') - ->toContain('role="menu"') - ->toContain('[position-area:top_span-left]') - ->toContain('data-md-fab-menu-item') + ->toContain('data-md-fab-menu-trigger') ->toContain('data-md-color="secondary"') + ->toContain('role="menu"') + ->toContain('data-md-fab-menu-popover') + ->toContain('data-md-position="top-end"') + ->toContain('data-md-fab-menu-item') ->toContain('role="menuitem"') ->toContain('wire:click="upload"') ->toContain('Upload files') - ->toContain('max-h-[calc(100dvh-8rem)] overflow-y-auto') - ->toContain('data-fab-menu') - ->and(ComponentStylesheet::read('fab-menu-item')->declarations('[data-md-fab-menu-item]'))->toMatchArray([ - 'block-size' => 'var(--md-sys-measurement-space700)', + ->and(ComponentStylesheet::read('fab-menu')->declarations('[data-md-fab-menu-popover]'))->toMatchArray([ + 'max-block-size' => 'calc(100dvh - 128px)', + 'overflow-y' => 'auto', + ]) + ->and(ComponentStylesheet::read('fab-menu')->declarations("[data-md-fab-menu-trigger][aria-expanded='true']"))->toBe([ 'border-radius' => 'var(--md-sys-shape-corner-full)', + 'background-color' => 'var(--md-fab-menu-color)', + 'color' => 'var(--md-fab-menu-on-color)', ]); }); @@ -35,6 +39,39 @@ it('names the FAB menu even when the caller forgets to', function () { ->and(substr_count((string) $this->blade(''), 'aria-label="Toggle menu"'))->toBe(2); }); +it('swaps its open icon for a close icon while the menu is open', function () { + $html = (string) $this->blade(''); + $css = ComponentStylesheet::read('fab-menu'); + + expect($html) + ->toContain('data-md-fab-menu-icon') + ->toContain('data-md-fab-menu-close-icon') + ->toContain('--md-icon-size: 20px') + ->and($css->declarations('[data-md-fab-menu-close-icon]'))->toBe(['display' => 'none']) + ->and($css->declarations("[data-md-fab-menu-trigger][aria-expanded='true'] [data-md-fab-menu-icon]"))->toBe(['display' => 'none']) + ->and($css->declarations("[data-md-fab-menu-trigger][aria-expanded='true'] [data-md-fab-menu-close-icon]"))->toBe(['display' => 'inline-flex']); +}); + +it('opens at the corner asked for, secondary and tertiary in their own colours', function () { + $css = ComponentStylesheet::read('fab-menu'); + + expect((string) $this->blade(''))->toContain('data-md-position="bottom-start"') + ->and($css->declarations("[data-md-fab-menu-popover][data-md-position='bottom-start']"))->toMatchArray(['position-area' => 'bottom span-right']) + ->and($css->declarations("[data-md-fab-menu-trigger][data-md-color='tertiary']"))->toBe([ + '--md-fab-menu-container' => 'var(--md-sys-color-tertiary-container)', + '--md-fab-menu-on-container' => 'var(--md-sys-color-on-tertiary-container)', + '--md-fab-menu-color' => 'var(--md-sys-color-tertiary)', + '--md-fab-menu-on-color' => 'var(--md-sys-color-on-tertiary)', + ]); +}); + +it('scrolls the list on a short window, behind the close button, which stays fixed', function () { + expect(ComponentStylesheet::read('fab-menu')->declarations('[data-md-fab-menu-popover]:not(:popover-open) > *'))->toBe([ + 'translate' => '0 var(--md-sys-measurement-space100)', + 'opacity' => '0', + ]); +}); + it('gives an item its icon, colour and the exit fab-menu.css drives from the popover', function () { $html = (string) $this->blade(''); $css = ComponentStylesheet::read('fab-menu-item');