From 330b835043695eb93ae2327fd7095f954e774b7a Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 16:18:18 +0200 Subject: [PATCH] Draw the FAB menu item without Tailwind renders data-md-fab-menu-item and data-md-color with no class list; fab-menu-item.css draws FabMenuBaselineTokens' 56px pill, 24px sides, 24px icon, full corner and elevation 3, and the entry/exit rise-and-sink transition (ACT-33) that fab-menu.css's [data-md-fab-menu]:not(:popover-open) rule will drive once the FAB menu itself is rewritten. Its tests move out of FabTest.php into a new FabMenuTest.php (plan step 36, actions). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/fab-menu-item.css | 100 ++++++++++++++++++ .../views/components/fab-menu-item.blade.php | 47 ++++---- .../Components/ActionStylesheetsTest.php | 1 + tests/Feature/Components/FabMenuTest.php | 69 ++++++++++++ tests/Feature/Components/FabTest.php | 28 +---- 6 files changed, 194 insertions(+), 52 deletions(-) create mode 100644 resources/css/components/fab-menu-item.css create mode 100644 tests/Feature/Components/FabMenuTest.php diff --git a/resources/css/components.css b/resources/css/components.css index 9d34eb4a..0e9330b0 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -24,6 +24,7 @@ @import './components/menu-group.css'; @import './components/menu-item.css'; @import './components/menu.css'; +@import './components/fab-menu-item.css'; /* Inputs, selection and data */ @import './components/form.css'; diff --git a/resources/css/components/fab-menu-item.css b/resources/css/components/fab-menu-item.css new file mode 100644 index 00000000..5be3028d --- /dev/null +++ b/resources/css/components/fab-menu-item.css @@ -0,0 +1,100 @@ +/* + * : one action in an ``, a pill that rises into place as the menu + * opens and sinks back as it closes. + * + * FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px tall, 24px either side, + * 8px between the icon and the label, 24px icons, full corner, elevation 3, `data-md-color`'s + * container. + * + * 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]:not(:popover-open) > *` + * (fab-menu.css) drives from the popover's own open state — the popover keeps `display: flex` for + * the length of its own discrete transition, so the item has somewhere to animate to (ACT-33). + */ + +@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-item] { + --md-fab-menu-item-container: var(--md-sys-color-primary-container); + --md-fab-menu-item-on-container: var(--md-sys-color-on-primary-container); + + position: relative; + isolation: isolate; + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: var(--md-sys-measurement-space100); + block-size: var(--md-sys-measurement-space700); + padding-inline: var(--md-sys-measurement-space300); + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-fab-menu-item-container); + color: var(--md-fab-menu-item-on-container); + box-shadow: var(--md-sys-elevation-3); + white-space: nowrap; + font: var(--md-sys-typescale-title-md); + letter-spacing: var(--md-sys-typescale-title-md-tracking); + font-variation-settings: normal; + cursor: pointer; + outline: none; + -webkit-user-select: none; + user-select: none; + translate: 0 0; + opacity: 1; + transition-property: translate, opacity, display, overlay; + transition-duration: + var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration), + var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration); + transition-timing-function: + var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default), linear, linear; + transition-behavior: allow-discrete; + + &::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::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); + } + + @starting-style { + translate: 0 var(--md-sys-measurement-space100); + opacity: 0; + } + } + + [data-md-fab-menu-item][data-md-color='secondary'] { + --md-fab-menu-item-container: var(--md-sys-color-secondary-container); + --md-fab-menu-item-on-container: var(--md-sys-color-on-secondary-container); + } + + [data-md-fab-menu-item][data-md-color='tertiary'] { + --md-fab-menu-item-container: var(--md-sys-color-tertiary-container); + --md-fab-menu-item-on-container: var(--md-sys-color-on-tertiary-container); + } +} diff --git a/resources/views/components/fab-menu-item.blade.php b/resources/views/components/fab-menu-item.blade.php index c013bae0..9bfbb06a 100644 --- a/resources/views/components/fab-menu-item.blade.php +++ b/resources/views/components/fab-menu-item.blade.php @@ -1,6 +1,13 @@ {{-- One action in an ``: a 56px pill with an icon and a label, rising into place as - the menu opens. `link` makes it an anchor (with `wire:navigate` unless `external`); `color` - should match the menu's. --}} + the menu opens and sinking back as it closes (fab-menu.css drives the exit from the popover's + own open state). `link` makes it an anchor (with `wire:navigate` unless `external`); `color` + should match the menu's. + + FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px tall, 24px either side, + 8px between the icon and the label, 24px icons, full corner, elevation 3. + + Drawn by resources/css/components/fab-menu-item.css from `data-md-fab-menu-item` and + `data-md-color`. --}} @props([ 'label' => null, @@ -13,34 +20,24 @@ @php $isLink = filled($link); $tag = $isLink ? 'a' : 'button'; + $color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'; - $colours = [ - 'primary' => 'bg-primary-container text-on-primary-container', - 'secondary' => 'bg-secondary-container text-on-secondary-container', - 'tertiary' => 'bg-tertiary-container text-on-tertiary-container', - ][in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary']; - - $attributes = $attributes - ->class([ - 'state-layer inline-flex h-14 shrink-0 cursor-pointer items-center gap-2 rounded-corner-full px-6 whitespace-nowrap type-title-md shadow-elevation-3 outline-none', - 'focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary', - 'transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-2 starting:opacity-0', - $colours, - ]) - ->merge(array_filter([ - 'role' => 'menuitem', - 'tabindex' => '-1', - 'type' => $isLink ? null : 'button', - 'href' => $isLink ? $link : null, - 'target' => $isLink && $external ? '_blank' : null, - 'rel' => $isLink && $external ? 'noopener' : null, - 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, - ], fn ($value): bool => $value !== null)); + $attributes = $attributes->merge(array_filter([ + 'data-md-fab-menu-item' => true, + 'data-md-color' => $color, + 'role' => 'menuitem', + 'tabindex' => '-1', + 'type' => $isLink ? null : 'button', + 'href' => $isLink ? $link : null, + 'target' => $isLink && $external ? '_blank' : null, + 'rel' => $isLink && $external ? 'noopener' : null, + 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, + ], fn ($value): bool => $value !== null)); @endphp <{{ $tag }} {{ $attributes }}> @if ($icon) - + @endif {{ $label ?? $slot }} diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index f1b3ba25..efc45b3f 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -23,6 +23,7 @@ dataset('action components', [ 'menu-group', 'menu-item', 'menu', + 'fab-menu-item', ]); 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 new file mode 100644 index 00000000..f0e875e6 --- /dev/null +++ b/tests/Feature/Components/FabMenuTest.php @@ -0,0 +1,69 @@ +blade(<<<'BLADE' + + + + BLADE); + + expect($html) + ->toContain('x-data="materialMenu"') + ->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-color="secondary"') + ->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)', + 'border-radius' => 'var(--md-sys-shape-corner-full)', + ]); +}); + +it('names the FAB menu even when the caller forgets to', function () { + expect((string) $this->blade('')) + ->toContain('aria-label="Toggle menu"') + ->and(substr_count((string) $this->blade(''), 'aria-label="Toggle menu"'))->toBe(2); +}); + +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'); + + expect($html) + ->toContain('data-md-fab-menu-item') + ->toContain('data-md-color="tertiary"') + ->toContain('role="menuitem"') + ->toContain('tabindex="-1"') + ->toContain('type="button"') + ->toContain('--md-icon-size: 24px') + ->toContain('Upload a folder') + ->and($css->declarations("[data-md-fab-menu-item][data-md-color='tertiary']"))->toBe([ + '--md-fab-menu-item-container' => 'var(--md-sys-color-tertiary-container)', + '--md-fab-menu-item-on-container' => 'var(--md-sys-color-on-tertiary-container)', + ]) + ->and($css->declarations('[data-md-fab-menu-item]', ['@starting-style']))->toBe([ + 'translate' => '0 var(--md-sys-measurement-space100)', + 'opacity' => '0', + ]); +}); + +it('links a FAB menu item, with wire:navigate unless external', function () { + expect((string) $this->blade('')) + ->toContain('href="/open"') + ->toContain('wire:navigate') + ->not->toContain('target="_blank"') + ->and((string) $this->blade('')) + ->toContain('target="_blank"') + ->toContain('rel="noopener"') + ->not->toContain('wire:navigate'); +}); diff --git a/tests/Feature/Components/FabTest.php b/tests/Feature/Components/FabTest.php index bba76dcd..7cd5c4cf 100644 --- a/tests/Feature/Components/FabTest.php +++ b/tests/Feature/Components/FabTest.php @@ -124,30 +124,4 @@ it('cannot be disabled, because M3 says to remove a FAB instead', function () { ->and(ComponentStylesheet::read('fab')->css)->not->toContain(':disabled'); }); -it('opens a FAB menu of end-aligned actions above it', function () { - $html = (string) $this->blade(<<<'BLADE' - - - - BLADE); - - expect($html) - ->toContain('x-data="materialMenu"') - ->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('role="menuitem"') - ->toContain('wire:click="upload"') - ->toContain('h-14') - ->toContain('Upload files') - ->toContain('max-h-[calc(100dvh-8rem)] overflow-y-auto') - ->toContain('data-fab-menu'); -}); - -it('names the FAB menu even when the caller forgets to', function () { - expect((string) $this->blade('')) - ->toContain('aria-label="Toggle menu"') - ->and(substr_count((string) $this->blade(''), 'aria-label="Toggle menu"'))->toBe(2); -}); +// The FAB menu itself and its items: tests/Feature/Components/FabMenuTest.php.