Merge branch 'worktree-agent-a9b4c522a39c6ce62'
# Conflicts: # tests/Feature/StylesheetsTest.php # workbench/resources/css/app.css
This commit is contained in:
@@ -12,6 +12,14 @@
|
||||
@import './components/shape.css';
|
||||
|
||||
/* Actions and communication */
|
||||
@import './components/loading.css';
|
||||
@import './components/tooltip.css';
|
||||
@import './components/badge.css';
|
||||
@import './components/button.css';
|
||||
@import './components/group.css';
|
||||
@import './components/button-group.css';
|
||||
@import './components/split-button.css';
|
||||
@import './components/fab.css';
|
||||
|
||||
/* Inputs, selection and data */
|
||||
@import './components/form.css';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@
|
||||
@import './tokens/utilities.css';
|
||||
/* Actions and communication — leaving in step 36 */
|
||||
@import './components/actions.css';
|
||||
@import './components/groups.css';
|
||||
@import './components/menu.css';
|
||||
|
||||
/* Inputs, selection and data — leaving in step 36 */
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
Both are `error` by default, as M3 draws them, and an `outline` status label draws its edge in
|
||||
`outline`, not `outline-variant`: M3 asks a badge for 3:1 against what is behind it, and
|
||||
`outline-variant` is the decorative role dividers use. `floating` pins one to the top-end
|
||||
corner of a `relative` parent, on M3's anchor geometry — the dot flush in the corner, the
|
||||
count 2px above it — an icon or an icon button:
|
||||
corner of a `position: relative` parent, on M3's anchor geometry — the dot flush in the corner,
|
||||
the count 2px above it — an icon or an icon button:
|
||||
|
||||
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
|
||||
<span style="position: relative; display: inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
|
||||
|
||||
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
|
||||
colour's container ("Expired" in error-container), `solid` in the colour itself (a label that
|
||||
@@ -21,13 +21,17 @@
|
||||
count (the ink an outline badge already writes in), surface-container-high with
|
||||
on-surface-variant text when `tonal`, the outline edge when `outline`.
|
||||
- `plain` — no background, text or border colour at all, only shape, size and type, so the
|
||||
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
|
||||
caller's CSS paints it.
|
||||
|
||||
The value is `value`, or the slot, which renders as HTML — an icon beside the word:
|
||||
`<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
|
||||
`<x-badge tonal><x-icon name="bolt" size="12" /> Pro</x-badge>`. With neither it is a dot.
|
||||
|
||||
A count or dot says nothing to a screen reader on its own: give the icon's control a label
|
||||
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
|
||||
that includes it ("Notifications, 4 new"), or pass `label` here.
|
||||
|
||||
Drawn by resources/css/components/badge.css from `data-md-dot` (the small badge),
|
||||
`data-md-variant` (a status label: `solid`, `tonal` or `outline`, the first of them that is
|
||||
set, in that order), `data-md-color` and `data-md-floating`. --}}
|
||||
|
||||
@props([
|
||||
'value' => null,
|
||||
@@ -54,38 +58,23 @@
|
||||
$markup = false;
|
||||
}
|
||||
|
||||
$filled = [
|
||||
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
|
||||
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
|
||||
'neutral' => 'bg-on-surface-variant text-surface',
|
||||
];
|
||||
$container = [
|
||||
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
|
||||
'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container',
|
||||
'neutral' => 'bg-surface-container-high text-on-surface-variant',
|
||||
];
|
||||
$paint = match (true) {
|
||||
$color === 'plain' => '',
|
||||
! $status, $solid => $filled[$color],
|
||||
$tonal => $container[$color],
|
||||
default => 'border-outline text-on-surface-variant',
|
||||
// `solid` wins over `tonal`, and either over `outline`, as the three have always resolved.
|
||||
$variant = match (true) {
|
||||
! $status => null,
|
||||
(bool) $solid => 'solid',
|
||||
(bool) $tonal => 'tonal',
|
||||
default => 'outline',
|
||||
};
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
'inline-flex shrink-0 items-center justify-center whitespace-nowrap',
|
||||
'size-1.5 rounded-corner-full' => $dot,
|
||||
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
|
||||
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
|
||||
'border' => $outline && ! $tonal && ! $solid && ! $dot,
|
||||
$paint => $paint !== '',
|
||||
'absolute top-0 end-0' => $floating && $dot,
|
||||
'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
|
||||
])
|
||||
->merge(array_filter([
|
||||
'aria-label' => $label,
|
||||
'aria-hidden' => $label === null && ! $status ? 'true' : null,
|
||||
]));
|
||||
$attributes = $attributes->merge(array_filter([
|
||||
'data-md-badge' => true,
|
||||
'data-md-dot' => $dot ? true : null,
|
||||
'data-md-variant' => $variant,
|
||||
'data-md-color' => $color,
|
||||
'data-md-floating' => $floating ? true : null,
|
||||
'aria-label' => $label,
|
||||
'aria-hidden' => $label === null && ! $status ? 'true' : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
smaller `size` or fewer buttons, or two groups stacked.
|
||||
|
||||
Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The
|
||||
shapes are resources/css/components/groups.css. --}}
|
||||
spacing, shapes and press expansion are resources/css/components/button-group.css. --}}
|
||||
|
||||
@props([
|
||||
'connected' => false,
|
||||
@@ -71,12 +71,12 @@
|
||||
<div
|
||||
role="group"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
data-button-group="{{ $connected ? 'connected' : 'standard' }}"
|
||||
data-size="{{ $size }}"
|
||||
data-shape="{{ $shape }}"
|
||||
data-md-button-group="{{ $connected ? 'connected' : 'standard' }}"
|
||||
data-md-size="{{ $size }}"
|
||||
data-md-shape="{{ $shape }}"
|
||||
@if ($selection !== null)
|
||||
data-selection="{{ $selection }}"
|
||||
@if ($required) data-selection-required @endif
|
||||
data-md-selection="{{ $selection }}"
|
||||
@if ($required) data-md-selection-required @endif
|
||||
x-data="{
|
||||
multiple: {{ $multiple ? 'true' : 'false' }},
|
||||
required: {{ $required ? 'true' : 'false' }},
|
||||
@@ -129,13 +129,7 @@
|
||||
x-on:click="press($event)"
|
||||
@unless ($model) x-modelable="value" @endunless
|
||||
@endif
|
||||
{{ $attributes->class([
|
||||
'inline-flex items-center',
|
||||
'gap-0.5' => $connected,
|
||||
'gap-[18px]' => ! $connected && $size === 'xs',
|
||||
'gap-3' => ! $connected && $size === 'sm',
|
||||
'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true),
|
||||
]) }}
|
||||
{{ $attributes }}
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,8 @@
|
||||
`size` is Expressive's scale — `xs` 32px, `sm` 40px (the default), `md` 56px, `lg` 96px,
|
||||
`xl` 136px — each with its own padding, type style and icon size. `shape` is `round` (the
|
||||
default) or `square`; either one squares off towards a smaller corner while pressed, on the
|
||||
fast spatial spring, while the colours cross on the fast effects spring beside it
|
||||
(`state-transition-fast`, resources/css/components/actions.css: a colour must never
|
||||
overshoot).
|
||||
fast spatial spring, while the colours cross on the fast effects spring beside it (a colour
|
||||
must never overshoot).
|
||||
|
||||
With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`,
|
||||
`variant="text"` is M3's standard icon button, and the tooltip or label names it for screen
|
||||
@@ -39,8 +38,9 @@
|
||||
(`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a snackbar above a
|
||||
FAB and never over one.
|
||||
|
||||
Never pass `hidden`, a display or a position class: the button is `inline-flex` and
|
||||
`relative`, and whichever Tailwind emits last wins. Wrap it instead. --}}
|
||||
The view renders the props as `data-md-*` attributes and resources/css/components/button.css
|
||||
draws them, in the package's components layer: a caller's class or unlayered CSS outranks
|
||||
every rule there, `hidden` included. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
@@ -68,7 +68,6 @@
|
||||
'fab' => false,
|
||||
'disabled' => false,
|
||||
'type' => 'button',
|
||||
'corners' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -86,7 +85,7 @@
|
||||
};
|
||||
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
$square = $shape === 'square';
|
||||
$shape = $shape === 'square' ? 'square' : 'round';
|
||||
$width = in_array($width, ['narrow', 'default', 'wide'], true) ? $width : 'default';
|
||||
$iconOnly = filled($icon) && blank($label) && $slot->isEmpty();
|
||||
$isLink = filled($link);
|
||||
@@ -107,134 +106,61 @@
|
||||
default => null,
|
||||
};
|
||||
|
||||
// Every colour class written out whole, so Tailwind compiles it.
|
||||
$ink = [
|
||||
'primary' => 'text-primary', 'secondary' => 'text-secondary', 'tertiary' => 'text-tertiary',
|
||||
'error' => 'text-error', 'success' => 'text-success', 'warning' => 'text-warning', 'info' => 'text-info',
|
||||
];
|
||||
$fill = [
|
||||
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary',
|
||||
'tertiary' => 'bg-tertiary text-on-tertiary', 'error' => 'bg-error text-on-error',
|
||||
'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
|
||||
];
|
||||
$container = [
|
||||
'primary' => 'bg-secondary-container text-on-secondary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container',
|
||||
'tertiary' => 'bg-tertiary-container text-on-tertiary-container', 'error' => 'bg-error-container text-on-error-container',
|
||||
'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container',
|
||||
'info' => 'bg-info-container text-on-info-container',
|
||||
];
|
||||
$containerSelected = ['primary' => 'bg-secondary text-on-secondary'] + $fill;
|
||||
$quietInk = ['primary' => 'text-on-surface-variant'] + $ink;
|
||||
|
||||
$colours = match ($variant) {
|
||||
'filled' => $selected === false ? 'bg-surface-container text-on-surface-variant' : $fill[$color],
|
||||
'tonal' => $selected === true ? $containerSelected[$color] : $container[$color],
|
||||
'outlined' => $selected === true ? 'bg-inverse-surface text-inverse-on-surface border-transparent' : 'border-outline-variant '.$quietInk[$color],
|
||||
'elevated' => $selected === true ? $fill[$color] : 'bg-surface-container-low '.$ink[$color],
|
||||
'text' => match (true) {
|
||||
$iconOnly => $selected === true ? $ink[$color] : $quietInk[$color],
|
||||
default => $selected === true ? $container[$color] : $ink[$color],
|
||||
},
|
||||
};
|
||||
|
||||
$squareCorners = ['xs' => 'rounded-corner-md', 'sm' => 'rounded-corner-md', 'md' => 'rounded-corner-lg', 'lg' => 'rounded-corner-xl', 'xl' => 'rounded-corner-xl'];
|
||||
$pressed = ['xs' => 'active:rounded-corner-sm', 'sm' => 'active:rounded-corner-sm', 'md' => 'active:rounded-corner-md', 'lg' => 'active:rounded-corner-lg', 'xl' => 'active:rounded-corner-lg'];
|
||||
|
||||
$corner = match (true) {
|
||||
$iconOnly && $selected === true => $square ? 'rounded-corner-full' : $squareCorners[$size],
|
||||
$selected === true, $square => $squareCorners[$size],
|
||||
default => 'rounded-corner-full',
|
||||
};
|
||||
|
||||
$dimensions = $iconOnly
|
||||
? [
|
||||
'xs' => ['narrow' => 'h-8 w-7', 'default' => 'size-8', 'wide' => 'h-8 w-10'],
|
||||
'sm' => ['narrow' => 'h-10 w-8', 'default' => 'size-10', 'wide' => 'h-10 w-13'],
|
||||
'md' => ['narrow' => 'h-14 w-12', 'default' => 'size-14', 'wide' => 'h-14 w-18'],
|
||||
'lg' => ['narrow' => 'h-24 w-16', 'default' => 'size-24', 'wide' => 'h-24 w-32'],
|
||||
'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'],
|
||||
][$size][$width]
|
||||
: [
|
||||
'xs' => 'h-8 gap-2 px-4 type-label-lg',
|
||||
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||
][$size];
|
||||
|
||||
// The glyph's size in px (button.css draws the loading indicator at the same size). M3 draws a
|
||||
// symbol from its 20px cut below 24px, which the icon component picks from the size.
|
||||
$iconSize = $iconOnly
|
||||
? ['xs' => 'size-5', 'sm' => 'size-6', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size]
|
||||
: ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||
|
||||
// M3 draws a Material Symbol from a 20px cut below 24px: heavier strokes, wider counters, so a
|
||||
// small glyph does not thin out. The cut follows the size the icon is drawn at, never the button's.
|
||||
$optical = $iconSize === 'size-5' ? '20' : '24';
|
||||
|
||||
$outline = ['xs' => 'border', 'sm' => 'border', 'md' => 'border', 'lg' => 'border-2', 'xl' => 'border-3'][$size];
|
||||
$contained = in_array($variant, ['filled', 'tonal', 'elevated'], true) || ($variant === 'outlined' && $selected === true);
|
||||
|
||||
$classes = [
|
||||
'group/button state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'state-transition-fast',
|
||||
$dimensions,
|
||||
// A composite component (a split button's halves) passes the corners its place needs.
|
||||
$corners ?? $corner.' '.$pressed[$size],
|
||||
$colours,
|
||||
$outline => $variant === 'outlined',
|
||||
'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true),
|
||||
'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated',
|
||||
// Below 48px the touch target reaches past the button, as M3 requires (tokens/state.css).
|
||||
'touch-target' => in_array($size, ['xs', 'sm'], true),
|
||||
'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none',
|
||||
'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained,
|
||||
'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained,
|
||||
// The FAB, on a compact window only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares.
|
||||
'max-medium:fixed max-medium:end-4 max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)] max-medium:z-30 max-medium:h-14 max-medium:gap-2 max-medium:px-4 max-medium:rounded-corner-lg max-medium:type-title-md max-medium:bg-primary-container max-medium:text-on-primary-container max-medium:shadow-elevation-3' => $fab,
|
||||
];
|
||||
? ['xs' => 20, 'sm' => 24, 'md' => 24, 'lg' => 32, 'xl' => 40][$size]
|
||||
: ['xs' => 20, 'sm' => 20, 'md' => 24, 'lg' => 32, 'xl' => 40][$size];
|
||||
|
||||
$tag = $isLink ? 'a' : 'button';
|
||||
|
||||
$attributes = $attributes
|
||||
->class($classes)
|
||||
->merge(array_filter([
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
||||
'aria-disabled' => $inert ? 'true' : null,
|
||||
'tabindex' => $inert ? '-1' : null,
|
||||
'type' => $isLink ? null : $type,
|
||||
'disabled' => ! $isLink && $disabled ? true : null,
|
||||
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
|
||||
// A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps
|
||||
// the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`).
|
||||
'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'),
|
||||
'data-icon-button' => $iconOnly ? true : null,
|
||||
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
|
||||
'wire:target' => $spinnerTarget,
|
||||
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
$attributes = $attributes->merge(array_filter([
|
||||
'data-md-button' => true,
|
||||
'data-md-variant' => $variant,
|
||||
'data-md-color' => $color,
|
||||
'data-md-size' => $size,
|
||||
'data-md-shape' => $shape,
|
||||
'data-md-icon-button' => $iconOnly ? true : null,
|
||||
'data-md-width' => $iconOnly ? $width : null,
|
||||
'data-md-selected' => $selected === null ? null : ($selected ? 'true' : 'false'),
|
||||
'data-md-compact-fab' => $fab ? true : null,
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
||||
'aria-disabled' => $inert ? 'true' : null,
|
||||
'tabindex' => $inert ? '-1' : null,
|
||||
'type' => $isLink ? null : $type,
|
||||
'disabled' => ! $isLink && $disabled ? true : null,
|
||||
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
|
||||
// A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps
|
||||
// the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`).
|
||||
'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'),
|
||||
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
|
||||
'wire:target' => $spinnerTarget,
|
||||
'style' => $anchor ? "anchor-name: {$anchor}" : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }} {{ $attributes }}>
|
||||
@if ($spinnerTarget)
|
||||
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
|
||||
<x-livewire-material::loading :class="$iconSize.' text-current'" :label="false" />
|
||||
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" data-md-button-spinner>
|
||||
<x-livewire-material::loading :label="false" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if ($icon)
|
||||
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
|
||||
<x-livewire-material::icon :name="$icon" :optical="$optical" :filled="$selected === true || ($iconOnly && $selected === null)" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-medium:size-6' => $fab])" />
|
||||
<span data-md-button-icon @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
|
||||
<x-livewire-material::icon :name="$icon" :size="$iconSize" :filled="$selected === true || ($iconOnly && $selected === null)" />
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@unless ($iconOnly)
|
||||
<span @class(['max-expanded:hidden' => $responsive])>{{ $label ?? $slot }}</span>
|
||||
<span data-md-button-label @if ($responsive) data-md-responsive @endif>{{ $label ?? $slot }}</span>
|
||||
@endunless
|
||||
|
||||
@if ($iconRight)
|
||||
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="$iconRight" :size="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($tip !== null)
|
||||
|
||||
@@ -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.
|
||||
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
|
||||
create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
|
||||
`<div style="position: fixed; inset-inline-end: 16px; bottom: 16px"><x-fab icon="add" tooltip="New share" /></div>`.
|
||||
For a create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
|
||||
|
||||
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)
|
||||
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="$icon" filled :size="$iconSize" />
|
||||
@endif
|
||||
|
||||
@if ($collapsing)
|
||||
<span data-fab-label><span>{{ $label ?? $slot }}</span></span>
|
||||
<span data-md-fab-label><span>{{ $label ?? $slot }}</span></span>
|
||||
@elseif ($extended)
|
||||
<span>{{ $label ?? $slot }}</span>
|
||||
@endif
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the
|
||||
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
|
||||
`inline`. An option with `'disabled' => true` greys its own segment. At `xs` and `sm` a
|
||||
segment carries `touch-target` and a 48px minimum width, which M3 asks for by name and tells
|
||||
you never to reduce. Corners move on the spatial spring and colours on the effects one
|
||||
(`state-transition-fast`).
|
||||
segment keeps a 48px target and a 48px minimum width, which M3 asks for by name and tells
|
||||
you never to reduce. Corners move on the spatial spring and colours on the effects one.
|
||||
|
||||
`shape` is M3's "Default shape | Round, square" configuration: `square` squares the two ends
|
||||
of the group to the corner its inner edges take (M3's square table, 4/8/8/16/20dp by size),
|
||||
@@ -25,9 +24,11 @@
|
||||
the hint.
|
||||
|
||||
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
|
||||
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
|
||||
specificity, as the field's does in the components layer, because which of two colour
|
||||
utilities wins depends on the order Tailwind emits them. --}}
|
||||
(`hint-class="md-ink-warning"` for a hint that warns), because a caller's class outranks the
|
||||
package's layer.
|
||||
|
||||
Drawn by resources/css/components/group.css, with the connected shapes of
|
||||
resources/css/components/button-group.css. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
@@ -52,76 +53,54 @@
|
||||
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
|
||||
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
$variant = in_array($variant, ['tonal', 'filled', 'outlined'], true) ? $variant : 'tonal';
|
||||
// M3's "Default shape | Round, square": a square group's ends take the corner its inner edges
|
||||
// take; the chosen segment still rounds fully, which is the cue selection has always carried.
|
||||
$shape = $shape === 'square' ? 'square' : 'round';
|
||||
// M3: an xs or sm connected segment keeps a 48px target and a 48px minimum width, whatever
|
||||
// its 32px/40px container measures. From md the segment is wider than that on its own.
|
||||
$small = in_array($size, ['xs', 'sm'], true);
|
||||
|
||||
$segment = [
|
||||
'xs' => 'h-8 gap-2 px-4 type-label-lg',
|
||||
'sm' => 'h-10 gap-2 px-4 type-label-lg',
|
||||
'md' => 'h-14 gap-2 px-6 type-title-md',
|
||||
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
|
||||
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
|
||||
][$size];
|
||||
// A 20px glyph comes from M3's 20px cut, which the icon component picks from the size.
|
||||
$iconSize = ['xs' => 20, 'sm' => 20, 'md' => 24, 'lg' => 32, 'xl' => 40][$size];
|
||||
|
||||
$hintClasses = filled($hintClass)
|
||||
? \Illuminate\Support\Arr::toCssClasses(['mt-1 type-body-sm [:where(&)]:text-on-surface-variant', $hintClass])
|
||||
: 'mt-1 type-body-sm text-on-surface-variant';
|
||||
|
||||
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
|
||||
// A 20px glyph comes from M3's 20px cut, which is drawn for that size rather than scaled to it.
|
||||
$optical = $iconSize === 'size-5' ? '20' : '24';
|
||||
|
||||
$colours = match ($variant) {
|
||||
'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary',
|
||||
'outlined' => 'border border-outline-variant text-on-surface-variant has-checked:border-transparent has-checked:bg-inverse-surface has-checked:text-inverse-on-surface',
|
||||
default => 'bg-secondary-container text-on-secondary-container has-checked:bg-secondary has-checked:text-on-secondary',
|
||||
};
|
||||
$root = $attributes->only(['class', 'wire:key'])->merge(array_filter([
|
||||
'data-md-group' => true,
|
||||
'data-md-size' => $size,
|
||||
'data-md-variant' => $variant,
|
||||
'data-md-shape' => $shape,
|
||||
'data-md-multiple' => $multiple ? true : null,
|
||||
'data-md-inline' => $inline ? true : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<fieldset {{ $attributes->only(['class', 'wire:key'])->class('min-w-0') }}>
|
||||
<fieldset {{ $root }}>
|
||||
@if (filled($label))
|
||||
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
|
||||
<legend data-md-group-legend>{{ $label }}</legend>
|
||||
@endif
|
||||
|
||||
<div data-button-group="connected" data-size="{{ $size }}" data-shape="{{ $shape }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
|
||||
<div data-md-button-group="connected" data-md-size="{{ $size }}" data-md-shape="{{ $shape }}">
|
||||
@foreach ($options as $option)
|
||||
<label @class([
|
||||
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
|
||||
'state-transition-fast',
|
||||
'flex-1' => ! $inline,
|
||||
$small ? 'touch-target min-w-12' : 'min-w-0',
|
||||
$segment,
|
||||
$colours,
|
||||
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
|
||||
'has-disabled:cursor-not-allowed has-disabled:bg-on-surface/10 has-disabled:text-on-surface/38',
|
||||
])>
|
||||
<label data-md-group-segment>
|
||||
<input
|
||||
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
|
||||
type="{{ $multiple ? 'checkbox' : 'radio' }}"
|
||||
name="{{ $multiple ? $name.'[]' : $name }}"
|
||||
value="{{ data_get($option, $optionValue) }}"
|
||||
@disabled(data_get($option, 'disabled'))
|
||||
class="peer sr-only"
|
||||
/>
|
||||
|
||||
@if (filled(data_get($option, $optionIcon)))
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :optical="$optical" :class="$iconSize" />
|
||||
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :size="$iconSize" />
|
||||
@endif
|
||||
|
||||
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
|
||||
<span data-md-group-label>{{ data_get($option, $optionLabel) }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if ($messages !== [])
|
||||
@foreach ($messages as $message)
|
||||
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
|
||||
<p data-md-group-error>{{ $message }}</p>
|
||||
@endforeach
|
||||
@elseif (filled($hint))
|
||||
<p class="{{ $hintClasses }}">{{ $hint }}</p>
|
||||
<p data-md-group-hint @if (filled($hintClass)) class="{{ $hintClass }}" @endif>{{ $hint }}</p>
|
||||
@endif
|
||||
</fieldset>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while
|
||||
it turns, for a wait that has no known length.
|
||||
|
||||
In the text colour — primary unless the caller colours it — and 48px unless sized. `contained`
|
||||
puts it on a primary-container circle, for a spinner over content. It is a `progressbar`
|
||||
named `label` ("Loading" by default); pass `:label="false"` where something else already
|
||||
says what is happening, as a button's own spinner does.
|
||||
In the text colour — primary unless the caller colours it — and 48px unless the caller's CSS
|
||||
sizes it. `contained` puts it on a primary-container circle, for a spinner over content. It
|
||||
is a `progressbar` named `label` ("Loading" by default); pass `:label="false"` where something
|
||||
else already says what is happening, as a button's own spinner does.
|
||||
|
||||
Pure SVG with SMIL, so it animates in every engine without script. Under
|
||||
`prefers-reduced-motion` the shape rests. The geometry is androidx Compose Material 3's
|
||||
LoadingIndicator and Morph, ported in bin/loading-indicator.mjs (Apache-2.0):
|
||||
LoadingIndicatorTokens' 38px indicator in a 48px container, a morph every 650ms on a spring,
|
||||
a quarter turn per morph and a full turn every 4666ms. --}}
|
||||
a quarter turn per morph and a full turn every 4666ms. Drawn by
|
||||
resources/css/components/loading.css. --}}
|
||||
|
||||
@props([
|
||||
'contained' => false,
|
||||
@@ -18,26 +19,19 @@
|
||||
])
|
||||
|
||||
@php
|
||||
$sized = preg_match('/(^|\s)(size|w|h)-/', (string) $attributes->get('class')) === 1;
|
||||
$coloured = preg_match('/(^|\s)text-(?!(xs|sm|base|lg|xl|[2-9]xl|left|center|right|start|end)(\s|$))/', (string) $attributes->get('class')) === 1;
|
||||
$decorative = $label === false;
|
||||
$label = $decorative ? null : ($label ?? __('Loading'));
|
||||
|
||||
$attributes = $attributes
|
||||
->class([
|
||||
'inline-flex shrink-0 items-center justify-center',
|
||||
'size-12' => ! $sized,
|
||||
'rounded-corner-full bg-primary-container text-on-primary-container' => $contained,
|
||||
'text-primary' => ! $contained && ! $coloured,
|
||||
])
|
||||
->merge(array_filter([
|
||||
'role' => $decorative ? null : 'progressbar',
|
||||
'aria-label' => $label,
|
||||
'aria-hidden' => $decorative ? 'true' : null,
|
||||
]));
|
||||
$attributes = $attributes->merge(array_filter([
|
||||
'data-md-loading' => true,
|
||||
'data-md-contained' => $contained ? true : null,
|
||||
'role' => $decorative ? null : 'progressbar',
|
||||
'aria-label' => $label,
|
||||
'aria-hidden' => $decorative ? 'true' : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes }}>
|
||||
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['class' => 'size-full motion-reduce:hidden', 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
|
||||
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['class' => 'hidden size-full motion-reduce:block', 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
|
||||
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['data-md-loading-animated' => true, 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
|
||||
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['data-md-loading-still' => true, 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
|
||||
</span>
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
|
||||
</x-split-button>
|
||||
|
||||
Attributes (wire:click, spinner…) go to the leading button; the slot is the menu. `variant` is
|
||||
`filled` (the default), `tonal`, `outlined` or `elevated` — a text split button does not exist
|
||||
in M3 — with `color` and `size` as on `<x-button>`. The halves sit 2px apart; the trailing
|
||||
one rounds fully and turns its chevron over while the menu is open. `menu-label` names the
|
||||
trailing button and the menu for screen readers.
|
||||
Attributes (wire:click, spinner…) go to the leading button, a `class` to the pair; the slot is
|
||||
the menu. `variant` is `filled` (the default), `tonal`, `outlined` or `elevated` — a text split
|
||||
button does not exist in M3 — with `color` and `size` as on `<x-button>`. The halves sit 2px
|
||||
apart; the trailing one rounds fully and turns its chevron over while the menu is open.
|
||||
`menu-label` names the trailing button and the menu for screen readers.
|
||||
|
||||
Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button
|
||||
keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px
|
||||
wide there. resources/css/components/groups.css carries the rest of the spec, which is all
|
||||
geometry: the inner corners (4/4/4/8/12px, growing under the finger, not shrinking as a
|
||||
wide there. resources/css/components/split-button.css carries the rest of the spec, which is
|
||||
all geometry: the inner corners (4/4/4/8/12px, growing under the finger, not shrinking as a
|
||||
connected group's do), the trailing icon's own size (22/22/26/38/50px, which the icon-button
|
||||
table has no entry for), the 1/1/2/3/6px nudge that sits the chevron towards the leading half,
|
||||
and the standard motion scheme M3 asks for while it turns over. --}}
|
||||
@@ -35,12 +35,9 @@
|
||||
$variant = in_array($variant, ['filled', 'tonal', 'outlined', 'elevated'], true) ? $variant : 'filled';
|
||||
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
|
||||
$menuLabel ??= __('More options');
|
||||
|
||||
$leadingPadding = ['xs' => 'pe-2.5', 'sm' => 'pe-3', 'md' => '', 'lg' => '', 'xl' => ''][$size];
|
||||
$trailingWidth = ['xs' => 'w-12', 'sm' => 'w-12', 'md' => '', 'lg' => '', 'xl' => ''][$size];
|
||||
@endphp
|
||||
|
||||
<div data-button-group="split" data-size="{{ $size }}" {{ $attributes->only('class')->class('inline-flex items-center gap-0.5') }}>
|
||||
<div data-md-split-button data-md-size="{{ $size }}" {{ $attributes->only('class') }}>
|
||||
<x-livewire-material::button
|
||||
{{ $attributes->except('class') }}
|
||||
:label="$label"
|
||||
@@ -50,9 +47,7 @@
|
||||
:size="$size"
|
||||
:disabled="$disabled"
|
||||
:spinner="$spinner"
|
||||
corners=""
|
||||
data-split="leading"
|
||||
:class="$leadingPadding"
|
||||
data-md-split="leading"
|
||||
/>
|
||||
|
||||
<x-livewire-material::menu :position="$position" :label="$menuLabel">
|
||||
@@ -64,9 +59,7 @@
|
||||
:color="$color"
|
||||
:size="$size"
|
||||
:disabled="$disabled"
|
||||
corners=""
|
||||
data-split="trailing"
|
||||
:class="$trailingWidth"
|
||||
data-md-split="trailing"
|
||||
/>
|
||||
</x-slot:trigger>
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<button
|
||||
type="button"
|
||||
x-data
|
||||
data-icon-button
|
||||
data-md-icon-button
|
||||
data-theme-toggle="{{ $mode }}"
|
||||
@if ($mode === 'cycle')
|
||||
aria-label="{{ __('Theme') }}"
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
at once on a press or Escape; leaving or blurring lets it stand for M3's 1.5s. Only one
|
||||
tooltip is on screen at a time, as M3 asks. A phone has no hover, so a control there carries its
|
||||
words. The bubble is a `popover="manual"` in the top layer — never clipped by an
|
||||
`overflow-hidden` parent, never widening a scroll container — placed by CSS anchor
|
||||
`overflow: hidden` parent, never widening a scroll container — placed by CSS anchor
|
||||
positioning on `side` (`top`, `bottom`, `left`, `right`), flipping when there is no room.
|
||||
Drawn by resources/css/components/tooltip.css.
|
||||
|
||||
It is aria-hidden: an icon button takes the same words as its aria-label, and a labelled
|
||||
control would otherwise read them twice. --}}
|
||||
@@ -28,7 +29,7 @@
|
||||
@endphp
|
||||
|
||||
@if ($standalone)
|
||||
<span class="inline-flex" style="anchor-name: {{ $anchor }}">
|
||||
<span data-md-tooltip-anchor style="anchor-name: {{ $anchor }}">
|
||||
{{ $slot }}
|
||||
@endif
|
||||
|
||||
@@ -36,15 +37,9 @@
|
||||
popover="manual"
|
||||
aria-hidden="true"
|
||||
x-data="materialTooltip"
|
||||
data-md-tooltip
|
||||
data-md-side="{{ $side }}"
|
||||
style="position-anchor: {{ $anchor }}"
|
||||
@class([
|
||||
'pointer-events-none m-0 max-w-50 overflow-visible border-0 rounded-corner-xs bg-inverse-surface px-2 py-1 text-center whitespace-normal type-body-sm text-inverse-on-surface [inset:auto]',
|
||||
'opacity-0 transition-[opacity,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
|
||||
'my-1 [position-area:top] [position-try-fallbacks:flip-block]' => $side === 'top',
|
||||
'my-1 [position-area:bottom] [position-try-fallbacks:flip-block]' => $side === 'bottom',
|
||||
'mx-1 [position-area:left] [position-try-fallbacks:flip-inline]' => $side === 'left',
|
||||
'mx-1 [position-area:right] [position-try-fallbacks:flip-inline]' => $side === 'right',
|
||||
])
|
||||
>{{ $text }}</span>
|
||||
|
||||
@if ($standalone)
|
||||
|
||||
@@ -222,10 +222,10 @@ it('moves a connected group\'s choice with the arrow keys', function () {
|
||||
});
|
||||
|
||||
it('rounds a split button\'s trailing half while its menu is open', function () {
|
||||
$trailing = "document.querySelector('[data-split=\"trailing\"]')";
|
||||
$trailing = "document.querySelector('[data-md-split=\"trailing\"]')";
|
||||
|
||||
showcase()
|
||||
->click('[data-split="trailing"] >> nth=0')
|
||||
->click('[data-md-split="trailing"] >> nth=0')
|
||||
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
|
||||
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === ({$trailing}.offsetHeight / 2) + 'px'");
|
||||
});
|
||||
@@ -235,7 +235,7 @@ it('keeps a connected segment\'s small inner corners, which a 9999px outer corne
|
||||
// factor: a full corner written as 9999px drew the 8px inner corners square. Every radius
|
||||
// in a connected group or a split button stays within half its height, so none is scaled.
|
||||
showcase()
|
||||
->assertScript("[...document.querySelectorAll('[data-button-group=\"connected\"] > *, [data-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })")
|
||||
->assertScript("[...document.querySelectorAll('[data-md-button-group=\"connected\"] > *, [data-md-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })")
|
||||
->assertScript("(el => getComputedStyle(el).borderTopRightRadius === '8px')(document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"light\"]').parentElement)");
|
||||
});
|
||||
|
||||
@@ -464,3 +464,73 @@ it('closes a FAB menu when an item\'s action runs, and opens and closes it clean
|
||||
->assertScript('! '.FAB_MENU.".matches(':popover-open')")
|
||||
->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'
|
||||
(() => {
|
||||
const segment = document.querySelector('#buttons input[name="showcase-theme"][value="light"]').parentElement
|
||||
segment.scrollIntoView({ block: 'center' })
|
||||
const box = segment.getBoundingClientRect()
|
||||
const x = box.left + box.width / 2
|
||||
return box.height === 40 && box.width >= 48
|
||||
&& document.elementFromPoint(x, box.top - 3) === segment
|
||||
&& document.elementFromPoint(x, box.bottom + 3) === segment
|
||||
})()
|
||||
JS);
|
||||
});
|
||||
|
||||
it('keeps one choice in a single, required selection group', function () {
|
||||
$group = '#buttons [data-md-selection="single"]';
|
||||
$view = "document.querySelector('{$group}').parentElement.querySelector('code').textContent";
|
||||
|
||||
$page = showcase();
|
||||
|
||||
$page->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'true')
|
||||
->click("{$group} button:has-text(\"Day\")")
|
||||
->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true')
|
||||
->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'false')
|
||||
->assertScript("{$view} === 'day'");
|
||||
|
||||
// Required: pressing the chosen button again leaves it chosen.
|
||||
$page->click("{$group} button:has-text(\"Day\")")
|
||||
->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true')
|
||||
->assertScript("{$view} === 'day'");
|
||||
});
|
||||
|
||||
it('toggles each choice of a multi selection group, down to none', function () {
|
||||
$group = '#buttons [data-md-selection="multi"]';
|
||||
$marks = "document.querySelector('{$group}').parentElement.querySelectorAll('code')[1].textContent";
|
||||
|
||||
showcase()
|
||||
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'true')
|
||||
->click("{$group} [aria-label=\"Italic\"]")
|
||||
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'true')
|
||||
->assertScript("{$marks} === 'bold, italic'")
|
||||
->click("{$group} [aria-label=\"Bold\"]")
|
||||
->click("{$group} [aria-label=\"Italic\"]")
|
||||
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'false')
|
||||
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'false')
|
||||
->assertScript("{$marks} === 'none'");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
/**
|
||||
* The actions and communication components, rewritten without Tailwind (plan step 36): each view
|
||||
* renders `data-md-*` attributes and no class list of its own, and each has a stylesheet in
|
||||
* `material.components` that imports the stylesheets of the components its view renders, writes
|
||||
* its values from the tokens and its breakpoints as px range queries, and is imported from the
|
||||
* "Actions and communication" block of components.css.
|
||||
*/
|
||||
dataset('action components', [
|
||||
'loading',
|
||||
'tooltip',
|
||||
'badge',
|
||||
'button',
|
||||
'group',
|
||||
'button-group',
|
||||
'split-button',
|
||||
'fab',
|
||||
]);
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
$css = ComponentStylesheet::read($name);
|
||||
|
||||
expect($css->css)->toStartWith('/*')
|
||||
->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;')
|
||||
->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/')
|
||||
->and($css->blocks())->each->toBe('@layer material.components')
|
||||
->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/');
|
||||
|
||||
foreach ($css->imports() as $import) {
|
||||
expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist");
|
||||
}
|
||||
})->with('action components');
|
||||
|
||||
it('imports the stylesheet of every component its view renders', function (string $name) {
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"), $tags);
|
||||
|
||||
$rendered = collect($tags[1])->unique()
|
||||
// A component still written for Tailwind has no stylesheet of its own to import yet; its
|
||||
// old stylesheet, if it has one, lives in tailwind.css without the layer statement.
|
||||
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
|
||||
->map(fn (string $tag): string => "./{$tag}.css")
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]);
|
||||
})->with('action components');
|
||||
|
||||
it('writes no class list into the view', function (string $name) {
|
||||
$view = File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php");
|
||||
|
||||
// A class the caller hands in (`hint-class`, `icon-class`, a slot's attributes) passes through.
|
||||
expect($view)->not->toMatch('/(?<![\w-])class="(?!\{\{ \$)|@class\(|->class\(|toCssClasses|(?:x-bind)?:class=/');
|
||||
})->with('action components');
|
||||
|
||||
it('takes its values from the tokens and its breakpoints in px', function (string $name) {
|
||||
$css = ComponentStylesheet::read($name);
|
||||
$source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css);
|
||||
|
||||
// Colours are roles, shadows are elevation levels, type is a type style, motion is a spring.
|
||||
expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i')
|
||||
->not->toMatch('/\bbox-shadow:(?!\s*(?:none|var\(--md-))/')
|
||||
->not->toMatch('/\bfont:(?!\s*var\(--md-sys-typescale-)/')
|
||||
->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/');
|
||||
|
||||
foreach ($css->mediaQueries() as $query) {
|
||||
preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $query, $lengths, PREG_SET_ORDER);
|
||||
|
||||
foreach ($lengths as [, $number, $unit]) {
|
||||
expect($unit)->toBe('px', "{$name}.css: {$query}")
|
||||
->and(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$name}.css: {$query} is not at an M3 breakpoint");
|
||||
}
|
||||
}
|
||||
})->with('action components');
|
||||
|
||||
it('is imported from the actions and communication block of components.css', function (string $name) {
|
||||
$components = File::get(__DIR__.'/../../../resources/css/components.css');
|
||||
$block = substr($components, (int) strpos($components, '/* Actions and communication */'));
|
||||
$block = substr($block, 0, (int) strpos($block, '/* Inputs, selection and data */'));
|
||||
|
||||
expect($block)->toContain("@import './components/{$name}.css';");
|
||||
})->with('action components');
|
||||
@@ -65,14 +65,14 @@ it('keeps two trailing icon buttons below medium and four from it, the rest in a
|
||||
->and(substr_count($html, '<span data-app-bar-action'))->toBe(3)
|
||||
->and(substr_count($html, 'data-overflows-compact'))->toBe(2)
|
||||
->and($html)->toMatch('/<span data-app-bar-action\s*>\s*<button[^>]*aria-label="Search"/')
|
||||
->toMatch('/<span data-app-bar-action\s+data-overflows-compact\s*>\s*<a href="\/shares\/1\/share"[^>]*aria-label="Share"/')
|
||||
->toMatch('/<span data-app-bar-action\s+data-overflows-compact\s*>\s*<a [^>]*href="\/shares\/1\/share"[^>]*aria-label="Share"/')
|
||||
->not->toMatch('/<(?:button|a)[^>]*aria-label="(?:Archive|Delete)"/')
|
||||
// Every icon button is named, tooltipped and reaches 48px.
|
||||
->toMatch('/<button[^>]*aria-label="Search"[^>]*class="[^"]*touch-target/')
|
||||
->toMatch('/<button[^>]*data-md-size="sm"[^>]*aria-label="Search"/')
|
||||
->toMatch('/popover="manual"[^>]*>\s*Search<\/span>/')
|
||||
->toMatch('/<button[^>]*aria-label="Star" aria-pressed="true"/')
|
||||
// One overflow button per width, named and tooltipped.
|
||||
->and(preg_match_all('/<button[^>]*aria-label="More options"[^>]*class="[^"]*touch-target/', $html))->toBe(2)
|
||||
->and(preg_match_all('/<button[^>]*data-md-size="sm"[^>]*aria-label="More options"/', $html))->toBe(2)
|
||||
->and(preg_match_all('/role="menu"\s+data-menu\s+aria-label="More options"/', $html))->toBe(2)
|
||||
// Below medium: all but the first.
|
||||
->and($compact)
|
||||
@@ -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 () {
|
||||
@@ -192,7 +192,7 @@ it('keeps a toolbar at the bottom clear of the navigation bar', function () {
|
||||
// A vertical toolbar keeps 24dp from the edge where a horizontal one keeps 16 (N-12).
|
||||
->toContain('inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right)));')
|
||||
// A standard toolbar's standard buttons are primary (N-13).
|
||||
->toContain('[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) {');
|
||||
->toContain('[data-toolbar]:not([data-vibrant]) [data-md-icon-button]:not([aria-pressed="true"]) {');
|
||||
});
|
||||
|
||||
it('offers M3\'s three contrast levels, marking the one in force', function () {
|
||||
@@ -200,7 +200,7 @@ it('offers M3\'s three contrast levels, marking the one in force', function () {
|
||||
->toContain('data-theme-toggle="contrast"')
|
||||
->toContain('aria-label="Contrast"')
|
||||
// A connected button group over native radios, not the deprecated segmented button (N-04).
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('data-md-button-group="connected"')
|
||||
->toContain('name="material-contrast"')
|
||||
->toContain('value="standard"')
|
||||
->toContain('value="medium"')
|
||||
@@ -216,7 +216,7 @@ it('names a theme row for a screen reader and legends it on request', function (
|
||||
->not->toContain('<legend')
|
||||
->and((string) $this->blade('<x-theme-toggle mode="picker" label="Appearance" />'))
|
||||
->toContain('aria-label="Appearance"')
|
||||
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Appearance</legend>');
|
||||
->toContain('<legend data-md-group-legend>Appearance</legend>');
|
||||
});
|
||||
|
||||
it('switches the theme through the store in three shapes', function () {
|
||||
|
||||
@@ -1,92 +1,145 @@
|
||||
<?php
|
||||
|
||||
it('is M3\'s small badge without a value', function () {
|
||||
expect((string) $this->blade('<x-badge />'))
|
||||
->toContain('size-1.5 rounded-corner-full')
|
||||
->toContain('bg-error text-on-error')
|
||||
->toContain('aria-hidden="true"');
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
/**
|
||||
* The attributes of the rendered badge, by name.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function badgeAttributes(string $blade): array
|
||||
{
|
||||
preg_match('/<span\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('is M3\'s small badge without a value, hidden from screen readers', function () {
|
||||
expect(badgeAttributes('<x-badge />'))->toBe([
|
||||
'data-md-badge' => 'data-md-badge',
|
||||
'data-md-dot' => 'data-md-dot',
|
||||
'data-md-color' => 'error',
|
||||
'aria-hidden' => 'true',
|
||||
])
|
||||
->and(ComponentStylesheet::read('badge')->declarations('[data-md-badge][data-md-dot]'))->toBe([
|
||||
'inline-size' => '6px',
|
||||
'block-size' => '6px',
|
||||
'border-radius' => 'var(--md-sys-shape-corner-full)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is M3\'s large badge with a count, capped by max', function () {
|
||||
expect((string) $this->blade('<x-badge value="4" />'))->toContain('h-4 min-w-4')->toContain('>4</span>')
|
||||
$css = ComponentStylesheet::read('badge');
|
||||
|
||||
expect((string) $this->blade('<x-badge value="4" />'))->toContain('>4</span>')->not->toContain('data-md-dot')->not->toContain('data-md-variant')
|
||||
->and((string) $this->blade('<x-badge value="1204" max="999" />'))->toContain('>999+</span>')
|
||||
->and((string) $this->blade('<x-badge value="12" max="999" color="primary" />'))->toContain('>12</span>')->toContain('bg-primary text-on-primary');
|
||||
->and((string) $this->blade('<x-badge value="12" max="999" color="primary" />'))->toContain('>12</span>')->toContain('data-md-color="primary"')
|
||||
->and($css->declarations('[data-md-badge]:not([data-md-dot], [data-md-variant])'))->toMatchArray([
|
||||
'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)',
|
||||
'font-variant-numeric' => 'tabular-nums',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins itself to the corner of an icon when floating', function () {
|
||||
expect((string) $this->blade('<x-badge floating />'))->toContain('absolute top-0 end-0')
|
||||
->and((string) $this->blade('<x-badge value="3" floating label="3 new messages" />'))
|
||||
->toContain('absolute -top-0.5')
|
||||
->toContain('aria-label="3 new messages"')
|
||||
->not->toContain('aria-hidden');
|
||||
$css = ComponentStylesheet::read('badge');
|
||||
|
||||
expect((string) $this->blade('<x-badge floating />'))->toContain('data-md-floating')
|
||||
->and(badgeAttributes('<x-badge value="3" floating label="3 new messages" />'))
|
||||
->toMatchArray(['data-md-floating' => 'data-md-floating', 'aria-label' => '3 new messages'])
|
||||
->not->toHaveKey('aria-hidden')
|
||||
->and($css->declarations('[data-md-badge][data-md-floating]'))->toBe([
|
||||
'position' => 'absolute',
|
||||
'inset-block-start' => 'calc(-1 * var(--md-sys-measurement-space25))',
|
||||
'inset-inline-start' => 'calc(100% - 12px)',
|
||||
])
|
||||
->and($css->declarations('[data-md-badge][data-md-floating][data-md-dot]'))->toBe(['inset-block-start' => '0', 'inset-inline' => 'auto 0']);
|
||||
});
|
||||
|
||||
it('draws a status label in a container or an outline', function () {
|
||||
expect((string) $this->blade('<x-badge value="Active" tone="success" tonal />'))
|
||||
->toContain('h-6 gap-1 rounded-corner-sm px-2 type-label-md')
|
||||
->toContain('bg-success-container text-on-success-container')
|
||||
->not->toContain('aria-hidden')
|
||||
->and((string) $this->blade('<x-badge value="Pro" outline />'))
|
||||
->toContain('border border-outline text-on-surface-variant')
|
||||
->not->toContain('bg-error');
|
||||
it('draws a status label in a container or an outline, spoken like any label', function () {
|
||||
$css = ComponentStylesheet::read('badge');
|
||||
|
||||
expect(badgeAttributes('<x-badge value="Active" tone="success" tonal />'))
|
||||
->toBe(['data-md-badge' => 'data-md-badge', 'data-md-variant' => 'tonal', 'data-md-color' => 'success'])
|
||||
->and(badgeAttributes('<x-badge value="Pro" outline />'))
|
||||
->toBe(['data-md-badge' => 'data-md-badge', 'data-md-variant' => 'outline', 'data-md-color' => 'error'])
|
||||
->and($css->declarations('[data-md-badge][data-md-variant]'))->toMatchArray([
|
||||
'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)',
|
||||
])
|
||||
->and($css->declarations("[data-md-badge]:not([data-md-color='plain'])[data-md-variant='tonal']"))->toBe([
|
||||
'background-color' => 'var(--md-badge-container)',
|
||||
'color' => 'var(--md-badge-on-container)',
|
||||
])
|
||||
// The outline role, not outline-variant: a badge needs 3:1 against what is behind it.
|
||||
->and($css->declarations("[data-md-badge]:not([data-md-color='plain'])[data-md-variant='outline']"))->toBe([
|
||||
'border-color' => 'var(--md-sys-color-outline)',
|
||||
'background-color' => 'transparent',
|
||||
'color' => 'var(--md-sys-color-on-surface-variant)',
|
||||
])
|
||||
->and($css->declarations("[data-md-badge][data-md-variant='outline']"))->toBe(['border' => '1px solid']);
|
||||
});
|
||||
|
||||
it('resolves solid over tonal, and either over outline', function () {
|
||||
expect(badgeAttributes('<x-badge value="Built in" solid tonal outline color="primary" />'))->toMatchArray(['data-md-variant' => 'solid', 'data-md-color' => 'primary'])
|
||||
->and(badgeAttributes('<x-badge value="Built in" tonal outline />'))->toMatchArray(['data-md-variant' => 'tonal'])
|
||||
->and(badgeAttributes('<x-badge solid />'))->toMatchArray(['data-md-dot' => 'data-md-dot'])->not->toHaveKey('data-md-variant')
|
||||
->and((string) $this->blade('<x-badge value="Built in" solid color="primary" />'))->toContain('>Built in<')->not->toContain('aria-hidden');
|
||||
});
|
||||
|
||||
it('renders its slot as HTML, and is a dot while the slot holds nothing but comments', function () {
|
||||
$html = (string) $this->blade('<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>');
|
||||
$html = (string) $this->blade('<x-badge tonal><x-icon name="bolt" size="12" /> Pro</x-badge>');
|
||||
|
||||
expect($html)
|
||||
->toContain('h-6 gap-1 rounded-corner-sm')
|
||||
->toContain('data-md-variant="tonal"')
|
||||
->toContain('<svg')
|
||||
->toContain(' Pro</span>')
|
||||
->not->toContain('<svg')
|
||||
->and((string) $this->blade('<x-badge value="<b>4</b>" />'))->toContain('<b>4</b>')
|
||||
->and((string) $this->blade("<x-badge tonal>\n <!-- nothing yet -->\n</x-badge>"))->toContain('size-1.5 rounded-corner-full')
|
||||
->and((string) $this->blade("<x-badge tonal>\n <!-- nothing yet -->\n</x-badge>"))->toContain('data-md-dot')
|
||||
->and((string) $this->blade('<x-badge max="99">120</x-badge>'))->toContain('>99+</span>');
|
||||
});
|
||||
|
||||
it('draws neutral ink on every variant', function () {
|
||||
expect((string) $this->blade('<x-badge color="neutral" />'))->toContain('size-1.5 rounded-corner-full bg-on-surface-variant text-surface')
|
||||
->and((string) $this->blade('<x-badge value="7" color="neutral" />'))->toContain('tabular-nums bg-on-surface-variant text-surface')
|
||||
->and((string) $this->blade('<x-badge value="Draft" tone="neutral" tonal />'))->toContain('type-label-md bg-surface-container-high text-on-surface-variant')
|
||||
->and((string) $this->blade('<x-badge value="Draft" color="neutral" outline />'))->toContain('type-label-md border border-outline text-on-surface-variant');
|
||||
});
|
||||
|
||||
it('leaves a plain badge to the caller\'s colour classes', function (string $badge, string $shape) {
|
||||
$html = (string) $this->blade($badge);
|
||||
|
||||
preg_match('/class="([^"]*)"/', $html, $class);
|
||||
|
||||
expect($class[1])
|
||||
->toContain($shape)
|
||||
->toEndWith('bg-tertiary-container text-on-tertiary-container')
|
||||
->and(preg_replace('/bg-tertiary-container text-on-tertiary-container$/', '', $class[1]))->not->toMatch('/(^|\s)(bg|text|border)-/');
|
||||
it('paints each colour from its pair of roles, and neutral without a hue', function (string $color, array $roles) {
|
||||
expect(ComponentStylesheet::read('badge')->declarations("[data-md-badge][data-md-color='{$color}']"))->toBe([
|
||||
'--md-badge-color' => "var(--md-sys-color-{$roles[0]})",
|
||||
'--md-badge-on-color' => "var(--md-sys-color-{$roles[1]})",
|
||||
'--md-badge-container' => "var(--md-sys-color-{$roles[2]})",
|
||||
'--md-badge-on-container' => "var(--md-sys-color-{$roles[3]})",
|
||||
]);
|
||||
})->with([
|
||||
'dot' => ['<x-badge color="plain" class="bg-tertiary-container text-on-tertiary-container" />', 'size-1.5 rounded-corner-full'],
|
||||
'count' => ['<x-badge value="3" color="plain" class="bg-tertiary-container text-on-tertiary-container" />', 'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums'],
|
||||
'tonal' => ['<x-badge value="Run" color="plain" tonal class="bg-tertiary-container text-on-tertiary-container" />', 'h-6 gap-1 rounded-corner-sm px-2 type-label-md'],
|
||||
'outline' => ['<x-badge value="Run" color="plain" outline class="bg-tertiary-container text-on-tertiary-container" />', 'h-6 gap-1 rounded-corner-sm px-2 type-label-md border'],
|
||||
'primary' => ['primary', ['primary', 'on-primary', 'primary-container', 'on-primary-container']],
|
||||
'secondary' => ['secondary', ['secondary', 'on-secondary', 'secondary-container', 'on-secondary-container']],
|
||||
'tertiary' => ['tertiary', ['tertiary', 'on-tertiary', 'tertiary-container', 'on-tertiary-container']],
|
||||
'success' => ['success', ['success', 'on-success', 'success-container', 'on-success-container']],
|
||||
'warning' => ['warning', ['warning', 'on-warning', 'warning-container', 'on-warning-container']],
|
||||
'info' => ['info', ['info', 'on-info', 'info-container', 'on-info-container']],
|
||||
'neutral' => ['neutral', ['on-surface-variant', 'surface', 'surface-container-high', 'on-surface-variant']],
|
||||
]);
|
||||
|
||||
it('keeps its default colours, and falls back to error on a colour it does not know', function () {
|
||||
expect((string) $this->blade('<x-badge value="4" />'))
|
||||
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums bg-error text-on-error"')
|
||||
->and((string) $this->blade('<x-badge value="Active" tonal />'))
|
||||
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md bg-error-container text-on-error-container"')
|
||||
->and((string) $this->blade('<x-badge value="Pro" outline color="success" />'))
|
||||
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md border border-outline text-on-surface-variant"')
|
||||
->and((string) $this->blade('<x-badge value="3" color="sport-run" />'))->toContain('bg-error text-on-error');
|
||||
it('is error by default, and falls back to error on a colour it does not know', function () {
|
||||
$css = ComponentStylesheet::read('badge');
|
||||
|
||||
expect($css->declarations('[data-md-badge]'))->toMatchArray([
|
||||
'--md-badge-color' => 'var(--md-sys-color-error)',
|
||||
'--md-badge-on-color' => 'var(--md-sys-color-on-error)',
|
||||
])
|
||||
->and($css->declarations("[data-md-badge]:not([data-md-color='plain'])"))->toBe([
|
||||
'background-color' => 'var(--md-badge-color)',
|
||||
'color' => 'var(--md-badge-on-color)',
|
||||
])
|
||||
->and(badgeAttributes('<x-badge value="3" color="sport-run" />'))->toMatchArray(['data-md-color' => 'error']);
|
||||
});
|
||||
|
||||
it('draws a solid status label in the colour itself, spoken like any label', function () {
|
||||
$html = (string) $this->blade('<x-badge value="Built in" solid color="primary" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('bg-primary text-on-primary')
|
||||
->toContain('h-6 gap-1 rounded-corner-sm px-2 type-label-md')
|
||||
->not->toContain('aria-hidden')
|
||||
->toContain('>Built in<')
|
||||
->and((string) $this->blade('<x-badge value="Draft" solid color="neutral" />'))
|
||||
->toContain('bg-on-surface-variant text-surface')
|
||||
->and((string) $this->blade('<x-badge solid />'))
|
||||
->toContain('size-1.5');
|
||||
it('leaves a plain badge, and every colour, to the caller\'s CSS', function () {
|
||||
// `plain` matches none of the colour rules; a caller's class lands on the root untouched.
|
||||
expect(badgeAttributes('<x-badge value="Run" color="plain" tonal class="bg-tertiary-container text-on-tertiary-container" />'))
|
||||
->toMatchArray(['data-md-color' => 'plain', 'data-md-variant' => 'tonal', 'class' => 'bg-tertiary-container text-on-tertiary-container']);
|
||||
});
|
||||
|
||||
@@ -2,46 +2,73 @@
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('groups buttons with the spacing of their size', function () {
|
||||
expect((string) $this->blade('<x-button-group label="Views" size="md"><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('role="group"')
|
||||
->toContain('aria-label="Views"')
|
||||
->toContain('data-button-group="standard"')
|
||||
->toContain('data-size="md"')
|
||||
->toContain('gap-2')
|
||||
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('gap-3');
|
||||
it('groups buttons with the spacing of their size', function (string $size, string $gap) {
|
||||
expect((string) $this->blade("<x-button-group label=\"Views\" size=\"{$size}\"><x-button label=\"Day\" /></x-button-group>"))
|
||||
->toMatch("/<div\\s+role=\"group\"\\s+aria-label=\"Views\"\\s+data-md-button-group=\"standard\"\\s+data-md-size=\"{$size}\"\\s+data-md-shape=\"round\"/")
|
||||
->not->toContain('class=')
|
||||
->and(ComponentStylesheet::read('button-group')->declarations(in_array($size, ['xs', 'sm'], true)
|
||||
? "[data-md-button-group='standard'][data-md-size='{$size}']"
|
||||
: "[data-md-button-group='standard']"))->toBe(['gap' => $gap]);
|
||||
})->with([
|
||||
'xs' => ['xs', '18px'],
|
||||
'sm' => ['sm', '12px'],
|
||||
'md' => ['md', 'var(--md-sys-measurement-space100)'],
|
||||
'lg' => ['lg', 'var(--md-sys-measurement-space100)'],
|
||||
'xl' => ['xl', 'var(--md-sys-measurement-space100)'],
|
||||
]);
|
||||
|
||||
it('connects buttons 2px apart, with M3\'s inner corners', function () {
|
||||
$css = ComponentStylesheet::read('button-group');
|
||||
|
||||
expect((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))->toContain('data-md-button-group="connected"')
|
||||
->and($css->declarations("[data-md-button-group='connected']"))->toBe(['gap' => 'var(--md-sys-measurement-space25)'])
|
||||
->and($css->declarations("[data-md-button-group='connected'] > *"))->toMatchArray([
|
||||
'--md-button-group-corner' => 'var(--md-button-group-inner)',
|
||||
'border-start-start-radius' => 'var(--md-button-group-corner)',
|
||||
])
|
||||
->and($css->declarations("[data-md-button-group='connected'] > :active"))->toBe(['--md-button-group-corner' => 'var(--md-button-group-inner-pressed)'])
|
||||
->and($css->declarations("[data-md-button-group='connected'] > :first-child"))->toBe([
|
||||
'border-start-start-radius' => 'var(--md-button-group-outer)',
|
||||
'border-end-start-radius' => 'var(--md-button-group-outer)',
|
||||
])
|
||||
// Half the height, never 9999px, which would scale the small inner corners away.
|
||||
->and($css->declarations("[data-md-button-group][data-md-size='xl']"))->toMatchArray(['--md-button-group-full' => '68px', '--md-button-group-inner' => 'var(--md-sys-shape-corner-lg-increased)']);
|
||||
});
|
||||
|
||||
it('connects buttons 2px apart', function () {
|
||||
expect((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('gap-0.5');
|
||||
});
|
||||
|
||||
it('squares a group, and a choice drawn as one', function () {
|
||||
it('squares a group', function () {
|
||||
// M3 lists "Default shape | Round, square" as a button-group configuration; the shape covers
|
||||
// every button in the group, so it need not be written on each one.
|
||||
$css = ComponentStylesheet::read('button-group');
|
||||
|
||||
expect((string) $this->blade('<x-button-group shape="square"><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-shape="square"')
|
||||
->toContain('data-md-shape="square"')
|
||||
->and((string) $this->blade('<x-button-group connected shape="square"><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('data-shape="square"')
|
||||
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-shape="round"')
|
||||
->and((string) $this->blade('<x-group shape="square" name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
|
||||
->toContain('data-shape="square"')
|
||||
->and((string) $this->blade('<x-group name="x" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
|
||||
->toContain('data-shape="round"');
|
||||
->toContain('data-md-button-group="connected"')
|
||||
->toContain('data-md-shape="square"')
|
||||
->and((string) $this->blade('<x-button-group shape="oval"><x-button label="Day" /></x-button-group>'))
|
||||
->toContain('data-md-shape="round"')
|
||||
->and($css->declarations("[data-md-button-group='standard'][data-md-shape='square'] > *"))->toBe(['border-radius' => 'var(--md-button-group-square)'])
|
||||
->and($css->declarations("[data-md-button-group='standard'][data-md-shape='square'] > [aria-pressed='true']"))->toBe(['border-radius' => 'var(--md-button-group-full)']);
|
||||
});
|
||||
|
||||
it('widens a pressed label button while its neighbours give way', function () {
|
||||
$css = ComponentStylesheet::read('button-group');
|
||||
|
||||
expect($css->declarations("[data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true'])"))
|
||||
->toBe(['padding-inline' => 'calc(var(--md-button-group-pad) + var(--md-button-group-grow))'])
|
||||
->and($css->declarations("[data-md-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-md-icon-button])"))
|
||||
->toBe(['padding-inline-start' => 'calc(var(--md-button-group-pad) - var(--md-button-group-grow))'])
|
||||
->and($css->declarations("[data-md-button-group][data-md-size='lg']"))->toMatchArray(['--md-button-group-pad' => 'var(--md-sys-measurement-space600)', '--md-button-group-grow' => '16px']);
|
||||
});
|
||||
|
||||
it('takes over aria-pressed for a group that carries a selection', function () {
|
||||
$html = (string) $this->blade('<x-button-group connected selection="single" required label="View"><x-button label="Day" value="day" :selected="true" /></x-button-group>');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-selection="single"')
|
||||
->toContain('data-selection-required')
|
||||
->toContain('data-md-selection="single"')
|
||||
->toContain('data-md-selection-required')
|
||||
->toContain('multiple: false')
|
||||
->toContain('required: true')
|
||||
->toContain('value: null')
|
||||
@@ -53,7 +80,7 @@ it('takes over aria-pressed for a group that carries a selection', function () {
|
||||
->toContain('required: false')
|
||||
->toContain('value: []')
|
||||
->and((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
|
||||
->not->toContain('data-selection')
|
||||
->not->toContain('data-md-selection')
|
||||
->not->toContain('press($event)');
|
||||
});
|
||||
|
||||
@@ -78,99 +105,17 @@ it('entangles a selection group with the property it binds, and keeps wire:model
|
||||
$html = Livewire::test($component)->assertSet('view', 'week')->html();
|
||||
|
||||
expect($html)
|
||||
->toContain('data-selection="single"')
|
||||
->toContain('data-md-selection="single"')
|
||||
->toMatch('/value: window\.Livewire\.find\(/')
|
||||
->and(substr_count($html, 'wire:model.live="view"'))->toBe(0);
|
||||
});
|
||||
|
||||
it('never wraps a group onto a second line', function () {
|
||||
expect((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
|
||||
->not->toContain('flex-wrap');
|
||||
expect(ComponentStylesheet::read('button-group')->declarations('[data-md-button-group]'))->toMatchArray(['display' => 'inline-flex'])
|
||||
->not->toHaveKey('flex-wrap');
|
||||
});
|
||||
|
||||
it('marks icon buttons, which keep their width when a group is pressed', function () {
|
||||
expect((string) $this->blade('<x-button icon="format_bold" aria-label="Bold" />'))->toContain('data-icon-button')
|
||||
->and((string) $this->blade('<x-button label="Bold" />'))->not->toContain('data-icon-button');
|
||||
});
|
||||
|
||||
it('draws a choice as connected radios', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-group label="Theme" name="theme" x-model="theme" :options="[
|
||||
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
|
||||
['id' => 'dark', 'name' => 'Dark', 'disabled' => true],
|
||||
]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Theme</legend>')
|
||||
->toContain('data-button-group="connected"')
|
||||
->toContain('type="radio"')
|
||||
->toContain('name="theme"')
|
||||
->toContain('x-model="theme"')
|
||||
->toContain('value="light"')
|
||||
->toContain('has-checked:bg-secondary has-checked:text-on-secondary')
|
||||
->toContain('disabled')
|
||||
->and(substr_count($html, '<svg'))->toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a 48px target and a 48px minimum width on the smallest connected segments', function () {
|
||||
$options = [['id' => 'a', 'name' => 'A']];
|
||||
|
||||
expect((string) $this->blade('<x-group size="sm" name="x" :$options />', ['options' => $options]))
|
||||
->toContain('touch-target min-w-12')
|
||||
->and((string) $this->blade('<x-group size="md" name="x" :$options />', ['options' => $options]))
|
||||
->not->toContain('touch-target')
|
||||
->toContain('min-w-0');
|
||||
});
|
||||
|
||||
it('draws several choices as checkboxes', function () {
|
||||
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('name="days[]"')
|
||||
->toContain('has-checked:bg-inverse-surface');
|
||||
});
|
||||
|
||||
it('binds to a Livewire property and shows its validation message', function () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
public string $theme = 'dark';
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate(['theme' => 'in:light'], ['theme.in' => 'Pick light.']);
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-group wire:model="theme" hint="How it looks" :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark']]" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
};
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSeeHtml('wire:model="theme"')
|
||||
->assertSee('How it looks')
|
||||
->set('theme', 'light')
|
||||
->assertSet('theme', 'light')
|
||||
->set('theme', 'dark')
|
||||
->call('save')
|
||||
->assertSee('Pick light.')
|
||||
->assertDontSee('How it looks');
|
||||
});
|
||||
|
||||
it('adds hint-class to the hint, which a validation message still replaces', function () {
|
||||
$options = [['id' => 'flat', 'name' => 'Flat'], ['id' => 'hilly', 'name' => 'Hilly']];
|
||||
|
||||
expect((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
|
||||
->toContain('<p class="mt-1 type-body-sm [:where(&)]:text-on-surface-variant text-warning">No elevation data here</p>')
|
||||
->and((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" :$options />', ['options' => $options]))
|
||||
->toContain('<p class="mt-1 type-body-sm text-on-surface-variant">No elevation data here</p>');
|
||||
|
||||
expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
|
||||
->toContain('<p class="mt-1 type-body-sm text-error">Pick a terrain.</p>')
|
||||
->not->toContain('No elevation data here')
|
||||
->not->toContain('text-warning');
|
||||
expect((string) $this->blade('<x-button icon="format_bold" aria-label="Bold" />'))->toContain('data-md-icon-button')
|
||||
->and((string) $this->blade('<x-button label="Bold" />'))->not->toContain('data-md-icon-button');
|
||||
});
|
||||
|
||||
@@ -1,70 +1,137 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* The classes on the rendered button element.
|
||||
*/
|
||||
function buttonClasses(string $blade): string
|
||||
{
|
||||
preg_match('/<(?:button|a)\b[^>]*\bclass="([^"]*)"/', (string) test()->blade($blade), $matches);
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
return $matches[1] ?? '';
|
||||
/**
|
||||
* The attributes of the rendered button or link element, by name.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function buttonAttributes(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('is a text button in the primary colour by default', function () {
|
||||
expect(buttonClasses('<x-button label="Cancel" />'))
|
||||
->toContain('text-primary')
|
||||
->not->toContain('bg-primary');
|
||||
function buttonCss(): ComponentStylesheet
|
||||
{
|
||||
return ComponentStylesheet::read('button');
|
||||
}
|
||||
|
||||
it('is a text button in the primary colour by default, drawn by its stylesheet', function () {
|
||||
expect(buttonAttributes('<x-button label="Cancel" />'))->toBe([
|
||||
'data-md-button' => 'data-md-button',
|
||||
'data-md-variant' => 'text',
|
||||
'data-md-color' => 'primary',
|
||||
'data-md-size' => 'sm',
|
||||
'data-md-shape' => 'round',
|
||||
'type' => 'button',
|
||||
])
|
||||
->and(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
|
||||
'--md-button-container' => 'transparent',
|
||||
'--md-button-label' => 'var(--md-button-color)',
|
||||
'--md-button-color' => 'var(--md-sys-color-primary)',
|
||||
'background-color' => 'var(--md-button-container)',
|
||||
'color' => 'var(--md-button-label)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws each M3 variant', function (string $variant, string $classes) {
|
||||
expect(buttonClasses("<x-button label=\"Go\" variant=\"{$variant}\" />"))->toContain($classes);
|
||||
it('draws each M3 variant', function (string $variant, array $declarations) {
|
||||
expect(buttonAttributes("<x-button label=\"Go\" variant=\"{$variant}\" />"))->toMatchArray(['data-md-variant' => $variant])
|
||||
->and(buttonCss()->declarations("[data-md-button][data-md-variant='{$variant}']"))->toMatchArray($declarations);
|
||||
})->with([
|
||||
'filled' => ['filled', 'bg-primary text-on-primary'],
|
||||
'tonal' => ['tonal', 'bg-secondary-container text-on-secondary-container'],
|
||||
'outlined' => ['outlined', 'border-outline-variant text-on-surface-variant'],
|
||||
'elevated' => ['elevated', 'bg-surface-container-low text-primary'],
|
||||
'text' => ['text', 'text-primary'],
|
||||
'filled' => ['filled', ['--md-button-container' => 'var(--md-button-color)', '--md-button-label' => 'var(--md-button-on-color)']],
|
||||
'tonal' => ['tonal', ['--md-button-container' => 'var(--md-button-tone)', '--md-button-label' => 'var(--md-button-on-tone)']],
|
||||
'outlined' => ['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)']],
|
||||
'elevated' => ['elevated', ['--md-button-container' => 'var(--md-sys-color-surface-container-low)', '--md-button-elevation' => 'var(--md-sys-elevation-1)']],
|
||||
]);
|
||||
|
||||
it('draws a variant in another colour role', function () {
|
||||
expect(buttonClasses('<x-button label="Go" variant="filled" color="tertiary" />'))->toContain('bg-tertiary text-on-tertiary')
|
||||
->and(buttonClasses('<x-button label="Go" variant="tonal" color="error" />'))->toContain('bg-error-container text-on-error-container')
|
||||
->and(buttonClasses('<x-button label="Go" variant="outlined" tone="success" />'))->toContain('text-success');
|
||||
it('draws primary\'s tonal button in secondary-container and its quiet ink in on-surface-variant', function () {
|
||||
expect(buttonCss()->declarations('[data-md-button]'))->toMatchArray([
|
||||
'--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-quiet' => 'var(--md-sys-color-on-surface-variant)',
|
||||
])
|
||||
->and(buttonCss()->declarations("[data-md-button]:not([data-md-color='primary'])"))->toBe([
|
||||
'--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)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws a variant in another colour role', function (string $color) {
|
||||
expect(buttonCss()->declarations("[data-md-button][data-md-color='{$color}']"))->toBe([
|
||||
'--md-button-color' => "var(--md-sys-color-{$color})",
|
||||
'--md-button-on-color' => "var(--md-sys-color-on-{$color})",
|
||||
'--md-button-tone' => "var(--md-sys-color-{$color}-container)",
|
||||
'--md-button-on-tone' => "var(--md-sys-color-on-{$color}-container)",
|
||||
]);
|
||||
})->with(['secondary', 'tertiary', 'error', 'success', 'warning', 'info']);
|
||||
|
||||
it('keeps the maryUI and ReStride shorthands', function () {
|
||||
expect(buttonClasses('<x-button label="Save" primary />'))->toContain('bg-primary text-on-primary')
|
||||
->and(buttonClasses('<x-button label="Delete" danger />'))->toContain('bg-error text-on-error')
|
||||
->and(buttonClasses('<x-button label="Take down" caution />'))->toContain('bg-warning text-on-warning');
|
||||
expect(buttonAttributes('<x-button label="Save" primary />'))->toMatchArray(['data-md-variant' => 'filled', 'data-md-color' => 'primary'])
|
||||
->and(buttonAttributes('<x-button label="Delete" danger />'))->toMatchArray(['data-md-variant' => 'filled', 'data-md-color' => 'error'])
|
||||
->and(buttonAttributes('<x-button label="Take down" caution />'))->toMatchArray(['data-md-variant' => 'filled', 'data-md-color' => 'warning'])
|
||||
->and(buttonAttributes('<x-button label="Go" variant="outlined" tone="success" />'))->toMatchArray(['data-md-color' => 'success']);
|
||||
});
|
||||
|
||||
it('falls back to the defaults for values it does not know', function () {
|
||||
expect(buttonClasses('<x-button label="Go" variant="glass" color="purple" size="huge" />'))
|
||||
->toContain('text-primary')
|
||||
->toContain('h-10 gap-2 px-4 type-label-lg');
|
||||
expect(buttonAttributes('<x-button label="Go" variant="glass" color="purple" size="huge" shape="blob" />'))
|
||||
->toMatchArray(['data-md-variant' => 'text', 'data-md-color' => 'primary', 'data-md-size' => 'sm', 'data-md-shape' => 'round']);
|
||||
});
|
||||
|
||||
it('sizes a label button on Expressive\'s scale', function (string $size, string $classes, string $icon) {
|
||||
it('sizes a label button on Expressive\'s scale', function (string $size, array $geometry, string $type, int $icon) {
|
||||
$html = (string) $this->blade("<x-button label=\"Upload\" icon=\"upload\" size=\"{$size}\" />");
|
||||
|
||||
expect($html)->toContain($classes)->toContain($icon);
|
||||
expect($html)->toContain("data-md-size=\"{$size}\"")->toContain("--md-icon-size: {$icon}px")
|
||||
->and(buttonCss()->declarations("[data-md-button][data-md-size='{$size}']"))->toMatchArray($geometry)
|
||||
->and(buttonCss()->declarations(in_array($size, ['xs', 'sm'], true)
|
||||
? "[data-md-button]:not([data-md-icon-button]):is([data-md-size='xs'], [data-md-size='sm'])"
|
||||
: "[data-md-button]:not([data-md-icon-button])[data-md-size='{$size}']"))->toMatchArray(['font' => "var(--md-sys-typescale-{$type})"]);
|
||||
})->with([
|
||||
'xs' => ['xs', 'h-8 gap-2 px-4 type-label-lg', 'size-5'],
|
||||
'sm' => ['sm', 'h-10 gap-2 px-4 type-label-lg', 'size-5'],
|
||||
'md' => ['md', 'h-14 gap-2 px-6 type-title-md', 'size-6'],
|
||||
'lg' => ['lg', 'h-24 gap-3 px-12 type-headline-sm', 'size-8'],
|
||||
'xl' => ['xl', 'h-34 gap-4 px-16 type-headline-lg', 'size-10'],
|
||||
'xs' => ['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'], 'label-lg', 20],
|
||||
'sm' => ['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'], 'label-lg', 20],
|
||||
'md' => ['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'], 'title-md', 24],
|
||||
'lg' => ['lg', ['--md-button-height' => '96px', '--md-button-padding' => 'var(--md-sys-measurement-space600)', '--md-button-gap' => '12px', '--md-button-icon' => '32px'], 'headline-sm', 32],
|
||||
'xl' => ['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'], 'headline-lg', 40],
|
||||
]);
|
||||
|
||||
it('rounds by default, squares on request, and squares off further while pressed', function () {
|
||||
expect(buttonClasses('<x-button label="Go" size="md" />'))->toContain('rounded-corner-full')->toContain('active:rounded-corner-md')
|
||||
->and(buttonClasses('<x-button label="Go" size="md" shape="square" />'))->toContain('rounded-corner-lg')
|
||||
->and(buttonClasses('<x-button label="Go" size="xl" shape="square" />'))->toContain('rounded-corner-xl')->toContain('active:rounded-corner-lg');
|
||||
it('rounds by default, squares on request, and squares off further while pressed — without specificity', function () {
|
||||
expect(buttonAttributes('<x-button label="Go" size="md" shape="square" />'))->toMatchArray(['data-md-shape' => 'square'])
|
||||
->and(buttonCss()->declarations(':where([data-md-button])'))->toBe(['border-radius' => 'var(--md-sys-shape-corner-full)'])
|
||||
->and(buttonCss()->declarations(":where([data-md-button]):where([data-md-shape='square'], [data-md-selected='true'])"))->toBe(['border-radius' => 'var(--md-button-square)'])
|
||||
->and(buttonCss()->declarations(':where([data-md-button]):where(:active)'))->toBe(['border-radius' => 'var(--md-button-pressed)'])
|
||||
->and(buttonCss()->declarations("[data-md-button][data-md-size='md']"))->toMatchArray(['--md-button-square' => 'var(--md-sys-shape-corner-lg)', '--md-button-pressed' => 'var(--md-sys-shape-corner-md)'])
|
||||
->and(buttonCss()->declarations("[data-md-button][data-md-size='xl']"))->toMatchArray(['--md-button-square' => 'var(--md-sys-shape-corner-xl)', '--md-button-pressed' => 'var(--md-sys-shape-corner-lg)']);
|
||||
});
|
||||
|
||||
it('reaches a 48px touch target below the medium size', function () {
|
||||
expect(buttonClasses('<x-button label="Go" size="sm" />'))->toContain('touch-target')
|
||||
->and(buttonClasses('<x-button label="Go" size="md" />'))->not->toContain('touch-target');
|
||||
expect(buttonCss()->declarations("[data-md-button]:is([data-md-size='xs'], [data-md-size='sm'])::after"))->toMatchArray([
|
||||
'content' => "''",
|
||||
'min-width' => 'var(--md-sys-measurement-space600)',
|
||||
'min-height' => 'var(--md-sys-measurement-space600)',
|
||||
])
|
||||
->and(buttonCss()->has("[data-md-button]:is([data-md-size='md'])::after"))->toBeFalse();
|
||||
});
|
||||
|
||||
it('draws M3\'s state layer and focus ring on the button itself', function () {
|
||||
$css = buttonCss();
|
||||
|
||||
expect($css->declarations('[data-md-button]::before'))->toMatchArray(['background-color' => 'currentColor', 'opacity' => '0', 'border-radius' => 'inherit'])
|
||||
->and($css->declarations('[data-md-button]:hover::before', ['@media (hover: hover)']))->toBe(['opacity' => 'var(--md-sys-state-hover-state-layer-opacity)'])
|
||||
->and($css->declarations('[data-md-button]:focus-visible::before'))->toBe(['opacity' => 'var(--md-sys-state-focus-state-layer-opacity)'])
|
||||
->and($css->declarations('[data-md-button]:active::before'))->toBe(['opacity' => 'var(--md-sys-state-pressed-state-layer-opacity)'])
|
||||
->and($css->declarations('[data-md-button]:focus-visible'))->toBe(['outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px']);
|
||||
});
|
||||
|
||||
it('moves shape on the spatial spring and colour on the effects spring', function () {
|
||||
$declarations = buttonCss()->declarations('[data-md-button]');
|
||||
|
||||
expect($declarations['transition-property'])->toBe('border-radius, padding, margin, bottom, background-color, color, box-shadow')
|
||||
->and($declarations['transition-timing-function'])->toBe('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)');
|
||||
});
|
||||
|
||||
it('fills a default icon button\'s glyph, and draws a 20px one from the 20px cut', function () {
|
||||
@@ -87,31 +154,39 @@ it('fills a default icon button\'s glyph, and draws a 20px one from the 20px cut
|
||||
it('is an icon button named by its tooltip when it has no label', function () {
|
||||
$html = (string) $this->blade('<x-button icon="close" tooltip="Close menu" />');
|
||||
|
||||
expect($html)
|
||||
->toContain('aria-label="Close menu"')
|
||||
->toContain('size-10')
|
||||
->toContain('text-on-surface-variant')
|
||||
->toContain('popover="manual"')
|
||||
->toContain('aria-hidden="true"');
|
||||
expect(buttonAttributes('<x-button icon="close" tooltip="Close menu" />'))
|
||||
->toMatchArray(['data-md-icon-button' => 'data-md-icon-button', 'data-md-width' => 'default', 'aria-label' => 'Close menu'])
|
||||
->and($html)->toContain('--md-icon-size: 24px')->toContain('popover="manual"')->toContain('aria-hidden="true"')
|
||||
// M3's standard icon button: on-surface-variant, unless selected.
|
||||
->and(buttonCss()->declarations("[data-md-button][data-md-variant='text'][data-md-icon-button]:not([data-md-selected='true'])"))->toBe(['--md-button-label' => 'var(--md-button-quiet)']);
|
||||
});
|
||||
|
||||
it('sizes an icon button by width', function () {
|
||||
expect(buttonClasses('<x-button icon="share" label="" width="wide" aria-label="Share" />'))->toContain('h-10 w-13')
|
||||
->and(buttonClasses('<x-button icon="share" size="xl" width="narrow" aria-label="Share" />'))->toContain('h-34 w-26');
|
||||
expect(buttonAttributes('<x-button icon="share" label="" width="wide" aria-label="Share" />'))->toMatchArray(['data-md-width' => 'wide'])
|
||||
->and(buttonAttributes('<x-button icon="share" size="xl" width="narrow" aria-label="Share" />'))->toMatchArray(['data-md-width' => 'narrow', 'data-md-size' => 'xl'])
|
||||
->and(buttonAttributes('<x-button label="Share" width="wide" />'))->not->toHaveKey('data-md-width')
|
||||
->and(buttonCss()->declarations("[data-md-button][data-md-size='sm']"))->toMatchArray(['--md-button-narrow' => '32px', '--md-button-default' => '40px', '--md-button-wide' => '52px'])
|
||||
->and(buttonCss()->declarations("[data-md-button][data-md-size='xl']"))->toMatchArray(['--md-button-narrow' => '104px', '--md-button-default' => '136px', '--md-button-wide' => '184px'])
|
||||
->and(buttonCss()->declarations("[data-md-button][data-md-icon-button][data-md-width='wide']"))->toBe(['--md-button-width' => 'var(--md-button-wide)']);
|
||||
});
|
||||
|
||||
it('toggles with aria-pressed and M3\'s selected colours and shape', function () {
|
||||
expect((string) $this->blade('<x-button label="Bold" variant="filled" :selected="true" />'))->toContain('aria-pressed="true"')
|
||||
->and(buttonClasses('<x-button label="Bold" variant="filled" :selected="true" />'))->toContain('bg-primary')->toContain('rounded-corner-md')
|
||||
->and(buttonClasses('<x-button label="Bold" variant="filled" :selected="false" />'))->toContain('bg-surface-container text-on-surface-variant')->toContain('rounded-corner-full')
|
||||
->and(buttonClasses('<x-button label="Bold" variant="tonal" :selected="true" />'))->toContain('bg-secondary text-on-secondary')
|
||||
->and(buttonClasses('<x-button label="Bold" variant="outlined" :selected="true" />'))->toContain('bg-inverse-surface text-inverse-on-surface');
|
||||
$css = buttonCss();
|
||||
|
||||
expect(buttonAttributes('<x-button label="Bold" variant="filled" :selected="true" />'))->toMatchArray(['aria-pressed' => 'true', 'data-md-selected' => 'true'])
|
||||
->and(buttonAttributes('<x-button label="Bold" variant="filled" :selected="false" />'))->toMatchArray(['aria-pressed' => 'false', 'data-md-selected' => 'false'])
|
||||
->and(buttonAttributes('<x-button label="Bold" variant="filled" />'))->not->toHaveKey('data-md-selected')
|
||||
->and($css->declarations("[data-md-button][data-md-variant='filled'][data-md-selected='false']"))->toBe(['--md-button-container' => 'var(--md-sys-color-surface-container)', '--md-button-label' => 'var(--md-sys-color-on-surface-variant)'])
|
||||
->and($css->declarations("[data-md-button][data-md-variant='tonal'][data-md-selected='true']"))->toBe(['--md-button-container' => 'var(--md-button-tone-selected)', '--md-button-label' => 'var(--md-button-on-tone-selected)'])
|
||||
->and($css->declarations("[data-md-button][data-md-variant='outlined'][data-md-selected='true']"))->toBe(['--md-button-container' => 'var(--md-sys-color-inverse-surface)', '--md-button-label' => 'var(--md-sys-color-inverse-on-surface)', '--md-button-outline' => 'transparent'])
|
||||
// Text buttons are not toggles in M3: a selected one takes the tonal container.
|
||||
->and($css->declarations("[data-md-button][data-md-variant='text']:not([data-md-icon-button])[data-md-selected='true']"))->toBe(['--md-button-container' => 'var(--md-button-tone)', '--md-button-label' => 'var(--md-button-on-tone)']);
|
||||
});
|
||||
|
||||
it('squares a selected round icon button and rounds a selected square one', function () {
|
||||
expect(buttonClasses('<x-button icon="favorite" aria-label="Like" variant="tonal" :selected="true" />'))->toContain('rounded-corner-md')
|
||||
->and(buttonClasses('<x-button icon="favorite" aria-label="Like" variant="tonal" shape="square" :selected="true" />'))->toContain('rounded-corner-full')
|
||||
->and((string) $this->blade('<x-button icon="favorite" aria-label="Like" :selected="true" />'))->toContain('text-primary');
|
||||
expect(buttonCss()->declarations(":where([data-md-button]):where([data-md-icon-button][data-md-shape='square'][data-md-selected='true'])"))->toBe(['border-radius' => 'var(--md-sys-shape-corner-full)'])
|
||||
->and(buttonAttributes('<x-button icon="favorite" aria-label="Like" variant="tonal" shape="square" :selected="true" />'))
|
||||
->toMatchArray(['data-md-icon-button' => 'data-md-icon-button', 'data-md-shape' => 'square', 'data-md-selected' => 'true']);
|
||||
});
|
||||
|
||||
it('navigates inside the app, and leaves it for an external link', function () {
|
||||
@@ -128,21 +203,31 @@ it('navigates inside the app, and leaves it for an external link', function () {
|
||||
});
|
||||
|
||||
it('disables a button, and a link as far as a link can be', function () {
|
||||
$this->blade('<x-button label="Save" variant="filled" disabled />')
|
||||
->assertSee('disabled="disabled"', false)
|
||||
->assertSee('disabled:bg-on-surface/10', false);
|
||||
|
||||
$this->blade('<x-button label="Shares" link="/admin/shares" disabled />')
|
||||
->assertSee('aria-disabled="true"', false)
|
||||
->assertSee('tabindex="-1"', false);
|
||||
expect(buttonAttributes('<x-button label="Save" variant="filled" disabled />'))->toMatchArray(['disabled' => 'disabled'])
|
||||
->and(buttonAttributes('<x-button label="Shares" link="/admin/shares" disabled />'))->toMatchArray(['aria-disabled' => 'true', 'tabindex' => '-1'])
|
||||
->and(buttonCss()->declarations("[data-md-button]:is(:disabled, [aria-disabled='true'])"))->toBe([
|
||||
'box-shadow' => 'none',
|
||||
'color' => 'color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)',
|
||||
])
|
||||
->and(buttonCss()->declarations("[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'])"))
|
||||
->toBe(['background-color' => 'color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent)'])
|
||||
->and(buttonCss()->declarations("[data-md-button]:is(:disabled, [aria-disabled='true'])::before"))->toBe(['display' => 'none'])
|
||||
->and(buttonCss()->declarations("[data-md-button][aria-disabled='true']"))->toBe(['pointer-events' => 'none']);
|
||||
});
|
||||
|
||||
it('shows the loading indicator while its own action runs, in the button\'s own ink', function () {
|
||||
$this->blade('<x-button label="Save" wire:click="save" spinner />')
|
||||
->assertSee('wire:loading.attr="disabled"', false)
|
||||
->assertSee('wire:target="save"', false)
|
||||
->assertSee('size-5 text-current', false)
|
||||
->assertDontSee('text-primary"', false);
|
||||
$html = (string) $this->blade('<x-button label="Save" wire:click="save" spinner />');
|
||||
|
||||
expect($html)
|
||||
->toContain('wire:loading.attr="disabled"')
|
||||
->toContain('wire:target="save"')
|
||||
->toMatch('/<span wire:loading.flex wire:target="save" data-md-button-spinner>\s*<span data-md-loading="data-md-loading" aria-hidden="true">/')
|
||||
->toMatch('/<span data-md-button-icon\s+wire:loading.remove wire:target="save"\s*>|<span data-md-button-label/')
|
||||
->and(buttonCss()->declarations('[data-md-button-spinner] > [data-md-loading]'))->toBe([
|
||||
'inline-size' => 'var(--md-button-icon)',
|
||||
'block-size' => 'var(--md-button-icon)',
|
||||
'color' => 'currentColor',
|
||||
]);
|
||||
|
||||
$this->blade('<x-button label="Save" wire:click="save" spinner="upload" />')
|
||||
->assertSee('wire:target="upload"', false);
|
||||
@@ -150,7 +235,9 @@ it('shows the loading indicator while its own action runs, in the button\'s own
|
||||
|
||||
it('hides a responsive label below expanded', function () {
|
||||
$this->blade('<x-button label="New share" icon="add" responsive />')
|
||||
->assertSee('<span class="max-expanded:hidden">New share</span>', false);
|
||||
->assertSee('<span data-md-button-label data-md-responsive >New share</span>', false);
|
||||
|
||||
expect(buttonCss()->declarations('[data-md-button] > [data-md-responsive]', ['@media (width < 840px)']))->toBe(['display' => 'none']);
|
||||
});
|
||||
|
||||
it('anchors its tooltip to itself', function () {
|
||||
@@ -160,17 +247,27 @@ it('anchors its tooltip to itself', function () {
|
||||
|
||||
expect($anchor)->not->toBeEmpty()
|
||||
->and($html)->toContain("position-anchor: {$anchor[1]}")
|
||||
->toContain('[position-area:bottom]')
|
||||
->toContain('data-md-side="bottom"')
|
||||
->not->toContain('aria-label="Saves the draft"');
|
||||
});
|
||||
|
||||
it('is a FAB on a compact window and a filled button from medium, in one element', function () {
|
||||
$html = (string) $this->blade('<x-button label="New share" icon="add" fab />');
|
||||
$fab = buttonCss()->declarations('[data-md-button][data-md-compact-fab]', ['@media (width < 600px)']);
|
||||
|
||||
expect(substr_count($html, '<button'))->toBe(1)
|
||||
->and($html)->toContain('max-medium:fixed')->toContain('max-medium:bg-primary-container')->toContain('bg-primary text-on-primary')
|
||||
->and(buttonAttributes('<x-button label="New share" icon="add" fab />'))->toMatchArray(['data-md-compact-fab' => 'data-md-compact-fab', 'data-md-variant' => 'filled'])
|
||||
->and($fab)->toMatchArray([
|
||||
'position' => 'fixed',
|
||||
'inset-inline-end' => 'var(--md-sys-measurement-space200)',
|
||||
'--md-button-height' => '56px',
|
||||
'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)',
|
||||
])
|
||||
// M3 puts a snackbar above a FAB, never over one: the host publishes its height.
|
||||
->toContain('max-medium:bottom-[calc(var(--material-bottom-bar,0px)+var(--material-snackbar-height,0px)+1rem)]');
|
||||
->and($fab['bottom'])->toBe('calc(var(--material-bottom-bar, 0px) + var(--material-snackbar-height, 0px) + var(--md-sys-measurement-space200))');
|
||||
});
|
||||
|
||||
it('submits a form when asked', function () {
|
||||
@@ -181,6 +278,13 @@ it('submits a form when asked', function () {
|
||||
it('keeps aria-pressed off a selected link, which is not a toggle', function () {
|
||||
expect((string) $this->blade('<x-button label="Plans" link="/plans" :selected="true" />'))
|
||||
->not->toContain('aria-pressed')
|
||||
->toContain('data-md-selected="true"')
|
||||
->and((string) $this->blade('<x-button label="Bold" :selected="true" />'))
|
||||
->toContain('aria-pressed="true"');
|
||||
});
|
||||
|
||||
it('lands a caller\'s class and style on the button untouched', function () {
|
||||
expect(buttonAttributes('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />'))
|
||||
->toMatchArray(['class' => 'w-full'])
|
||||
->and(buttonAttributes('<x-button label="Save" class="w-full" style="margin: 0" tooltip="Save" />')['style'])->toStartWith('anchor-name: --material-button-')->toEndWith('margin: 0;');
|
||||
});
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
/**
|
||||
* The attributes of the rendered FAB, by name.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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('<x-fab icon="add" tooltip="New share" />'))
|
||||
->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('<x-fab icon="add" tooltip="New share" />'))->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('<x-fab icon="add" tooltip="New share" />'))->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("<x-fab icon=\"add\" size=\"{$size}\" aria-label=\"Add\" />"))->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("<x-fab icon=\"add\" size=\"{$size}\" aria-label=\"Add\" />"))->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('<x-fab-menu label="New" />'))->toContain($filled);
|
||||
});
|
||||
|
||||
it('extends with a label', function () {
|
||||
expect((string) $this->blade('<x-fab icon="upload" label="Upload" size="md" color="tertiary" variant="filled" />'))
|
||||
->toContain('h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg')
|
||||
->toContain('bg-tertiary text-on-tertiary')
|
||||
->toContain('<span>Upload</span>')
|
||||
->not->toContain('aria-label');
|
||||
it('extends with a label, in the colour itself when filled', function () {
|
||||
expect(fabAttributes('<x-fab icon="upload" label="Upload" size="md" color="tertiary" variant="filled" />'))
|
||||
->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('<x-fab icon="upload" label="Upload" />'))->toContain('<span>Upload</span>')
|
||||
->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("<x-fab icon=\"add\" label=\"New\" size=\"{$size}\" />"))->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('<x-fab icon="add" label="New share" size="lg" collapse-on-scroll />');
|
||||
$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('<span data-fab-label><span>New share</span></span>');
|
||||
->toContain('<span data-md-fab-label><span>New share</span></span>')
|
||||
->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('<x-fab icon="add" aria-label="New" collapse-on-scroll />'))->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('<x-fab icon="add" aria-label="New" />'))->toContain('data-fab');
|
||||
expect((string) $this->blade('<x-fab icon="add" aria-label="New" />'))->toContain('data-md-fab')->not->toContain('class=');
|
||||
});
|
||||
|
||||
it('cannot be disabled, because M3 says to remove a FAB instead', function () {
|
||||
$html = (string) $this->blade('<x-fab icon="add" aria-label="New" disabled />');
|
||||
|
||||
// `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('<x-fab icon="add" aria-label="New" disabled />'))->toContain('disabled="disabled"')
|
||||
->and(ComponentStylesheet::read('fab')->css)->not->toContain(':disabled');
|
||||
});
|
||||
|
||||
it('opens a FAB menu of end-aligned actions above it', function () {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('draws a choice as connected radios', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-group label="Theme" name="theme" x-model="theme" :options="[
|
||||
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
|
||||
['id' => 'dark', 'name' => 'Dark', 'disabled' => true],
|
||||
]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toMatch('/<fieldset data-md-group="data-md-group" data-md-size="sm" data-md-variant="tonal" data-md-shape="round">/')
|
||||
->toContain('<legend data-md-group-legend>Theme</legend>')
|
||||
->toContain('<div data-md-button-group="connected" data-md-size="sm" data-md-shape="round">')
|
||||
->toContain('<label data-md-group-segment>')
|
||||
->toContain('type="radio"')
|
||||
->toContain('name="theme"')
|
||||
->toContain('x-model="theme"')
|
||||
->toContain('value="light"')
|
||||
->toContain('disabled')
|
||||
->toContain('<span data-md-group-label>Light</span>')
|
||||
->not->toContain('class=')
|
||||
->and(substr_count($html, '<svg'))->toBe(1);
|
||||
});
|
||||
|
||||
it('squares a choice\'s ends, and still rounds the chosen segment', function () {
|
||||
$options = [['id' => 'a', 'name' => 'A']];
|
||||
|
||||
expect((string) $this->blade('<x-group shape="square" name="x" :$options />', ['options' => $options]))->toContain('data-md-button-group="connected" data-md-size="sm" data-md-shape="square"')
|
||||
->and((string) $this->blade('<x-group name="x" :$options />', ['options' => $options]))->toContain('data-md-shape="round"')
|
||||
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group][data-md-shape='square']"))->toBe(['--md-button-group-outer' => 'var(--md-button-group-inner)'])
|
||||
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group='connected'] > :is([aria-pressed='true'], :has(:checked))"))->toBe(['--md-button-group-corner' => 'var(--md-button-group-full)'])
|
||||
->and(ComponentStylesheet::read('group')->imports())->toBe(['./icon.css', './button-group.css']);
|
||||
});
|
||||
|
||||
it('keeps a 48px target and a 48px minimum width on the smallest connected segments', function () {
|
||||
$css = ComponentStylesheet::read('group');
|
||||
|
||||
expect($css->declarations("[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment]"))->toBe(['min-inline-size' => 'var(--md-sys-measurement-space600)'])
|
||||
->and($css->declarations("[data-md-group]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-group-segment]::after"))->toMatchArray([
|
||||
'min-width' => 'var(--md-sys-measurement-space600)',
|
||||
'min-height' => 'var(--md-sys-measurement-space600)',
|
||||
])
|
||||
->and($css->declarations('[data-md-group-segment]'))->toMatchArray(['min-inline-size' => '0'])
|
||||
->and(ComponentStylesheet::read('button-group')->declarations("[data-md-button-group='connected']:is([data-md-size='xs'], [data-md-size='sm']) > *"))->toBe(['min-inline-size' => 'var(--md-sys-measurement-space600)']);
|
||||
});
|
||||
|
||||
it('sizes its segments as M3\'s buttons', function (string $size, string $height, string $padding, string $type, int $icon) {
|
||||
$html = (string) $this->blade("<x-group size=\"{$size}\" name=\"x\" :options=\"[['id' => 'a', 'name' => 'A', 'icon' => 'home']]\" />");
|
||||
|
||||
expect($html)->toContain("data-md-size=\"{$size}\"")->toContain("--md-icon-size: {$icon}px")
|
||||
->and(ComponentStylesheet::read('group')->declarations("[data-md-group][data-md-size='{$size}'] [data-md-group-segment]"))->toMatchArray([
|
||||
'block-size' => $height,
|
||||
'padding-inline' => $padding,
|
||||
'font' => "var(--md-sys-typescale-{$type})",
|
||||
]);
|
||||
})->with([
|
||||
'xs' => ['xs', '32px', 'var(--md-sys-measurement-space200)', 'label-lg', 20],
|
||||
'sm' => ['sm', '40px', 'var(--md-sys-measurement-space200)', 'label-lg', 20],
|
||||
'md' => ['md', '56px', 'var(--md-sys-measurement-space300)', 'title-md', 24],
|
||||
'lg' => ['lg', '96px', 'var(--md-sys-measurement-space600)', 'headline-sm', 32],
|
||||
'xl' => ['xl', '136px', 'var(--md-sys-measurement-space800)', 'headline-lg', 40],
|
||||
]);
|
||||
|
||||
it('draws several choices as checkboxes, in the toggle button\'s colours', function () {
|
||||
$css = ComponentStylesheet::read('group');
|
||||
|
||||
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
|
||||
->toContain('type="checkbox"')
|
||||
->toContain('name="days[]"')
|
||||
->toContain('data-md-variant="outlined"')
|
||||
->toContain('data-md-multiple')
|
||||
->and($css->declarations('[data-md-group-segment]:has(:checked)'))->toBe(['--md-group-container' => 'var(--md-sys-color-secondary)', '--md-group-label' => 'var(--md-sys-color-on-secondary)'])
|
||||
->and($css->declarations("[data-md-group][data-md-variant='filled'] [data-md-group-segment]:has(:checked)"))->toBe(['--md-group-container' => 'var(--md-sys-color-primary)', '--md-group-label' => 'var(--md-sys-color-on-primary)'])
|
||||
->and($css->declarations("[data-md-group][data-md-variant='outlined'] [data-md-group-segment]:has(:checked)"))->toBe(['--md-group-container' => 'var(--md-sys-color-inverse-surface)', '--md-group-label' => 'var(--md-sys-color-inverse-on-surface)', 'border-color' => 'transparent'])
|
||||
->and($css->declarations('[data-md-group-segment]:has(:disabled)'))->toMatchArray(['cursor' => 'not-allowed'])
|
||||
->and($css->declarations('[data-md-group-segment]:has(:focus-visible)'))->toBe(['outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
|
||||
->and((string) $this->blade('<x-group name="x" variant="glass" :options="[]" />'))->toContain('data-md-variant="tonal"');
|
||||
});
|
||||
|
||||
it('binds to a Livewire property and shows its validation message', function () {
|
||||
$component = new class extends Component
|
||||
{
|
||||
public string $theme = 'dark';
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->validate(['theme' => 'in:light'], ['theme.in' => 'Pick light.']);
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
return <<<'BLADE'
|
||||
<div>
|
||||
<x-group wire:model="theme" hint="How it looks" :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark']]" />
|
||||
</div>
|
||||
BLADE;
|
||||
}
|
||||
};
|
||||
|
||||
Livewire::test($component)
|
||||
->assertSeeHtml('wire:model="theme"')
|
||||
->assertSee('How it looks')
|
||||
->set('theme', 'light')
|
||||
->assertSet('theme', 'light')
|
||||
->set('theme', 'dark')
|
||||
->call('save')
|
||||
->assertSee('Pick light.')
|
||||
->assertDontSee('How it looks');
|
||||
});
|
||||
|
||||
it('adds hint-class to the hint, which a validation message still replaces', function () {
|
||||
$options = [['id' => 'flat', 'name' => 'Flat'], ['id' => 'hilly', 'name' => 'Hilly']];
|
||||
|
||||
expect((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
|
||||
->toContain('<p data-md-group-hint class="text-warning" >No elevation data here</p>')
|
||||
->and((string) $this->blade('<x-group wire:model="terrain" hint="No elevation data here" :$options />', ['options' => $options]))
|
||||
->toContain('<p data-md-group-hint >No elevation data here</p>')
|
||||
->and(ComponentStylesheet::read('group')->declarations('[data-md-group-hint]'))->toBe(['color' => 'var(--md-sys-color-on-surface-variant)']);
|
||||
|
||||
expect((string) $this->withViewErrors(['terrain' => 'Pick a terrain.'])->blade('<x-group wire:model="terrain" hint="No elevation data here" hint-class="text-warning" :$options />', ['options' => $options]))
|
||||
->toContain('<p data-md-group-error>Pick a terrain.</p>')
|
||||
->not->toContain('No elevation data here')
|
||||
->not->toContain('text-warning');
|
||||
});
|
||||
@@ -1,30 +1,47 @@
|
||||
<?php
|
||||
|
||||
it('is a named progressbar in the primary colour, 48px by default', function () {
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('is a named progressbar, drawn by its stylesheet rather than by classes', function () {
|
||||
$html = (string) $this->blade('<x-loading />');
|
||||
|
||||
expect($html)
|
||||
->toContain('data-md-loading')
|
||||
->toContain('role="progressbar"')
|
||||
->toContain('aria-label="Loading"')
|
||||
->toContain('size-12')
|
||||
->toContain('text-primary')
|
||||
->toContain('<animateTransform')
|
||||
->toContain('class="size-full motion-reduce:hidden"')
|
||||
->toContain('class="hidden size-full motion-reduce:block"');
|
||||
->toMatch('/<svg data-md-loading-animated="data-md-loading-animated" aria-hidden="true" focusable="false"/')
|
||||
->toMatch('/<svg data-md-loading-still="data-md-loading-still" aria-hidden="true" focusable="false"/')
|
||||
->not->toContain('class=');
|
||||
});
|
||||
|
||||
it('sits on a primary-container circle when contained', function () {
|
||||
expect((string) $this->blade('<x-loading contained class="size-8" label="Uploading" />'))
|
||||
->toContain('rounded-corner-full bg-primary-container text-on-primary-container')
|
||||
->toContain('size-8')
|
||||
->not->toContain('size-12')
|
||||
it('is 48px in the primary colour unless the caller sizes or colours it', function () {
|
||||
$css = ComponentStylesheet::read('loading');
|
||||
|
||||
expect($css->declarations('[data-md-loading]'))
|
||||
->toMatchArray(['inline-size' => '48px', 'block-size' => '48px', 'color' => 'var(--md-sys-color-primary)'])
|
||||
// A caller's class lands on the root untouched, and outranks the package's layer.
|
||||
->and((string) $this->blade('<x-loading class="size-24 text-tertiary" label="Uploading" />'))
|
||||
->toContain('class="size-24 text-tertiary"')
|
||||
->toContain('aria-label="Uploading"');
|
||||
});
|
||||
|
||||
it('takes the caller\'s colour', function () {
|
||||
expect((string) $this->blade('<x-loading class="text-tertiary" />'))
|
||||
->toContain('text-tertiary')
|
||||
->not->toContain('text-primary');
|
||||
it('sits on a primary-container circle when contained', function () {
|
||||
expect((string) $this->blade('<x-loading contained />'))->toContain('data-md-contained')
|
||||
->and((string) $this->blade('<x-loading />'))->not->toContain('data-md-contained')
|
||||
->and(ComponentStylesheet::read('loading')->declarations('[data-md-loading][data-md-contained]'))->toBe([
|
||||
'border-radius' => 'var(--md-sys-shape-corner-full)',
|
||||
'background-color' => 'var(--md-sys-color-primary-container)',
|
||||
'color' => 'var(--md-sys-color-on-primary-container)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rests the same shape under reduced motion', function () {
|
||||
$css = ComponentStylesheet::read('loading');
|
||||
|
||||
expect($css->declarations('[data-md-loading] > [data-md-loading-still]'))->toBe(['display' => 'none'])
|
||||
->and($css->declarations('[data-md-loading] > [data-md-loading-animated]', ['@media (prefers-reduced-motion: reduce)']))->toBe(['display' => 'none'])
|
||||
->and($css->declarations('[data-md-loading] > [data-md-loading-still]', ['@media (prefers-reduced-motion: reduce)']))->toBe(['display' => 'block']);
|
||||
});
|
||||
|
||||
it('says nothing where something else already does', function () {
|
||||
|
||||
@@ -45,7 +45,7 @@ it('badges the icon with a dot or a count that screen readers hear', function ()
|
||||
$dot = (string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" :badge="true" badge-label="New mail" />');
|
||||
|
||||
expect($count)->toContain('999+')->toContain('<span class="sr-only">, 1200</span>')
|
||||
->and($dot)->toContain('size-1.5')->toContain('<span class="sr-only">, New mail</span>')
|
||||
->and($dot)->toContain('data-md-dot')->toContain('<span class="sr-only">, New mail</span>')
|
||||
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))->not->toContain('sr-only');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <x-fab>'s root, and both are unlayered, because what they
|
||||
// Both rules hang off `data-md-fab` on <x-fab>'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;/');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('puts the action on the leading button and the menu on the trailing one', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options" class="mt-4">
|
||||
@@ -8,36 +10,59 @@ it('puts the action on the leading button and the menu on the trailing one', fun
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-button-group="split"')
|
||||
->toContain('mt-4')
|
||||
->toContain('data-split="leading"')
|
||||
->toContain('wire:click="downloadZip"')
|
||||
->toContain('data-split="trailing"')
|
||||
->toContain('aria-label="Download options"')
|
||||
->toStartWith('<div data-md-split-button data-md-size="sm" class="mt-4">')
|
||||
->toMatch('/<button data-md-button="data-md-button"[^>]*data-md-split="leading" wire:click="downloadZip">/')
|
||||
->toMatch('/<button data-md-button="data-md-button"[^>]*data-md-icon-button="data-md-icon-button"[^>]*aria-label="Download options"[^>]*data-md-split="trailing">/')
|
||||
->toContain('role="menu"')
|
||||
->toContain('As a ZIP')
|
||||
->and(substr_count($html, 'wire:click="downloadZip"'))->toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the corners to the group', function () {
|
||||
$html = (string) $this->blade('<x-split-button label="Save"><x-menu-item label="Draft" /></x-split-button>');
|
||||
it('leaves the corners to the pair, growing the inner ones under the finger', function () {
|
||||
$css = ComponentStylesheet::read('split-button');
|
||||
|
||||
preg_match_all('/<button\b[^>]*data-split="[^"]*"[^>]*>/', $html, $halves);
|
||||
|
||||
expect($halves[0])->toHaveCount(2);
|
||||
|
||||
foreach ($halves[0] as $half) {
|
||||
expect($half)->not->toContain('rounded-corner-full')->not->toContain('active:rounded-corner');
|
||||
}
|
||||
expect($css->declarations('[data-md-split-button] [data-md-split]'))->toBe([
|
||||
'--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)',
|
||||
])
|
||||
->and($css->declarations('[data-md-split-button] [data-md-split]:active'))->toBe(['--md-split-button-corner' => 'var(--md-split-button-inner-pressed)'])
|
||||
->and($css->declarations("[data-md-split-button] [data-md-split='trailing'][aria-expanded='true']"))->toBe(['--md-split-button-corner' => 'var(--md-split-button-full)'])
|
||||
->and($css->declarations("[data-md-split-button][data-md-size='xl']"))->toBe([
|
||||
'--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',
|
||||
])
|
||||
->and($css->imports())->toBe(['./button.css']);
|
||||
});
|
||||
|
||||
it('is filled unless another container variant is asked for', function () {
|
||||
expect((string) $this->blade('<x-split-button label="Save" variant="text"><x-menu-item label="Draft" /></x-split-button>'))->toContain('bg-primary text-on-primary')
|
||||
->and((string) $this->blade('<x-split-button label="Save" variant="tonal"><x-menu-item label="Draft" /></x-split-button>'))->toContain('bg-secondary-container');
|
||||
expect((string) $this->blade('<x-split-button label="Save" variant="text"><x-menu-item label="Draft" /></x-split-button>'))->toContain('data-md-variant="filled"')->not->toContain('data-md-variant="text"')
|
||||
->and((string) $this->blade('<x-split-button label="Save" variant="tonal"><x-menu-item label="Draft" /></x-split-button>'))->toContain('data-md-variant="tonal"');
|
||||
});
|
||||
|
||||
it('gives the two smallest sizes their narrower inner padding and 48px trailing button', function () {
|
||||
expect((string) $this->blade('<x-split-button label="Save" size="xs"><x-menu-item label="Draft" /></x-split-button>'))
|
||||
->toContain('pe-2.5')
|
||||
->toContain('w-12');
|
||||
$css = ComponentStylesheet::read('split-button');
|
||||
|
||||
expect((string) $this->blade('<x-split-button label="Save" size="xs"><x-menu-item label="Draft" /></x-split-button>'))->toContain('data-md-split-button data-md-size="xs"')
|
||||
->and($css->declarations("[data-md-split-button][data-md-size='xs'] [data-md-split='leading']"))->toBe(['padding-inline-end' => 'var(--md-sys-measurement-space125)'])
|
||||
->and($css->declarations("[data-md-split-button][data-md-size='sm'] [data-md-split='leading']"))->toBe(['padding-inline-end' => '12px'])
|
||||
->and($css->declarations("[data-md-split-button]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-split='trailing']"))->toBe(['inline-size' => 'var(--md-sys-measurement-space600)']);
|
||||
});
|
||||
|
||||
it('nudges its chevron towards the leading half and turns it over on the standard scheme', function () {
|
||||
$css = ComponentStylesheet::read('split-button');
|
||||
|
||||
expect($css->declarations("[data-md-split-button] [data-md-split='trailing'] [data-md-icon]"))->toBe([
|
||||
'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)',
|
||||
])
|
||||
->and($css->declarations("[data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] [data-md-icon]"))->toBe(['rotate' => '180deg']);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('wraps a trigger and anchors the bubble to it', function () {
|
||||
$html = (string) $this->blade('<x-tooltip text="Copy link"><button>Copy</button></x-tooltip>');
|
||||
|
||||
@@ -7,22 +9,49 @@ it('wraps a trigger and anchors the bubble to it', function () {
|
||||
|
||||
expect($anchor)->not->toBeEmpty()
|
||||
->and($html)
|
||||
->toContain('<button>Copy</button>')
|
||||
->toContain('popover="manual"')
|
||||
->toContain('x-data="materialTooltip"')
|
||||
->toContain("position-anchor: {$anchor[1]}")
|
||||
->toContain('bg-inverse-surface')
|
||||
->toContain('[position-area:top]');
|
||||
->toMatch('/<span data-md-tooltip-anchor style="anchor-name: --material-tooltip-[a-z0-9]+">\s*<button>Copy<\/button>/')
|
||||
->toMatch('/<span\s+popover="manual"\s+aria-hidden="true"\s+x-data="materialTooltip"\s+data-md-tooltip\s+data-md-side="top"\s+style="position-anchor: '.preg_quote($anchor[1], '/').'"\s*>Copy link<\/span>/')
|
||||
->not->toContain('class=');
|
||||
});
|
||||
|
||||
it('places the bubble on the side asked for, flipping when there is no room', function (string $side, string $classes) {
|
||||
expect((string) $this->blade("<x-tooltip text=\"Hi\" side=\"{$side}\"><span>x</span></x-tooltip>"))->toContain($classes);
|
||||
it('places the bubble on the side asked for, 4px out, flipping when there is no room', function (string $side, string $axis, string $margin, string $flip) {
|
||||
$css = ComponentStylesheet::read('tooltip');
|
||||
|
||||
expect((string) $this->blade("<x-tooltip text=\"Hi\" side=\"{$side}\"><span>x</span></x-tooltip>"))->toContain("data-md-side=\"{$side}\"")
|
||||
->and($css->declarations("[data-md-tooltip][data-md-side='{$side}']"))->toBe(['position-area' => $side])
|
||||
->and($css->declarations("[data-md-tooltip]:is({$axis})"))->toBe([$margin => 'var(--md-sys-measurement-space50)', 'position-try-fallbacks' => $flip]);
|
||||
})->with([
|
||||
'bottom' => ['bottom', '[position-area:bottom] [position-try-fallbacks:flip-block]'],
|
||||
'left' => ['left', '[position-area:left] [position-try-fallbacks:flip-inline]'],
|
||||
'right' => ['right', '[position-area:right] [position-try-fallbacks:flip-inline]'],
|
||||
'top' => ['top', "[data-md-side='top'], [data-md-side='bottom']", 'margin-block', 'flip-block'],
|
||||
'bottom' => ['bottom', "[data-md-side='top'], [data-md-side='bottom']", 'margin-block', 'flip-block'],
|
||||
'left' => ['left', "[data-md-side='left'], [data-md-side='right']", 'margin-inline', 'flip-inline'],
|
||||
'right' => ['right', "[data-md-side='left'], [data-md-side='right']", 'margin-inline', 'flip-inline'],
|
||||
]);
|
||||
|
||||
it('falls back to the top for a side it does not know', function () {
|
||||
expect((string) $this->blade('<x-tooltip text="Hi" side="middle"><span>x</span></x-tooltip>'))->toContain('data-md-side="top"');
|
||||
});
|
||||
|
||||
it('draws M3\'s plain tooltip in the inverse surface, and fades on the effects spring', function () {
|
||||
$css = ComponentStylesheet::read('tooltip');
|
||||
|
||||
expect($css->declarations('[data-md-tooltip]'))->toMatchArray([
|
||||
'max-inline-size' => '200px',
|
||||
'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)',
|
||||
'pointer-events' => 'none',
|
||||
'opacity' => '0',
|
||||
'transition-duration' => 'var(--md-sys-motion-effects-fast-duration)',
|
||||
'transition-timing-function' => 'var(--md-sys-motion-effects-fast)',
|
||||
'transition-behavior' => 'allow-discrete',
|
||||
])
|
||||
->and($css->declarations('[data-md-tooltip]:popover-open'))->toBe(['opacity' => '1'])
|
||||
->and($css->declarations('[data-md-tooltip]:popover-open', ['@starting-style']))->toBe(['opacity' => '0']);
|
||||
});
|
||||
|
||||
it('escapes its text', function () {
|
||||
$this->blade('<x-tooltip text="<b>bold</b>"><span>x</span></x-tooltip>')
|
||||
->assertSee('<b>bold</b>', false);
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Tests\Support;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* A component stylesheet (resources/css/components/<name>.css), read the way a render test asks
|
||||
* about it: its imports, its layer blocks, and the declarations of a rule by its selector.
|
||||
*
|
||||
* Nesting is resolved as the browser resolves it — `&` stands for the parent selector, and a
|
||||
* nested selector without `&` is a descendant of it — so a test names the selector the rule
|
||||
* applies to (`[data-md-button]:focus-visible`), not how the file happens to nest it. At-rules
|
||||
* (`@media`, `@starting-style`, `@supports`) are kept beside the selector they wrap.
|
||||
*/
|
||||
final class ComponentStylesheet
|
||||
{
|
||||
/** @var list<array{selector: string, at: list<string>, declarations: array<string, string>}> */
|
||||
private array $rules = [];
|
||||
|
||||
/** @var list<string> */
|
||||
private array $statements = [];
|
||||
|
||||
/** @var list<string> */
|
||||
private array $blocks = [];
|
||||
|
||||
private function __construct(public readonly string $name, public readonly string $css)
|
||||
{
|
||||
$source = self::withoutComments($css);
|
||||
|
||||
foreach (self::items($source) as $item) {
|
||||
if (isset($item['statement'])) {
|
||||
$this->statements[] = $item['statement'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->blocks[] = $item['prelude'];
|
||||
$this->collect($item['prelude'], $item['body'], null, []);
|
||||
}
|
||||
}
|
||||
|
||||
public static function read(string $name): self
|
||||
{
|
||||
$path = self::path($name);
|
||||
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException("No stylesheet [{$name}].");
|
||||
}
|
||||
|
||||
return new self($name, (string) file_get_contents($path));
|
||||
}
|
||||
|
||||
public static function path(string $name): string
|
||||
{
|
||||
return dirname(__DIR__, 2)."/resources/css/components/{$name}.css";
|
||||
}
|
||||
|
||||
/**
|
||||
* The statements before the first block, in order: the layer statement and the imports.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function statements(): array
|
||||
{
|
||||
return $this->statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* The files this stylesheet imports, as written.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function imports(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
fn (string $statement): ?string => preg_match('/^@import\s+([\'"])(.+?)\1;$/', $statement, $match) === 1 ? $match[2] : null,
|
||||
$this->statements,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The preludes of the top-level blocks.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function blocks(): array
|
||||
{
|
||||
return $this->blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declarations of every rule for this selector (and inside these at-rules, outermost
|
||||
* first), merged in source order.
|
||||
*
|
||||
* @param list<string> $at
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function declarations(string $selector, array $at = []): array
|
||||
{
|
||||
$selector = self::normalise($selector);
|
||||
$at = array_map(self::normalise(...), $at);
|
||||
$found = false;
|
||||
$declarations = [];
|
||||
|
||||
foreach ($this->rules as $rule) {
|
||||
if ($rule['selector'] === $selector && $rule['at'] === $at) {
|
||||
$found = true;
|
||||
$declarations = array_merge($declarations, $rule['declarations']);
|
||||
}
|
||||
}
|
||||
|
||||
if (! $found) {
|
||||
throw new RuntimeException("{$this->name}.css has no rule [{$selector}]".($at === [] ? '' : ' inside ['.implode(' › ', $at).']').'.');
|
||||
}
|
||||
|
||||
return $declarations;
|
||||
}
|
||||
|
||||
public function has(string $selector, array $at = []): bool
|
||||
{
|
||||
try {
|
||||
$this->declarations($selector, $at);
|
||||
|
||||
return true;
|
||||
} catch (RuntimeException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every media query the stylesheet writes.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function mediaQueries(): array
|
||||
{
|
||||
preg_match_all('/@media\s*([^{]+)\{/', self::withoutComments($this->css), $matches);
|
||||
|
||||
return array_map(fn (string $query): string => self::normalise($query), $matches[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $at
|
||||
*/
|
||||
private function collect(string $prelude, string $body, ?string $parent, array $at): void
|
||||
{
|
||||
if (str_starts_with($prelude, '@layer')) {
|
||||
foreach (self::items($body) as $item) {
|
||||
if (isset($item['prelude'])) {
|
||||
$this->collect($item['prelude'], $item['body'], $parent, $at);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($prelude, '@')) {
|
||||
$at[] = self::normalise($prelude);
|
||||
$selector = $parent;
|
||||
} else {
|
||||
$selector = $parent === null ? self::normalise($prelude) : self::nest($parent, $prelude);
|
||||
}
|
||||
|
||||
$declarations = [];
|
||||
|
||||
foreach (self::items($body, true) as $item) {
|
||||
if (isset($item['prelude'])) {
|
||||
$this->collect($item['prelude'], $item['body'], $selector, $at);
|
||||
} elseif (isset($item['statement']) && str_contains($item['statement'], ':') && $selector !== null) {
|
||||
[$property, $value] = explode(':', rtrim($item['statement'], ';'), 2);
|
||||
$declarations[trim($property)] = self::normalise($value);
|
||||
}
|
||||
}
|
||||
|
||||
if ($selector !== null && $declarations !== []) {
|
||||
$this->rules[] = ['selector' => $selector, 'at' => $at, 'declarations' => $declarations];
|
||||
}
|
||||
}
|
||||
|
||||
private static function nest(string $parent, string $child): string
|
||||
{
|
||||
$parents = self::split($parent);
|
||||
|
||||
return implode(', ', array_merge(...array_map(
|
||||
fn (string $each): array => array_map(
|
||||
fn (string $part): string => str_contains($part, '&') ? str_replace('&', $each, $part) : "{$each} {$part}",
|
||||
self::split($child),
|
||||
),
|
||||
$parents,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* A selector list split at its top-level commas.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function split(string $selector): array
|
||||
{
|
||||
$parts = [];
|
||||
$depth = 0;
|
||||
$current = '';
|
||||
|
||||
foreach (str_split(self::normalise($selector)) as $char) {
|
||||
$depth += match ($char) {
|
||||
'(', '[' => 1,
|
||||
')', ']' => -1,
|
||||
default => 0,
|
||||
};
|
||||
|
||||
if ($char === ',' && $depth === 0) {
|
||||
$parts[] = trim($current);
|
||||
$current = '';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$current .= $char;
|
||||
}
|
||||
|
||||
$parts[] = trim($current);
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
private static function normalise(string $text): string
|
||||
{
|
||||
return trim((string) preg_replace('/\s+/', ' ', $text));
|
||||
}
|
||||
|
||||
private static function withoutComments(string $css): string
|
||||
{
|
||||
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
|
||||
}
|
||||
|
||||
/**
|
||||
* The statements and blocks at the top of a piece of CSS. Inside a rule a declaration may end
|
||||
* at the closing brace without a semicolon.
|
||||
*
|
||||
* @return list<array{statement: string}|array{prelude: string, body: string}>
|
||||
*/
|
||||
private static function items(string $css, bool $declarations = false): array
|
||||
{
|
||||
$items = [];
|
||||
$start = 0;
|
||||
$depth = 0;
|
||||
$parens = 0;
|
||||
$quote = null;
|
||||
$opening = 0;
|
||||
|
||||
for ($i = 0, $length = strlen($css); $i < $length; $i++) {
|
||||
$char = $css[$i];
|
||||
|
||||
if ($quote !== null) {
|
||||
if ($char === '\\') {
|
||||
$i++;
|
||||
} elseif ($char === $quote) {
|
||||
$quote = null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($char === '"' || $char === "'") {
|
||||
$quote = $char;
|
||||
} elseif ($char === '(') {
|
||||
$parens++;
|
||||
} elseif ($char === ')') {
|
||||
$parens--;
|
||||
} elseif ($char === '{') {
|
||||
if ($depth++ === 0) {
|
||||
$opening = $i;
|
||||
}
|
||||
} elseif ($char === '}' && --$depth === 0) {
|
||||
$items[] = [
|
||||
'prelude' => self::normalise(substr($css, $start, $opening - $start)),
|
||||
'body' => substr($css, $opening + 1, $i - $opening - 1),
|
||||
];
|
||||
$start = $i + 1;
|
||||
} elseif ($char === ';' && $depth === 0 && $parens === 0) {
|
||||
$items[] = ['statement' => self::normalise(substr($css, $start, $i - $start)).';'];
|
||||
$start = $i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$rest = trim(substr($css, $start));
|
||||
|
||||
if ($rest !== '') {
|
||||
if (! $declarations) {
|
||||
throw new RuntimeException('The stylesheet ends inside an unclosed rule.');
|
||||
}
|
||||
|
||||
$items[] = ['statement' => self::normalise($rest).';'];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user