Draw the FAB menu without Tailwind

<x-fab-menu> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 16:20:45 +02:00
co-authored by Claude Opus 5
parent 330b835043
commit c065d940af
8 changed files with 260 additions and 101 deletions
-45
View File
@@ -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): `<x-fab-menu>`'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;
}
+185
View File
@@ -0,0 +1,185 @@
/*
* <x-fab-menu>: 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;
}
}