diff --git a/resources/css/components.css b/resources/css/components.css index c62aad2b..fe155089 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -19,6 +19,7 @@ @import './components/group.css'; @import './components/button-group.css'; @import './components/split-button.css'; +@import './components/fab.css'; /* Inputs, selection and data */ diff --git a/resources/css/components/actions.css b/resources/css/components/actions.css index 0f96f924..c85ae555 100644 --- a/resources/css/components/actions.css +++ b/resources/css/components/actions.css @@ -84,65 +84,3 @@ 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/css/components/fab.css b/resources/css/components/fab.css new file mode 100644 index 00000000..da57d3f4 --- /dev/null +++ b/resources/css/components/fab.css @@ -0,0 +1,207 @@ +/* + * : M3 Expressive's floating action button and extended FAB. + * + * FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens (androidx Compose Material 3, Apache-2.0), + * per `data-md-size`: + * + * size FAB corner glyph extended: min width gap padding type + * sm 56px lg (16px) 24px 80px 8px 16px title-md + * md 80px lg-increased 28px 80px 16px 26px title-lg + * lg 96px xl (28px) 32px 96px 20px 28px headline-sm + * + * A FAB and its extended form share the height and the corner. `data-md-color` is `primary`, + * `secondary` or `tertiary`, drawn in its container (`data-md-variant="container"`) or in the + * colour itself (`filled`), at elevation 3, 4 under a hovering pointer, the shadow and colours on + * the fast effects spring. The state layer and focus ring are the declarations of the foundation's + * `md-state-layer` and `md-focus-ring`. `data-md-fab` on the root is also the hook a place uses to + * draw a nested FAB its own way: a navigation rail or a docked toolbar flattens it to elevation 0. + * + * `data-md-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 — when switching between FAB↔extended FAB, shape changes, the + * icon moves left, and the text label fades in/out" (resources/js/fab.js sets + * `data-md-collapsed`). `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, + * while the label fades on the effects spring. The collapsed width is the FAB's own size. The + * extended padding already centres the glyph at `sm` (16 + 24 + 16) 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. The + * label stays in the accessibility tree — clipped and transparent, never `display: none` — so the + * collapsed FAB keeps the name the extended one had. Under reduced motion tokens/motion.css takes + * every duration to zero, so it swaps. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; +@import './tooltip.css'; + +@layer material.components { + [data-md-fab] { + --md-fab-color: var(--md-sys-color-primary); + --md-fab-on-color: var(--md-sys-color-on-primary); + --md-fab-container: var(--md-sys-color-primary-container); + --md-fab-on-container: var(--md-sys-color-on-primary-container); + + position: relative; + isolation: isolate; + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + block-size: var(--md-fab-size); + border-radius: var(--md-fab-corner); + background-color: var(--md-fab-container); + color: var(--md-fab-on-container); + box-shadow: var(--md-sys-elevation-3); + white-space: nowrap; + cursor: pointer; + -webkit-user-select: none; + user-select: none; + outline: none; + transition-property: box-shadow, background-color, color; + transition-duration: var(--md-sys-motion-effects-fast-duration); + transition-timing-function: var(--md-sys-motion-effects-fast); + + &::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); + } + } + + [data-md-fab][data-md-size='sm'] { + --md-fab-size: 56px; + --md-fab-corner: var(--md-sys-shape-corner-lg); + --md-fab-extended-min: 80px; + } + + [data-md-fab][data-md-size='md'] { + --md-fab-size: 80px; + --md-fab-corner: var(--md-sys-shape-corner-lg-increased); + --md-fab-extended-min: 80px; + } + + [data-md-fab][data-md-size='lg'] { + --md-fab-size: 96px; + --md-fab-corner: var(--md-sys-shape-corner-xl); + --md-fab-extended-min: 96px; + } + + [data-md-fab][data-md-color='secondary'] { + --md-fab-color: var(--md-sys-color-secondary); + --md-fab-on-color: var(--md-sys-color-on-secondary); + --md-fab-container: var(--md-sys-color-secondary-container); + --md-fab-on-container: var(--md-sys-color-on-secondary-container); + } + + [data-md-fab][data-md-color='tertiary'] { + --md-fab-color: var(--md-sys-color-tertiary); + --md-fab-on-color: var(--md-sys-color-on-tertiary); + --md-fab-container: var(--md-sys-color-tertiary-container); + --md-fab-on-container: var(--md-sys-color-on-tertiary-container); + } + + [data-md-fab][data-md-variant='filled'] { + background-color: var(--md-fab-color); + color: var(--md-fab-on-color); + } + + [data-md-fab]:not([data-md-extended]) { + inline-size: var(--md-fab-size); + } + + [data-md-fab][data-md-extended] { + min-inline-size: var(--md-fab-extended-min); + } + + [data-md-fab][data-md-extended][data-md-size='sm'] { + gap: var(--md-sys-measurement-space100); + padding-inline: var(--md-sys-measurement-space200); + font: var(--md-sys-typescale-title-md); + letter-spacing: var(--md-sys-typescale-title-md-tracking); + font-variation-settings: normal; + } + + [data-md-fab][data-md-extended][data-md-size='md'] { + gap: var(--md-sys-measurement-space200); + padding-inline: 26px; + font: var(--md-sys-typescale-title-lg); + letter-spacing: var(--md-sys-typescale-title-lg-tracking); + font-variation-settings: normal; + } + + [data-md-fab][data-md-extended][data-md-size='lg'] { + gap: 20px; + padding-inline: 28px; + font: var(--md-sys-typescale-headline-sm); + letter-spacing: var(--md-sys-typescale-headline-sm-tracking); + font-variation-settings: normal; + } + + /* The extended FAB that collapses to a FAB while the page scrolls down. */ + [data-md-fab][data-md-collapse-on-scroll] { + 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-md-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); + + & > span { + overflow: hidden; + white-space: nowrap; + } + } + + [data-md-fab][data-md-collapse-on-scroll][data-md-collapsed] { + min-inline-size: var(--md-fab-size); + gap: 0; + + & [data-md-fab-label] { + grid-template-columns: 0fr; + opacity: 0; + } + } + + [data-md-fab][data-md-collapse-on-scroll][data-md-size='lg'][data-md-collapsed] { + padding-inline: var(--md-sys-measurement-space400); + } +} diff --git a/resources/css/components/navigation.css b/resources/css/components/navigation.css index 99059700..6a85e9da 100644 --- a/resources/css/components/navigation.css +++ b/resources/css/components/navigation.css @@ -806,8 +806,8 @@ * within another component, such as the navigation rail, the FAB's resting elevation should be * level 0"). Unlayered on purpose: the FAB draws its shadow with a utility, and a rule in any * layer loses to a utility — the same reason toolbar.css gives for its vibrant recolouring. */ -[data-navigation-rail-header] [data-fab], -[data-navigation-rail-header] [data-fab]:hover { +[data-navigation-rail-header] [data-md-fab], +[data-navigation-rail-header] [data-md-fab]:hover { box-shadow: none; } @@ -821,7 +821,7 @@ * same reason as the rule above: `gap-2` and `min-w-20` are utilities, and a layer would lose to * them. The cap is there because a transition needs a length to travel to; a rail FAB's label is a * word or two. */ -[data-navigation-rail-header] [data-fab] > span { +[data-navigation-rail-header] [data-md-fab] > span { max-width: 16rem; overflow: hidden; white-space: nowrap; @@ -835,7 +835,7 @@ } } -[data-navigation-rail-header] [data-fab] { +[data-navigation-rail-header] [data-md-fab] { @variant rail-collapsed { min-width: 0; aspect-ratio: 1; diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css index 48af9a08..57e8d9ba 100644 --- a/resources/css/components/toolbar.css +++ b/resources/css/components/toolbar.css @@ -89,8 +89,8 @@ margin-inline-start: auto; } -[data-toolbar-fab] [data-fab], -[data-toolbar-fab] [data-fab]:hover { +[data-toolbar-fab] [data-md-fab], +[data-toolbar-fab] [data-md-fab]:hover { box-shadow: none; } diff --git a/resources/views/components/fab.blade.php b/resources/views/components/fab.blade.php index c9aefaea..c635cfa1 100644 --- a/resources/views/components/fab.blade.php +++ b/resources/views/components/fab.blade.php @@ -6,8 +6,8 @@ drawn in its container, or in the colour itself with `variant="filled"`. It does not place itself: put it where the layout wants it, e.g. - `
`. For a - create action that is a FAB on a phone and a header button above, use ``. + `
`. + For a create action that is a FAB on a phone and a header button above, use ``. The glyph is filled, not outlined: M3 says so twice, in the FAB's anatomy and again in its guidelines ("icon should be filled (not outlined) and unambiguous"). @@ -17,19 +17,19 @@ than greying it out. A form-submit FAB that must not be pressed twice takes the caller's own `wire:loading.attr="disabled"`. - `data-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation + `data-md-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. + closes and fades while the width follows it on the spatial spring; 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). --}} + (androidx Compose Material 3, Apache-2.0), drawn by resources/css/components/fab.css. --}} @props([ 'icon' => null, @@ -47,53 +47,42 @@ @php $size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm'; $color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'; + $variant = $variant === 'filled' ? 'filled' : 'container'; $extended = filled($label) || $slot->isNotEmpty(); $collapsing = $collapseOnScroll && $extended && filled($icon); $isLink = filled($link); - $colours = $variant === 'filled' - ? ['primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary'][$color] - : ['primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container'][$color]; - - $dimensions = $extended - ? ['sm' => 'h-14 min-w-20 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-5 px-7 rounded-corner-xl type-headline-sm'][$size] - : ['sm' => 'size-14 rounded-corner-lg', 'md' => 'size-20 rounded-corner-lg-increased', 'lg' => 'size-24 rounded-corner-xl'][$size]; - - $iconSize = ['sm' => 'size-6', 'md' => 'size-7', 'lg' => 'size-8'][$size]; + $iconSize = ['sm' => 24, 'md' => 28, 'lg' => 32][$size]; $anchor = $tooltip !== null ? '--material-fab-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null; $tag = $isLink ? 'a' : 'button'; - $attributes = $attributes - ->class([ - 'state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap', - 'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast', - $dimensions, - $colours, - ]) - ->merge(array_filter([ - 'href' => $isLink ? $link : null, - 'target' => $isLink && $external ? '_blank' : null, - 'rel' => $isLink && $external ? 'noopener' : null, - 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, - '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)); + $attributes = $attributes->merge(array_filter([ + // The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp. + 'data-md-fab' => true, + 'data-md-size' => $size, + 'data-md-color' => $color, + 'data-md-variant' => $variant, + 'data-md-extended' => $extended ? true : null, + 'data-md-collapse-on-scroll' => $collapsing ? true : null, + 'href' => $isLink ? $link : null, + 'target' => $isLink && $external ? '_blank' : null, + 'rel' => $isLink && $external ? 'noopener' : null, + 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, + 'type' => $isLink ? null : $type, + 'x-data' => $collapsing ? 'materialFab' : null, + 'x-bind:data-md-collapsed' => $collapsing ? "collapsed ? '' : null" : null, + 'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null, + 'style' => $anchor ? "anchor-name: {$anchor}" : null, + ], fn ($value): bool => $value !== null)); @endphp <{{ $tag }} {{ $attributes }}> @if ($icon) - + @endif @if ($collapsing) - {{ $label ?? $slot }} + {{ $label ?? $slot }} @elseif ($extended) {{ $label ?? $slot }} @endif diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index bfc6ef6c..29a1b928 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -465,6 +465,27 @@ it('closes a FAB menu when an item\'s action runs, and opens and closes it clean ->assertScript(focused("getAttribute('aria-label') === 'New'")); }); +it('collapses an extended FAB to a FAB while the page scrolls down, and extends it on the way back', function () { + $fab = "document.querySelector('#buttons [data-md-collapse-on-scroll]')"; + + // Reduced motion takes the spring's duration to zero, so the width is final at once. + $page = visit('/material/buttons', ['reducedMotion' => 'reduce']) + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'") + ->assertScript("! {$fab}.hasAttribute('data-md-collapsed') && {$fab}.getBoundingClientRect().width > 56"); + + $page->script('window.scrollTo(0, 600)'); + + $page->assertScript("{$fab}.hasAttribute('data-md-collapsed')") + ->assertScript("Math.round({$fab}.getBoundingClientRect().width) === 56") + // Clipped, not removed: the collapsed FAB keeps the extended one's name. + ->assertScript("{$fab}.textContent.trim() === 'Compose'"); + + $page->script('window.scrollTo(0, 200)'); + + $page->assertScript("! {$fab}.hasAttribute('data-md-collapsed')") + ->assertScript("{$fab}.getBoundingClientRect().width > 56"); +}); + it('takes a press on a small connected segment at its 48px edge, past what it draws', function () { // M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width". showcase()->assertScript(<<<'JS' diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index 4496c538..484d727d 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -18,6 +18,7 @@ dataset('action components', [ 'group', 'button-group', 'split-button', + 'fab', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { diff --git a/tests/Feature/Components/AppBarTest.php b/tests/Feature/Components/AppBarTest.php index 00f89dfa..5a92962d 100644 --- a/tests/Feature/Components/AppBarTest.php +++ b/tests/Feature/Components/AppBarTest.php @@ -168,7 +168,7 @@ it('sets a FAB at the end of a docked toolbar, resting flat on it', function () expect(file_get_contents(__DIR__.'/../../../resources/css/components/toolbar.css')) ->toMatch('/\[data-toolbar-fab\] \{\s+display: flex;\s+margin-inline-start: auto;/') // A nested FAB rests at elevation 0. - ->toMatch('/\[data-toolbar-fab\] \[data-fab\],\s+\[data-toolbar-fab\] \[data-fab\]:hover \{\s+box-shadow: none;/'); + ->toMatch('/\[data-toolbar-fab\] \[data-md-fab\],\s+\[data-toolbar-fab\] \[data-md-fab\]:hover \{\s+box-shadow: none;/'); }); it('centres a headline on a three-column row and curves the search container', function () { diff --git a/tests/Feature/Components/FabTest.php b/tests/Feature/Components/FabTest.php index 50a3e82c..bba76dcd 100644 --- a/tests/Feature/Components/FabTest.php +++ b/tests/Feature/Components/FabTest.php @@ -1,20 +1,54 @@ + */ +function fabAttributes(string $blade): array +{ + preg_match('/<(?:button|a)\b([^>]*)>/', (string) test()->blade($blade), $tag); + preg_match_all('/([\w:.-]+)(?:="([^"]*)")?/', $tag[1] ?? '', $pairs, PREG_SET_ORDER); + + return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2] ?? ''])->all(); +} + it('draws a FAB in its container colour, named by its tooltip', function () { - expect((string) $this->blade('')) - ->toContain('size-14 rounded-corner-lg') - ->toContain('bg-primary-container text-on-primary-container') - ->toContain('shadow-elevation-3') - ->toContain('aria-label="New share"') - ->toContain('popover="manual"'); + $css = ComponentStylesheet::read('fab'); + + expect(fabAttributes(''))->toMatchArray([ + 'data-md-fab' => 'data-md-fab', + 'data-md-size' => 'sm', + 'data-md-color' => 'primary', + 'data-md-variant' => 'container', + 'type' => 'button', + 'aria-label' => 'New share', + ]) + ->not->toHaveKey('data-md-extended') + ->and((string) $this->blade(''))->toContain('popover="manual"') + ->and($css->declarations('[data-md-fab]'))->toMatchArray([ + 'background-color' => 'var(--md-fab-container)', + 'color' => 'var(--md-fab-on-container)', + 'box-shadow' => 'var(--md-sys-elevation-3)', + 'block-size' => 'var(--md-fab-size)', + 'border-radius' => 'var(--md-fab-corner)', + ]) + ->and($css->declarations('[data-md-fab]:hover', ['@media (hover: hover)']))->toBe(['box-shadow' => 'var(--md-sys-elevation-4)']) + ->and($css->declarations('[data-md-fab]:not([data-md-extended])'))->toBe(['inline-size' => 'var(--md-fab-size)']); }); -it('sizes a FAB at 56, 80 and 96px', function (string $size, string $classes, string $icon) { - expect((string) $this->blade(""))->toContain($classes)->toContain($icon); +it('sizes a FAB at 56, 80 and 96px', function (string $size, string $px, string $corner, int $icon) { + expect((string) $this->blade(""))->toContain("data-md-size=\"{$size}\"")->toContain("--md-icon-size: {$icon}px") + ->and(ComponentStylesheet::read('fab')->declarations("[data-md-fab][data-md-size='{$size}']"))->toMatchArray([ + '--md-fab-size' => $px, + '--md-fab-corner' => "var(--md-sys-shape-corner-{$corner})", + ]); })->with([ - 'sm' => ['sm', 'size-14 rounded-corner-lg', 'size-6'], - 'md' => ['md', 'size-20 rounded-corner-lg-increased', 'size-7'], - 'lg' => ['lg', 'size-24 rounded-corner-xl', 'size-8'], + 'sm' => ['sm', '56px', 'lg', 24], + 'md' => ['md', '80px', 'lg-increased', 28], + 'lg' => ['lg', '96px', 'xl', 32], ]); it('fills the FAB\'s glyph, as M3 asks twice over', function () { @@ -25,32 +59,53 @@ it('fills the FAB\'s glyph, as M3 asks twice over', function () { ->and((string) $this->blade(''))->toContain($filled); }); -it('extends with a label', function () { - expect((string) $this->blade('')) - ->toContain('h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg') - ->toContain('bg-tertiary text-on-tertiary') - ->toContain('Upload') - ->not->toContain('aria-label'); +it('extends with a label, in the colour itself when filled', function () { + expect(fabAttributes('')) + ->toMatchArray(['data-md-size' => 'md', 'data-md-color' => 'tertiary', 'data-md-variant' => 'filled', 'data-md-extended' => 'data-md-extended']) + ->not->toHaveKey('aria-label') + ->and((string) $this->blade(''))->toContain('Upload') + ->and(ComponentStylesheet::read('fab')->declarations("[data-md-fab][data-md-variant='filled']"))->toBe([ + 'background-color' => 'var(--md-fab-color)', + 'color' => 'var(--md-fab-on-color)', + ]) + ->and(ComponentStylesheet::read('fab')->declarations("[data-md-fab][data-md-color='tertiary']"))->toMatchArray([ + '--md-fab-color' => 'var(--md-sys-color-tertiary)', + '--md-fab-container' => 'var(--md-sys-color-tertiary-container)', + ]); }); -it('gives the extended FAB M3\'s gaps and its 80px minimum width', function (string $size, string $classes) { - expect((string) $this->blade(""))->toContain($classes); +it('gives the extended FAB M3\'s gaps and its minimum width', function (string $size, string $gap, string $padding, string $type, string $minimum) { + $css = ComponentStylesheet::read('fab'); + + expect($css->declarations("[data-md-fab][data-md-extended][data-md-size='{$size}']"))->toMatchArray([ + 'gap' => $gap, + 'padding-inline' => $padding, + 'font' => "var(--md-sys-typescale-{$type})", + ]) + ->and($css->declarations("[data-md-fab][data-md-size='{$size}']"))->toMatchArray(['--md-fab-extended-min' => $minimum]) + ->and($css->declarations('[data-md-fab][data-md-extended]'))->toBe(['min-inline-size' => 'var(--md-fab-extended-min)']); })->with([ - 'sm' => ['sm', 'h-14 min-w-20 gap-2'], - 'md' => ['md', 'h-20 min-w-20 gap-4'], - 'lg' => ['lg', 'h-24 min-w-24 gap-5'], + 'sm' => ['sm', 'var(--md-sys-measurement-space100)', 'var(--md-sys-measurement-space200)', 'title-md', '80px'], + 'md' => ['md', 'var(--md-sys-measurement-space200)', '26px', 'title-lg', '80px'], + 'lg' => ['lg', '20px', '28px', 'headline-sm', '96px'], ]); it('collapses an extended FAB to a FAB on scroll when asked', function () { $html = (string) $this->blade(''); + $css = ComponentStylesheet::read('fab'); expect($html) ->toContain('x-data="materialFab"') - ->toContain('x-bind:data-collapsed="collapsed ? '' : null"') - ->toContain('data-fab-collapsible') - ->toContain('data-fab-size="lg"') + ->toContain('x-bind:data-md-collapsed="collapsed ? '' : null"') + ->toContain('data-md-collapse-on-scroll') + ->toContain('data-md-size="lg"') // The label is clipped, never removed, so the collapsed FAB keeps its name. - ->toContain('New share'); + ->toContain('New share') + ->and($css->declarations('[data-md-fab][data-md-collapse-on-scroll][data-md-collapsed]'))->toBe(['min-inline-size' => 'var(--md-fab-size)', 'gap' => '0']) + ->and($css->declarations('[data-md-fab][data-md-collapse-on-scroll][data-md-collapsed] [data-md-fab-label]'))->toBe(['grid-template-columns' => '0fr', 'opacity' => '0']) + ->and($css->declarations("[data-md-fab][data-md-collapse-on-scroll][data-md-size='lg'][data-md-collapsed]"))->toBe(['padding-inline' => 'var(--md-sys-measurement-space400)']) + // Width and gap on the spatial spring, colour and shadow on the effects spring. + ->and($css->declarations('[data-md-fab][data-md-collapse-on-scroll]')['transition-property'])->toBe('min-inline-size, padding-inline, gap, box-shadow, background-color, color'); // 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') @@ -59,15 +114,14 @@ it('collapses an extended FAB to a FAB on scroll when asked', function () { }); it('marks its root for the places that draw a nested FAB their own way', function () { - expect((string) $this->blade(''))->toContain('data-fab'); + expect((string) $this->blade(''))->toContain('data-md-fab')->not->toContain('class='); }); it('cannot be disabled, because M3 says to remove a FAB instead', function () { - $html = (string) $this->blade(''); - - // `disabled` is no longer a prop, so it falls through as a plain attribute rather than - // painting M3's disabled treatment. - expect($html)->not->toContain('disabled:bg-on-surface/10')->not->toContain('disabled:shadow-none'); + // `disabled` is not a prop, so it falls through as a plain attribute, and the stylesheet + // draws no disabled treatment for it. + expect((string) $this->blade(''))->toContain('disabled="disabled"') + ->and(ComponentStylesheet::read('fab')->css)->not->toContain(':disabled'); }); it('opens a FAB menu of end-aligned actions above it', function () { diff --git a/tests/Feature/Components/NavigationRailTest.php b/tests/Feature/Components/NavigationRailTest.php index 7fa83ae3..1ce5c00b 100644 --- a/tests/Feature/Components/NavigationRailTest.php +++ b/tests/Feature/Components/NavigationRailTest.php @@ -132,14 +132,14 @@ it('hides a collapsible or adaptive rail entirely when told to, and only those', }); it('flattens a FAB nested in the rail header and morphs its label', function () { - // Both rules hang off `data-fab` on 's root, and both are unlayered, because what they + // Both rules hang off `data-md-fab` on 's root, and both are unlayered, because what they // beat — the FAB's shadow, its gap, an extended FAB's minimum width — are utilities. expect(file_get_contents(__DIR__.'/../../../resources/css/components/navigation.css')) // A nested FAB rests at elevation 0, not the 3 a standalone one has (N-03). - ->toContain('[data-navigation-rail-header] [data-fab],') - ->toContain('[data-navigation-rail-header] [data-fab]:hover {') + ->toContain('[data-navigation-rail-header] [data-md-fab],') + ->toContain('[data-navigation-rail-header] [data-md-fab]:hover {') // One FAB whose label springs shut, not two swapped by display (N-23). - ->toContain('[data-navigation-rail-header] [data-fab] > span {') + ->toContain('[data-navigation-rail-header] [data-md-fab] > span {') ->toMatch('/@variant rail-collapsed \{\s+min-width: 0;\s+aspect-ratio: 1;\s+gap: 0;/'); });