Merge branch 'worktree-agent-a9b4c522a39c6ce62'

# Conflicts:
#	tests/Feature/StylesheetsTest.php
#	workbench/resources/css/app.css
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 15:41:37 +02:00
37 changed files with 2791 additions and 881 deletions
-62
View File
@@ -84,65 +84,3 @@
translate: 0 0.5rem;
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;
}
+143
View File
@@ -0,0 +1,143 @@
/*
* <x-badge>: M3's small and large badges, and the library's status label.
*
* BadgeTokens (androidx Compose Material 3, Apache-2.0): the small badge is a 6px dot
* (`data-md-dot`), the large badge 16px tall and at least 16px wide with 4px either side of a
* `label-small` count in tabular figures, both fully round and `error` by default. A floating
* badge sits on M3's anchor geometry at the top-end corner of its `position: relative` parent: the
* dot flush in the corner, the count 2px above it with its start 12px in from the parent's end.
*
* The status label (`data-md-variant`) is not an M3 badge: 24px tall, small corner, 8px either
* side, 4px between an icon and its word, `label-medium`. `tonal` draws it in the colour's
* container, `solid` in the colour itself, `outline` as a 1px `outline` edge around
* `on-surface-variant` text — `outline` rather than `outline-variant`, because M3 asks a badge for
* 3:1 against what is behind it and `outline-variant` is the decorative role dividers use.
*
* `data-md-color` picks the pair of roles; `neutral` has no hue of its own (`on-surface-variant`
* and `surface`, or `surface-container-high` behind `on-surface-variant` text), and `plain` sets
* no colour at all, so the caller's CSS paints it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-badge] {
--md-badge-color: var(--md-sys-color-error);
--md-badge-on-color: var(--md-sys-color-on-error);
--md-badge-container: var(--md-sys-color-error-container);
--md-badge-on-container: var(--md-sys-color-on-error-container);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
white-space: nowrap;
}
[data-md-badge][data-md-dot] {
inline-size: 6px;
block-size: 6px;
border-radius: var(--md-sys-shape-corner-full);
}
[data-md-badge]:not([data-md-dot], [data-md-variant]) {
min-inline-size: 16px;
block-size: 16px;
border-radius: var(--md-sys-shape-corner-full);
padding-inline: var(--md-sys-measurement-space50);
font: var(--md-sys-typescale-label-sm);
letter-spacing: var(--md-sys-typescale-label-sm-tracking);
font-variation-settings: normal;
font-variant-numeric: tabular-nums;
}
[data-md-badge][data-md-variant] {
gap: var(--md-sys-measurement-space50);
block-size: 24px;
border-radius: var(--md-sys-shape-corner-sm);
padding-inline: var(--md-sys-measurement-space100);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
font-variation-settings: normal;
}
[data-md-badge][data-md-variant='outline'] {
border: 1px solid;
}
[data-md-badge][data-md-color='primary'] {
--md-badge-color: var(--md-sys-color-primary);
--md-badge-on-color: var(--md-sys-color-on-primary);
--md-badge-container: var(--md-sys-color-primary-container);
--md-badge-on-container: var(--md-sys-color-on-primary-container);
}
[data-md-badge][data-md-color='secondary'] {
--md-badge-color: var(--md-sys-color-secondary);
--md-badge-on-color: var(--md-sys-color-on-secondary);
--md-badge-container: var(--md-sys-color-secondary-container);
--md-badge-on-container: var(--md-sys-color-on-secondary-container);
}
[data-md-badge][data-md-color='tertiary'] {
--md-badge-color: var(--md-sys-color-tertiary);
--md-badge-on-color: var(--md-sys-color-on-tertiary);
--md-badge-container: var(--md-sys-color-tertiary-container);
--md-badge-on-container: var(--md-sys-color-on-tertiary-container);
}
[data-md-badge][data-md-color='success'] {
--md-badge-color: var(--md-sys-color-success);
--md-badge-on-color: var(--md-sys-color-on-success);
--md-badge-container: var(--md-sys-color-success-container);
--md-badge-on-container: var(--md-sys-color-on-success-container);
}
[data-md-badge][data-md-color='warning'] {
--md-badge-color: var(--md-sys-color-warning);
--md-badge-on-color: var(--md-sys-color-on-warning);
--md-badge-container: var(--md-sys-color-warning-container);
--md-badge-on-container: var(--md-sys-color-on-warning-container);
}
[data-md-badge][data-md-color='info'] {
--md-badge-color: var(--md-sys-color-info);
--md-badge-on-color: var(--md-sys-color-on-info);
--md-badge-container: var(--md-sys-color-info-container);
--md-badge-on-container: var(--md-sys-color-on-info-container);
}
[data-md-badge][data-md-color='neutral'] {
--md-badge-color: var(--md-sys-color-on-surface-variant);
--md-badge-on-color: var(--md-sys-color-surface);
--md-badge-container: var(--md-sys-color-surface-container-high);
--md-badge-on-container: var(--md-sys-color-on-surface-variant);
}
[data-md-badge]:not([data-md-color='plain']) {
background-color: var(--md-badge-color);
color: var(--md-badge-on-color);
&[data-md-variant='tonal'] {
background-color: var(--md-badge-container);
color: var(--md-badge-on-container);
}
&[data-md-variant='outline'] {
border-color: var(--md-sys-color-outline);
background-color: transparent;
color: var(--md-sys-color-on-surface-variant);
}
}
[data-md-badge][data-md-floating] {
position: absolute;
inset-block-start: calc(-1 * var(--md-sys-measurement-space25));
inset-inline-start: calc(100% - 12px);
}
[data-md-badge][data-md-floating][data-md-dot] {
inset-block-start: 0;
inset-inline: auto 0;
}
}
+169
View File
@@ -0,0 +1,169 @@
/*
* <x-button-group>: related buttons in a row, and the shapes a button takes from its place among
* its siblings. The connected form is also what `<x-group>` draws its segments with, which is why
* its stylesheet imports this one. A group never wraps: M3's rule, and the press expansion only
* reaches a neighbour on the same line anyway.
*
* Standard (`data-md-button-group="standard"`): the buttons stand apart — 18/12/8/8/8px by size
* (ButtonGroupXSmall…XLargeTokens.BetweenSpace) — and pressing a label button widens it and
* narrows its neighbours by the same amount, so the row keeps its length: Expressive's press
* expansion (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size, 4/6/8/16/
* 20px, on the button's own padding of 16/16/24/48/64px). Icon buttons keep their width. A square
* standard group has no inner edges, so it hands its buttons the square corner scale
* `<x-button shape="square">` draws (12/12/16/28/28px, 8/8/12/16/16px under the finger), and a
* selected button in it turns round.
*
* Connected (`data-md-button-group="connected"`): 2px apart, the outer corners full, the inner
* corners small, smaller still while pressed, and a selected segment (`aria-pressed`, or a checked
* input inside it) fully round — ConnectedButtonGroupXSmall…XLargeTokens and the M3 Expressive
* connected button group spec: inner corners 4/8/8/16/20px, pressed 2/4/4/12/16px. At the two
* smallest sizes a segment is 32px or 40px tall, and M3 names both numbers it must not go below:
* "XS and S connected button groups have a 48dp target area and a 48dp minimum width".
*
* Shape (`data-md-shape="square"`, M3's "Default shape | Round, square" configuration):
* `--md-button-group-outer` is what the two ends of a group round to — half the height for a round
* group, and for a square one the corner its inner edges take, which is the square table M3
* publishes for connected groups (4/8/8/16/20dp by size). Selection morphs the other way: M3 has a
* toggle inside a group "swap shape square/round on selection", so a selected segment in a square
* group turns round, which is why the selected rule keeps `--md-button-group-full`.
*
* "Full" is half the size's height (16/20/28/48/68px), never `--md-sys-shape-corner-full`'s
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii add
* up to more than its side, CSS scales every radius by the same factor: a 9999px corner beside an
* 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew square.
*
* <x-button>'s own corner rules carry no specificity, so these reshape the buttons in a group
* whatever selector they use; a pressed or selected segment changes one custom property and the
* outer corners stay put.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-button-group] {
--md-button-group-inner: var(--md-sys-shape-corner-sm);
--md-button-group-inner-pressed: var(--md-sys-shape-corner-xs);
--md-button-group-full: 20px;
--md-button-group-square: var(--md-sys-shape-corner-md);
--md-button-group-square-pressed: var(--md-sys-shape-corner-sm);
--md-button-group-outer: var(--md-button-group-full);
--md-button-group-pad: var(--md-sys-measurement-space200);
--md-button-group-grow: 6px;
display: inline-flex;
align-items: center;
}
[data-md-button-group][data-md-size='xs'] {
--md-button-group-inner: var(--md-sys-shape-corner-xs);
--md-button-group-inner-pressed: 2px;
--md-button-group-full: 16px;
--md-button-group-grow: 4px;
}
[data-md-button-group][data-md-size='md'] {
--md-button-group-full: 28px;
--md-button-group-square: var(--md-sys-shape-corner-lg);
--md-button-group-square-pressed: var(--md-sys-shape-corner-md);
--md-button-group-pad: var(--md-sys-measurement-space300);
--md-button-group-grow: 8px;
}
[data-md-button-group][data-md-size='lg'] {
--md-button-group-inner: var(--md-sys-shape-corner-lg);
--md-button-group-inner-pressed: var(--md-sys-shape-corner-md);
--md-button-group-full: 48px;
--md-button-group-square: var(--md-sys-shape-corner-xl);
--md-button-group-square-pressed: var(--md-sys-shape-corner-lg);
--md-button-group-pad: var(--md-sys-measurement-space600);
--md-button-group-grow: 16px;
}
[data-md-button-group][data-md-size='xl'] {
--md-button-group-inner: var(--md-sys-shape-corner-lg-increased);
--md-button-group-inner-pressed: var(--md-sys-shape-corner-lg);
--md-button-group-full: 68px;
--md-button-group-square: var(--md-sys-shape-corner-xl);
--md-button-group-square-pressed: var(--md-sys-shape-corner-lg);
--md-button-group-pad: var(--md-sys-measurement-space800);
--md-button-group-grow: 20px;
}
[data-md-button-group='standard'] {
gap: var(--md-sys-measurement-space100);
}
[data-md-button-group='standard'][data-md-size='xs'] {
gap: 18px;
}
[data-md-button-group='standard'][data-md-size='sm'] {
gap: 12px;
}
/* A square standard group hands its buttons the square corner scale; a selected one rounds. */
[data-md-button-group='standard'][data-md-shape='square'] > * {
border-radius: var(--md-button-group-square);
}
[data-md-button-group='standard'][data-md-shape='square'] > :active:not(:disabled, [aria-disabled='true']) {
border-radius: var(--md-button-group-square-pressed);
}
[data-md-button-group='standard'][data-md-shape='square'] > [aria-pressed='true'] {
border-radius: var(--md-button-group-full);
}
/* Press expansion: the pressed label button grows, the label buttons beside it give way. */
[data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) {
padding-inline: calc(var(--md-button-group-pad) + var(--md-button-group-grow));
}
[data-md-button-group='standard'] > :not([data-md-icon-button]):has(+ :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true'])) {
padding-inline-end: calc(var(--md-button-group-pad) - var(--md-button-group-grow));
}
[data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-md-icon-button]) {
padding-inline-start: calc(var(--md-button-group-pad) - var(--md-button-group-grow));
}
/* A square group's ends take the corner its inner edges take. */
[data-md-button-group][data-md-shape='square'] {
--md-button-group-outer: var(--md-button-group-inner);
}
[data-md-button-group='connected'] {
gap: var(--md-sys-measurement-space25);
}
[data-md-button-group='connected']:is([data-md-size='xs'], [data-md-size='sm']) > * {
min-inline-size: var(--md-sys-measurement-space600);
}
[data-md-button-group='connected'] > * {
--md-button-group-corner: var(--md-button-group-inner);
border-start-start-radius: var(--md-button-group-corner);
border-end-start-radius: var(--md-button-group-corner);
border-start-end-radius: var(--md-button-group-corner);
border-end-end-radius: var(--md-button-group-corner);
}
[data-md-button-group='connected'] > :active {
--md-button-group-corner: var(--md-button-group-inner-pressed);
}
[data-md-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)) {
--md-button-group-corner: var(--md-button-group-full);
}
[data-md-button-group='connected'] > :first-child {
border-start-start-radius: var(--md-button-group-outer);
border-end-start-radius: var(--md-button-group-outer);
}
[data-md-button-group='connected'] > :last-child {
border-start-end-radius: var(--md-button-group-outer);
border-end-end-radius: var(--md-button-group-outer);
}
}
+452
View File
@@ -0,0 +1,452 @@
/*
* <x-button>: M3 Expressive's buttons — label button, icon button and toggle — and the page's
* create action that is a FAB on a compact window.
*
* Geometry from androidx Compose Material 3's tokens (Apache-2.0), per `data-md-size`:
*
* size height label padding gap type icon (label / icon-only) outline square pressed
* xs 32px 16px 8px label-lg 20 / 20px 1px md sm
* sm 40px 16px 8px label-lg 20 / 24px 1px md sm
* md 56px 24px 8px title-md 24 / 24px 1px lg md
* lg 96px 48px 12px headline-sm 32 / 32px 2px xl lg
* xl 136px 64px 16px headline-lg 40 / 40px 3px xl lg
*
* (ButtonXSmall…XLargeTokens; the xs padding is the 16px Phase D settled on, ACT-09.) An icon button
* (`data-md-icon-button`) is as tall and `narrow`, `default` or `wide` across by
* XSmall…XLargeIconButtonTokens: 28/32/40, 32/40/52, 48/56/72, 64/96/128 and 104/136/184px. The
* views size the glyphs through `<x-icon size>`; `--md-button-icon` carries the same size for the
* loading indicator that stands in for the glyph while the button's action runs.
*
* Shape: round (`--md-sys-shape-corner-full`) by default; `data-md-shape="square"` and a selected
* button take the size's square corner, a selected square icon button turns round, and every one
* squares off to the pressed corner under the finger. Those corner rules are wrapped in `:where()`,
* so they carry no specificity at all: a group's stylesheet (button-group.css, split-button.css)
* reshapes the buttons inside it with any selector, without a specificity fight.
*
* Colour: `data-md-color` picks the roles a variant draws with; the variants set
* `--md-button-container`, `--md-button-label`, `--md-button-outline` and `--md-button-elevation`,
* which one rule paints, so the disabled treatment (on-surface at 10% behind 38% text,
* FilledButtonTokens.DisabledContainerOpacity and DisabledLabelTextOpacity) replaces them with a
* plain declaration. Primary's tonal button is secondary-container and its standard icon button
* on-surface-variant, as M3 draws them; the text button's label is primary, as Compose draws it,
* not the token's on-surface-variant, which its own source marks as wrong; a selected text button
* takes the tonal container (the library's deliberate deviations, docs/audits/m3-alignment).
*
* Motion: corners, padding, margins and `bottom` on the fast spatial spring, colour and shadow on
* the fast effects spring beside it — a colour must never overshoot (tokens/motion.css).
*
* The state layer, focus ring and 48px target (xs and sm) are the declarations of the foundation's
* `md-state-layer`, `md-focus-ring` and `md-touch-target` (foundation/interaction.css), keyed on
* the button itself.
*
* `data-md-compact-fab`: below `medium` (600px) the button is an extended FAB in the thumb zone —
* fixed 16px from the end, 56px tall with 16px padding, the large corner, `title-md`,
* primary-container at elevation 3 (ExtendedFabPrimaryTokens) — lifted clear of a bottom bar and
* of a snackbar on screen (`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a
* snackbar above a FAB and never over one. From `medium` it is the button it was written as.
* `data-md-responsive` on the label hides it below `expanded` (840px).
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './loading.css';
@import './tooltip.css';
@layer material.components {
[data-md-button] {
--md-button-color: var(--md-sys-color-primary);
--md-button-on-color: var(--md-sys-color-on-primary);
--md-button-tone: var(--md-sys-color-secondary-container);
--md-button-on-tone: var(--md-sys-color-on-secondary-container);
--md-button-tone-selected: var(--md-sys-color-secondary);
--md-button-on-tone-selected: var(--md-sys-color-on-secondary);
--md-button-quiet: var(--md-sys-color-on-surface-variant);
--md-button-container: transparent;
--md-button-label: var(--md-button-color);
--md-button-outline: transparent;
--md-button-elevation: none;
position: relative;
isolation: isolate;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
block-size: var(--md-button-height);
background-color: var(--md-button-container);
color: var(--md-button-label);
box-shadow: var(--md-button-elevation);
white-space: nowrap;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
outline: none;
transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-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-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), 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::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);
}
}
/* Sizes. */
[data-md-button][data-md-size='xs'] {
--md-button-height: 32px;
--md-button-padding: var(--md-sys-measurement-space200);
--md-button-gap: var(--md-sys-measurement-space100);
--md-button-icon: 20px;
--md-button-outline-width: 1px;
--md-button-square: var(--md-sys-shape-corner-md);
--md-button-pressed: var(--md-sys-shape-corner-sm);
--md-button-narrow: 28px;
--md-button-default: 32px;
--md-button-wide: 40px;
}
[data-md-button][data-md-size='sm'] {
--md-button-height: 40px;
--md-button-padding: var(--md-sys-measurement-space200);
--md-button-gap: var(--md-sys-measurement-space100);
--md-button-icon: 20px;
--md-button-outline-width: 1px;
--md-button-square: var(--md-sys-shape-corner-md);
--md-button-pressed: var(--md-sys-shape-corner-sm);
--md-button-narrow: 32px;
--md-button-default: 40px;
--md-button-wide: 52px;
}
[data-md-button][data-md-size='sm'][data-md-icon-button] {
--md-button-icon: 24px;
}
[data-md-button][data-md-size='md'] {
--md-button-height: 56px;
--md-button-padding: var(--md-sys-measurement-space300);
--md-button-gap: var(--md-sys-measurement-space100);
--md-button-icon: 24px;
--md-button-outline-width: 1px;
--md-button-square: var(--md-sys-shape-corner-lg);
--md-button-pressed: var(--md-sys-shape-corner-md);
--md-button-narrow: 48px;
--md-button-default: 56px;
--md-button-wide: 72px;
}
[data-md-button][data-md-size='lg'] {
--md-button-height: 96px;
--md-button-padding: var(--md-sys-measurement-space600);
--md-button-gap: 12px;
--md-button-icon: 32px;
--md-button-outline-width: 2px;
--md-button-square: var(--md-sys-shape-corner-xl);
--md-button-pressed: var(--md-sys-shape-corner-lg);
--md-button-narrow: 64px;
--md-button-default: 96px;
--md-button-wide: 128px;
}
[data-md-button][data-md-size='xl'] {
--md-button-height: 136px;
--md-button-padding: var(--md-sys-measurement-space800);
--md-button-gap: var(--md-sys-measurement-space200);
--md-button-icon: 40px;
--md-button-outline-width: 3px;
--md-button-square: var(--md-sys-shape-corner-xl);
--md-button-pressed: var(--md-sys-shape-corner-lg);
--md-button-narrow: 104px;
--md-button-default: 136px;
--md-button-wide: 184px;
}
[data-md-button]:not([data-md-icon-button]) {
gap: var(--md-button-gap);
padding-inline: var(--md-button-padding);
}
[data-md-button]:not([data-md-icon-button]):is([data-md-size='xs'], [data-md-size='sm']) {
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-button]:not([data-md-icon-button])[data-md-size='md'] {
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
}
[data-md-button]:not([data-md-icon-button])[data-md-size='lg'] {
font: var(--md-sys-typescale-headline-sm);
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
font-variation-settings: normal;
}
[data-md-button]:not([data-md-icon-button])[data-md-size='xl'] {
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
font-variation-settings: normal;
}
[data-md-button][data-md-icon-button] {
--md-button-width: var(--md-button-default);
inline-size: var(--md-button-width);
&[data-md-width='narrow'] {
--md-button-width: var(--md-button-narrow);
}
&[data-md-width='wide'] {
--md-button-width: var(--md-button-wide);
}
}
/* M3's minimum target, past a button drawn smaller than 48px. */
[data-md-button]:is([data-md-size='xs'], [data-md-size='sm'])::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
min-width: var(--md-sys-measurement-space600);
min-height: var(--md-sys-measurement-space600);
transform: translate(-50%, -50%);
}
/* Shape, without specificity: a group reshapes the buttons in it. */
:where([data-md-button]) {
border-radius: var(--md-sys-shape-corner-full);
}
:where([data-md-button]):where([data-md-shape='square'], [data-md-selected='true']) {
border-radius: var(--md-button-square);
}
:where([data-md-button]):where([data-md-icon-button][data-md-shape='square'][data-md-selected='true']) {
border-radius: var(--md-sys-shape-corner-full);
}
:where([data-md-button]):where(:active) {
border-radius: var(--md-button-pressed);
}
/* Colour roles. */
[data-md-button][data-md-color='secondary'] {
--md-button-color: var(--md-sys-color-secondary);
--md-button-on-color: var(--md-sys-color-on-secondary);
--md-button-tone: var(--md-sys-color-secondary-container);
--md-button-on-tone: var(--md-sys-color-on-secondary-container);
}
[data-md-button][data-md-color='tertiary'] {
--md-button-color: var(--md-sys-color-tertiary);
--md-button-on-color: var(--md-sys-color-on-tertiary);
--md-button-tone: var(--md-sys-color-tertiary-container);
--md-button-on-tone: var(--md-sys-color-on-tertiary-container);
}
[data-md-button][data-md-color='error'] {
--md-button-color: var(--md-sys-color-error);
--md-button-on-color: var(--md-sys-color-on-error);
--md-button-tone: var(--md-sys-color-error-container);
--md-button-on-tone: var(--md-sys-color-on-error-container);
}
[data-md-button][data-md-color='success'] {
--md-button-color: var(--md-sys-color-success);
--md-button-on-color: var(--md-sys-color-on-success);
--md-button-tone: var(--md-sys-color-success-container);
--md-button-on-tone: var(--md-sys-color-on-success-container);
}
[data-md-button][data-md-color='warning'] {
--md-button-color: var(--md-sys-color-warning);
--md-button-on-color: var(--md-sys-color-on-warning);
--md-button-tone: var(--md-sys-color-warning-container);
--md-button-on-tone: var(--md-sys-color-on-warning-container);
}
[data-md-button][data-md-color='info'] {
--md-button-color: var(--md-sys-color-info);
--md-button-on-color: var(--md-sys-color-on-info);
--md-button-tone: var(--md-sys-color-info-container);
--md-button-on-tone: var(--md-sys-color-on-info-container);
}
/* Every colour but primary selects into the colour itself and is quiet in it. */
[data-md-button]:not([data-md-color='primary']) {
--md-button-tone-selected: var(--md-button-color);
--md-button-on-tone-selected: var(--md-button-on-color);
--md-button-quiet: var(--md-button-color);
}
/* Variants. */
[data-md-button][data-md-variant='filled'] {
--md-button-container: var(--md-button-color);
--md-button-label: var(--md-button-on-color);
&[data-md-selected='false'] {
--md-button-container: var(--md-sys-color-surface-container);
--md-button-label: var(--md-sys-color-on-surface-variant);
}
}
[data-md-button][data-md-variant='tonal'] {
--md-button-container: var(--md-button-tone);
--md-button-label: var(--md-button-on-tone);
&[data-md-selected='true'] {
--md-button-container: var(--md-button-tone-selected);
--md-button-label: var(--md-button-on-tone-selected);
}
}
[data-md-button][data-md-variant='outlined'] {
--md-button-label: var(--md-button-quiet);
--md-button-outline: var(--md-sys-color-outline-variant);
border: var(--md-button-outline-width) solid var(--md-button-outline);
&[data-md-selected='true'] {
--md-button-container: var(--md-sys-color-inverse-surface);
--md-button-label: var(--md-sys-color-inverse-on-surface);
--md-button-outline: transparent;
}
}
[data-md-button][data-md-variant='elevated'] {
--md-button-container: var(--md-sys-color-surface-container-low);
--md-button-elevation: var(--md-sys-elevation-1);
&[data-md-selected='true'] {
--md-button-container: var(--md-button-color);
--md-button-label: var(--md-button-on-color);
}
}
[data-md-button][data-md-variant='text'][data-md-icon-button]:not([data-md-selected='true']) {
--md-button-label: var(--md-button-quiet);
}
[data-md-button][data-md-variant='text']:not([data-md-icon-button])[data-md-selected='true'] {
--md-button-container: var(--md-button-tone);
--md-button-label: var(--md-button-on-tone);
}
@media (hover: hover) {
[data-md-button]:is([data-md-variant='filled'], [data-md-variant='tonal']):hover {
--md-button-elevation: var(--md-sys-elevation-1);
}
[data-md-button][data-md-variant='elevated']:hover {
--md-button-elevation: var(--md-sys-elevation-2);
}
}
/* Disabled: no state layer, no shadow, M3's disabled colours over whatever the variant set. */
[data-md-button]:is(:disabled, [aria-disabled='true']) {
box-shadow: none;
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
&::before {
display: none;
}
}
[data-md-button]:is([data-md-variant='filled'], [data-md-variant='tonal'], [data-md-variant='elevated'], [data-md-variant='outlined'][data-md-selected='true']):is(:disabled, [aria-disabled='true']) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
[data-md-button]:disabled {
cursor: not-allowed;
}
[data-md-button][aria-disabled='true'] {
pointer-events: none;
}
/* The glyph's place, and the loading indicator that stands in for it. */
[data-md-button-icon] {
display: contents;
}
[data-md-button-spinner] {
align-items: center;
justify-content: center;
}
[data-md-button-spinner] > [data-md-loading] {
inline-size: var(--md-button-icon);
block-size: var(--md-button-icon);
color: currentColor;
}
@media (width < 840px) {
[data-md-button] > [data-md-responsive] {
display: none;
}
}
@media (width < 600px) {
[data-md-button][data-md-compact-fab] {
--md-button-height: 56px;
--md-button-padding: var(--md-sys-measurement-space200);
--md-button-gap: var(--md-sys-measurement-space100);
position: fixed;
inset-inline-end: var(--md-sys-measurement-space200);
bottom: calc(var(--material-bottom-bar, 0px) + var(--material-snackbar-height, 0px) + var(--md-sys-measurement-space200));
z-index: 30;
border-radius: var(--md-sys-shape-corner-lg);
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
box-shadow: var(--md-sys-elevation-3);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
}
[data-md-button][data-md-compact-fab] [data-md-icon] {
inline-size: 24px;
block-size: 24px;
}
}
}
+207
View File
@@ -0,0 +1,207 @@
/*
* <x-fab>: 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);
}
}
+241
View File
@@ -0,0 +1,241 @@
/*
* <x-group>: a choice of a few options drawn as M3 Expressive's connected button group, the
* successor of the segmented button — native radios (checkboxes with `data-md-multiple`) under
* segments that share the row, or hug their labels with `data-md-inline`.
*
* The segments are M3's buttons at the group's `data-md-size` (Button*Tokens: 32/40/56/96/136px
* tall, 16/16/24/48/64px either side, 8/8/8/12/16px between icon and label, label-large, label-
* large, title-medium, headline-small, headline-large), with the connected shape from
* button-group.css: 2px apart, small inner corners, the chosen segment fully round. At `xs` and
* `sm` a segment carries M3's 48px target and a 48px minimum width, which M3 asks for by name.
*
* `data-md-variant` is the toggle button's colours: `tonal` (secondary-container, chosen in
* secondary), `filled` (surface-container behind on-surface-variant, chosen in primary) or
* `outlined` (an outline-variant edge, chosen in inverse-surface). A checked input picks the
* chosen colours (`:has(:checked)`), a disabled one the disabled treatment — on-surface at 10%
* behind 38% text — and keyboard focus on the input draws M3's focus ring around its segment.
* Corners move on the fast spatial spring and colours on the fast effects spring beside it.
*
* The legend is label-large and the hint body-small, both in on-surface-variant; a validation
* message takes the hint's place in error. `hint-class` lands on the hint, where a caller's class
* outranks the colour here.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './button-group.css';
@layer material.components {
[data-md-group] {
min-inline-size: 0;
}
[data-md-group-legend] {
margin-block-end: var(--md-sys-measurement-space100);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-group] > [data-md-button-group] {
display: flex;
align-items: normal;
inline-size: 100%;
}
[data-md-group][data-md-inline] > [data-md-button-group] {
inline-size: fit-content;
}
[data-md-group-segment] {
--md-group-container: var(--md-sys-color-secondary-container);
--md-group-label: var(--md-sys-color-on-secondary-container);
position: relative;
isolation: isolate;
display: flex;
flex: 1 1 0%;
align-items: center;
justify-content: center;
min-inline-size: 0;
background-color: var(--md-group-container);
color: var(--md-group-label);
white-space: nowrap;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
transition-duration:
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-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-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), 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::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:has(:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
/* The radio or checkbox stays in the page, for the form, the keyboard and a screen reader. */
& > input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border-width: 0;
}
}
[data-md-group][data-md-inline] [data-md-group-segment] {
flex: initial;
}
[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment] {
min-inline-size: var(--md-sys-measurement-space600);
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
min-width: var(--md-sys-measurement-space600);
min-height: var(--md-sys-measurement-space600);
transform: translate(-50%, -50%);
}
}
[data-md-group][data-md-size='xs'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space100);
block-size: 32px;
padding-inline: var(--md-sys-measurement-space200);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='sm'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space100);
block-size: 40px;
padding-inline: var(--md-sys-measurement-space200);
font: var(--md-sys-typescale-label-lg);
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='md'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space100);
block-size: 56px;
padding-inline: var(--md-sys-measurement-space300);
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='lg'] [data-md-group-segment] {
gap: 12px;
block-size: 96px;
padding-inline: var(--md-sys-measurement-space600);
font: var(--md-sys-typescale-headline-sm);
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
font-variation-settings: normal;
}
[data-md-group][data-md-size='xl'] [data-md-group-segment] {
gap: var(--md-sys-measurement-space200);
block-size: 136px;
padding-inline: var(--md-sys-measurement-space800);
font: var(--md-sys-typescale-headline-lg);
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
font-variation-settings: normal;
}
[data-md-group-segment]:has(:checked) {
--md-group-container: var(--md-sys-color-secondary);
--md-group-label: var(--md-sys-color-on-secondary);
}
[data-md-group][data-md-variant='filled'] [data-md-group-segment] {
--md-group-container: var(--md-sys-color-surface-container);
--md-group-label: var(--md-sys-color-on-surface-variant);
&:has(:checked) {
--md-group-container: var(--md-sys-color-primary);
--md-group-label: var(--md-sys-color-on-primary);
}
}
[data-md-group][data-md-variant='outlined'] [data-md-group-segment] {
--md-group-container: transparent;
--md-group-label: var(--md-sys-color-on-surface-variant);
border: 1px solid var(--md-sys-color-outline-variant);
&:has(:checked) {
--md-group-container: var(--md-sys-color-inverse-surface);
--md-group-label: var(--md-sys-color-inverse-on-surface);
border-color: transparent;
}
}
[data-md-group-segment]:has(:disabled) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
cursor: not-allowed;
}
[data-md-group-label] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
[data-md-group-hint],
[data-md-group-error] {
margin-block-start: var(--md-sys-measurement-space50);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
}
[data-md-group-hint] {
color: var(--md-sys-color-on-surface-variant);
}
[data-md-group-error] {
color: var(--md-sys-color-error);
}
}
-164
View File
@@ -1,164 +0,0 @@
/*
* Button groups and split buttons: shapes that depend on a button's place among its siblings.
*
* Unlayered on purpose. Each <x-button> draws its own corners and padding as utilities, and a
* rule in any @layer loses to a utility whatever its specificity; the group has to win.
*
* Standard group (`<x-button-group>`): pressing a label button widens it and narrows its
* neighbours by the same amount, so the row keeps its length — Expressive's press expansion
* (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size). Icon buttons keep
* their width.
*
* Connected group (`<x-button-group connected>`, `<x-group>`): 2px apart, the outer corners
* full, the inner corners small, smaller still while pressed, and a selected segment fully round
* (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec).
*
* Shape (`shape="square"`, M3's "Default shape | Round, square" configuration): `--group-outer`
* is what the two ends of a group round to — half the height for a round group, and for a square
* one the same corner its inner edges take, which is the square table M3 publishes for connected
* groups (4/8/8/16/20dp by size). A square *standard* group has no inner edges, so it hands its
* buttons the square corner scale `<x-button shape="square">` draws (`--group-square`, and
* `--group-square-pressed` under the finger) instead of asking the caller to write it on each
* one. Selection morphs the other way in both: M3 has a toggle inside a group "swap shape
* square/round on selection", so a selected button in a square group turns round, which is why
* the selected rules keep `--group-full` rather than `--group-outer`.
*
* Split button (`<x-split-button>`): the same idea for two halves, on its own corner scale —
* SplitButton*Tokens gives the inner corner 4/4/4/8/12px at rest and *grows* it to 8/12/12/20/20px
* under the finger, the opposite of a connected group, so `--split-inner*` is separate from
* `--group-inner*`. The trailing half turns round and its chevron turns over while its menu is
* open, on M3's standard scheme rather than the expressive springs (the site: "the trailing
* button uses the standard motion scheme, not expressive, when rotating"), and the chevron sits
* a little towards the leading half so the pair reads as one control (`--split-nudge`:
* 1/1/2/3/6px). `--split-icon` is the trailing icon's own size, 22/22/26/38/50px, which the
* icon-button table does not know about.
*
* "Full" here is half the size's height (`--group-full`), never `--md-sys-shape-corner-full`'s
* 9999px. One element mixes full outer corners with small inner ones, and when a box's radii
* add up to more than its side, CSS scales every radius by the same factor: a 9999px corner
* beside an 8px one shrank the 8px one to a hundredth of a pixel, so the inner corners drew
* square.
*/
[data-button-group] {
--group-inner: var(--md-sys-shape-corner-sm);
--group-inner-pressed: var(--md-sys-shape-corner-xs);
--group-full: 1.25rem;
--group-square: var(--md-sys-shape-corner-md);
--group-square-pressed: var(--md-sys-shape-corner-sm);
--group-outer: var(--group-full);
}
[data-button-group][data-size='xs'] { --group-pad: 1rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; --group-full: 1rem; }
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; --group-full: 1.25rem; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; --group-full: 1.75rem; --group-square: var(--md-sys-shape-corner-lg); --group-square-pressed: var(--md-sys-shape-corner-md); }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); --group-full: 3rem; --group-square: var(--md-sys-shape-corner-xl); --group-square-pressed: var(--md-sys-shape-corner-lg); }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); --group-full: 4.25rem; --group-square: var(--md-sys-shape-corner-xl); --group-square-pressed: var(--md-sys-shape-corner-lg); }
/* A square group's ends take the corner its inner edges take — M3's square table for connected
groups — and a square standard group hands its buttons the button's own square scale. */
[data-button-group][data-shape='square'] { --group-outer: var(--group-inner); }
[data-button-group='standard'][data-shape='square'] > * {
border-radius: var(--group-square);
}
[data-button-group='standard'][data-shape='square'] > :active:not(:disabled, [aria-disabled='true']) {
border-radius: var(--group-square-pressed);
}
[data-button-group='standard'][data-shape='square'] > [aria-pressed='true'] {
border-radius: var(--group-full);
}
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
padding-inline: calc(var(--group-pad) + var(--group-grow));
}
[data-button-group='standard'] > :not([data-icon-button]):has(+ :not([data-icon-button]):active:not(:disabled, [aria-disabled='true'])) {
padding-inline-end: calc(var(--group-pad) - var(--group-grow));
}
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-icon-button]) {
padding-inline-start: calc(var(--group-pad) - var(--group-grow));
}
[data-button-group='split'] { --split-inner: var(--md-sys-shape-corner-xs); --split-inner-pressed: var(--md-sys-shape-corner-md); --split-icon: 22px; --split-nudge: 1px; }
[data-button-group='split'][data-size='xs'] { --split-inner-pressed: var(--md-sys-shape-corner-sm); }
[data-button-group='split'][data-size='md'] { --split-icon: 26px; --split-nudge: 2px; }
[data-button-group='split'][data-size='lg'] { --split-inner: var(--md-sys-shape-corner-sm); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 38px; --split-nudge: 3px; }
[data-button-group='split'][data-size='xl'] { --split-inner: var(--md-sys-shape-corner-md); --split-inner-pressed: var(--md-sys-shape-corner-lg-increased); --split-icon: 50px; --split-nudge: 6px; }
/*
* A connected segment at the two smallest sizes is 32px or 40px tall, and an icon-only one is as
* narrow as it is tall. M3 names both numbers and tells you not to reduce them: "XS and S
* connected button groups have a 48dp target area and a 48dp minimum width". The target is the
* `touch-target` utility on the segment itself; the width is here, where a group can reach the
* `<x-button>`s a caller put inside it.
*/
[data-button-group='connected'][data-size='xs'] > *,
[data-button-group='connected'][data-size='sm'] > * {
min-inline-size: 3rem;
}
/*
* Connected segments and split halves take their inner corner from `--group-corner`, so pressing
* or selecting changes one variable and the rounded outer corners stay put.
*/
[data-button-group='connected'] > *,
[data-split] {
border-start-start-radius: var(--group-corner);
border-end-start-radius: var(--group-corner);
border-start-end-radius: var(--group-corner);
border-end-end-radius: var(--group-corner);
}
[data-button-group='connected'] > * {
--group-corner: var(--group-inner);
}
[data-button-group='connected'] > :active {
--group-corner: var(--group-inner-pressed);
}
[data-split] {
--group-corner: var(--split-inner);
}
[data-split]:active {
--group-corner: var(--split-inner-pressed);
}
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
[data-split='trailing'][aria-expanded='true'] {
--group-corner: var(--group-full);
}
[data-button-group='connected'] > :first-child,
[data-split='leading'] {
border-start-start-radius: var(--group-outer);
border-end-start-radius: var(--group-outer);
}
[data-button-group='connected'] > :last-child,
[data-split='trailing'] {
border-start-end-radius: var(--group-outer);
border-end-end-radius: var(--group-outer);
}
/*
* The trailing chevron: its own size, and `--split-nudge` off centre towards the leading half —
* equal and opposite margins move it by exactly that much inside a centred flex row, in whichever
* direction the writing mode runs.
*/
[data-split='trailing'] svg {
width: var(--split-icon);
height: var(--split-icon);
margin-inline-start: calc(-1 * var(--split-nudge));
margin-inline-end: var(--split-nudge);
transition: rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard);
}
[data-split='trailing'][aria-expanded='true'] svg {
rotate: 180deg;
}
+53
View File
@@ -0,0 +1,53 @@
/*
* <x-loading>: M3 Expressive's loading indicator, a shape that morphs through seven Expressive
* shapes while it turns, for a wait with no known length.
*
* The drawing is resources/svg/loading-indicator/, ported from androidx Compose Material 3's
* LoadingIndicator in bin/loading-indicator.mjs (Apache-2.0): LoadingIndicatorTokens' 38px
* indicator in a 48px container, which is the size here unless the caller's CSS sizes it. It is
* drawn in the text colour, `primary` (ActiveIndicatorColor) unless the caller colours it;
* `contained` puts it on a `primary-container` circle in `on-primary-container`
* (ContainedContainerColor, ContainedIndicatorColor), for a spinner over content.
*
* The animated drawing is SMIL, so it runs without script. Under `prefers-reduced-motion` the
* still drawing of the same shape takes its place: the view renders both, and this file shows one.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-loading] {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
inline-size: 48px;
block-size: 48px;
color: var(--md-sys-color-primary);
& > svg {
inline-size: 100%;
block-size: 100%;
}
& > [data-md-loading-still] {
display: none;
}
@media (prefers-reduced-motion: reduce) {
& > [data-md-loading-animated] {
display: none;
}
& > [data-md-loading-still] {
display: block;
}
}
}
[data-md-loading][data-md-contained] {
border-radius: var(--md-sys-shape-corner-full);
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
}
+4 -4
View File
@@ -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;
+115
View File
@@ -0,0 +1,115 @@
/*
* <x-split-button>: an action and a menu of its alternatives, as two `<x-button>`s 2px apart.
*
* The halves share one shape (SplitButtonXSmall…XLargeTokens, androidx Compose Material 3,
* Apache-2.0): outer corners full — half the height, 16/20/28/48/68px, never 9999px, which would
* scale the small inner corners away — and inner corners of 4/4/4/8/12px that *grow* under the
* finger to 8/12/12/20/20px, the opposite of a connected button group. The trailing half turns
* fully round while its menu is open (`aria-expanded`). `<x-button>`'s own corner rules carry no
* specificity, so these win.
*
* At the two smallest sizes the leading half keeps less room on its inner side (10px at `xs`,
* 12px at `sm`) and the trailing half is 48px wide. The trailing chevron has its own size,
* 22/22/26/38/50px, which the icon-button table does not know about, and sits
* `--md-split-button-nudge` (1/1/2/3/6px) towards the leading half so the pair reads as one
* control: equal and opposite inline margins move it by exactly that much inside a centred flex
* row, in whichever direction the writing mode runs. It turns over while the menu is open, on M3's
* standard scheme rather than the expressive springs (the site: "the trailing button uses the
* standard motion scheme, not expressive, when rotating").
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './button.css';
@layer material.components {
[data-md-split-button] {
--md-split-button-inner: var(--md-sys-shape-corner-xs);
--md-split-button-inner-pressed: var(--md-sys-shape-corner-md);
--md-split-button-full: 20px;
--md-split-button-icon: 22px;
--md-split-button-nudge: 1px;
display: inline-flex;
align-items: center;
gap: var(--md-sys-measurement-space25);
}
[data-md-split-button][data-md-size='xs'] {
--md-split-button-inner-pressed: var(--md-sys-shape-corner-sm);
--md-split-button-full: 16px;
}
[data-md-split-button][data-md-size='md'] {
--md-split-button-full: 28px;
--md-split-button-icon: 26px;
--md-split-button-nudge: 2px;
}
[data-md-split-button][data-md-size='lg'] {
--md-split-button-inner: var(--md-sys-shape-corner-sm);
--md-split-button-inner-pressed: var(--md-sys-shape-corner-lg-increased);
--md-split-button-full: 48px;
--md-split-button-icon: 38px;
--md-split-button-nudge: 3px;
}
[data-md-split-button][data-md-size='xl'] {
--md-split-button-inner: var(--md-sys-shape-corner-md);
--md-split-button-inner-pressed: var(--md-sys-shape-corner-lg-increased);
--md-split-button-full: 68px;
--md-split-button-icon: 50px;
--md-split-button-nudge: 6px;
}
[data-md-split-button] [data-md-split] {
--md-split-button-corner: var(--md-split-button-inner);
border-start-start-radius: var(--md-split-button-corner);
border-end-start-radius: var(--md-split-button-corner);
border-start-end-radius: var(--md-split-button-corner);
border-end-end-radius: var(--md-split-button-corner);
}
[data-md-split-button] [data-md-split]:active {
--md-split-button-corner: var(--md-split-button-inner-pressed);
}
[data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] {
--md-split-button-corner: var(--md-split-button-full);
}
[data-md-split-button] [data-md-split='leading'] {
border-start-start-radius: var(--md-split-button-full);
border-end-start-radius: var(--md-split-button-full);
}
[data-md-split-button] [data-md-split='trailing'] {
border-start-end-radius: var(--md-split-button-full);
border-end-end-radius: var(--md-split-button-full);
}
[data-md-split-button][data-md-size='xs'] [data-md-split='leading'] {
padding-inline-end: var(--md-sys-measurement-space125);
}
[data-md-split-button][data-md-size='sm'] [data-md-split='leading'] {
padding-inline-end: 12px;
}
[data-md-split-button]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-split='trailing'] {
inline-size: var(--md-sys-measurement-space600);
}
[data-md-split-button] [data-md-split='trailing'] [data-md-icon] {
inline-size: var(--md-split-button-icon);
block-size: var(--md-split-button-icon);
margin-inline-start: calc(-1 * var(--md-split-button-nudge));
margin-inline-end: var(--md-split-button-nudge);
transition: rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard);
}
[data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] [data-md-icon] {
rotate: 180deg;
}
}
+5 -5
View File
@@ -60,11 +60,11 @@
color: var(--md-sys-color-on-primary-container);
}
[data-toolbar][data-vibrant] [data-icon-button]:not([aria-pressed="true"]) {
[data-toolbar][data-vibrant] [data-md-icon-button]:not([aria-pressed="true"]) {
color: var(--md-sys-color-on-primary-container);
}
[data-toolbar][data-vibrant] [data-icon-button][aria-pressed="true"] {
[data-toolbar][data-vibrant] [data-md-icon-button][aria-pressed="true"] {
background-color: var(--md-sys-color-surface-container);
color: var(--md-sys-color-on-surface);
}
@@ -72,7 +72,7 @@
/* M3's colour list for a standard toolbar ends "Standard button (Primary)", as the vibrant list
ends "Standard button (On primary container)" — the row above. An icon button carries its own
ink as a utility, so the container's `color` never reaches it and this rule has to. */
[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) {
[data-toolbar]:not([data-vibrant]) [data-md-icon-button]:not([aria-pressed="true"]) {
color: var(--md-sys-color-primary);
}
@@ -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;
}
+80
View File
@@ -0,0 +1,80 @@
/*
* <x-tooltip>: M3's plain tooltip, a short label for a control in the inverse surface.
*
* The bubble is a `popover="manual"` in the top layer, so no `overflow: hidden` parent clips it,
* placed by CSS anchor positioning on its `data-md-side` and flipping to the other side where the
* window has no room. PlainTooltipTokens and TooltipDefaults (androidx Compose Material 3,
* Apache-2.0): `inverse-surface` container with the extra-small corner, `body-small` supporting
* text in `inverse-on-surface`, 8px either side and 4px above and below, 200px wide at most, 4px
* from its anchor. It never takes the pointer, so it can stand over the control it names.
*
* It fades in and out on the fast effects spring; `allow-discrete` keeps `display` and `overlay`
* alive until the fade out has been seen, and `@starting-style` gives the fade in a start.
* resources/js/tooltip.js shows and hides it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-tooltip-anchor] {
display: inline-flex;
}
[data-md-tooltip] {
inset: auto;
margin: 0;
max-inline-size: 200px;
overflow: visible;
border-width: 0;
border-radius: var(--md-sys-shape-corner-xs);
padding-block: var(--md-sys-measurement-space50);
padding-inline: var(--md-sys-measurement-space100);
background-color: var(--md-sys-color-inverse-surface);
color: var(--md-sys-color-inverse-on-surface);
font: var(--md-sys-typescale-body-sm);
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
font-variation-settings: normal;
text-align: center;
white-space: normal;
pointer-events: none;
opacity: 0;
transition-property: opacity, display, overlay;
transition-duration: var(--md-sys-motion-effects-fast-duration);
transition-timing-function: var(--md-sys-motion-effects-fast);
transition-behavior: allow-discrete;
&:popover-open {
opacity: 1;
@starting-style {
opacity: 0;
}
}
}
[data-md-tooltip]:is([data-md-side='top'], [data-md-side='bottom']) {
margin-block: var(--md-sys-measurement-space50);
position-try-fallbacks: flip-block;
}
[data-md-tooltip]:is([data-md-side='left'], [data-md-side='right']) {
margin-inline: var(--md-sys-measurement-space50);
position-try-fallbacks: flip-inline;
}
[data-md-tooltip][data-md-side='top'] {
position-area: top;
}
[data-md-tooltip][data-md-side='bottom'] {
position-area: bottom;
}
[data-md-tooltip][data-md-side='left'] {
position-area: left;
}
[data-md-tooltip][data-md-side='right'] {
position-area: right;
}
}