Draw the button without Tailwind
Plan step 36. <x-button> renders data-md-button with its variant, colour, size, shape, icon-button width, selected state and compact FAB as data-md-* attributes, and passes the glyph's size to <x-icon>. button.css draws Button*Tokens' geometry per size, the colour roles through custom properties that the disabled treatment replaces, the state layer, focus ring and 48px target, and the compact FAB below 600px. Its corner rules carry no specificity (:where), so a group's stylesheet reshapes the buttons inside it with any selector; the split button no longer passes an empty corners prop, which is gone. data-icon-button becomes data-md-icon-button, also in groups.css, toolbar.css and theme-toggle's own button (navigation group), and the AppBarTest assertions follow the new hooks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
7bdd3ef61c
commit
4ad257034e
@@ -15,6 +15,7 @@
|
||||
@import './components/loading.css';
|
||||
@import './components/tooltip.css';
|
||||
@import './components/badge.css';
|
||||
@import './components/button.css';
|
||||
|
||||
/* Inputs, selection and data */
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,15 +71,15 @@
|
||||
border-radius: var(--group-full);
|
||||
}
|
||||
|
||||
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
|
||||
[data-button-group='standard'] > :not([data-md-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'])) {
|
||||
[data-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(--group-pad) - var(--group-grow));
|
||||
}
|
||||
|
||||
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-icon-button]) {
|
||||
[data-button-group='standard'] > :not([data-md-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-md-icon-button]) {
|
||||
padding-inline-start: calc(var(--group-pad) - var(--group-grow));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,96 +106,24 @@
|
||||
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([
|
||||
$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,
|
||||
@@ -209,7 +136,6 @@
|
||||
// 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,
|
||||
@@ -218,23 +144,23 @@
|
||||
|
||||
<{{ $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)
|
||||
|
||||
@@ -50,7 +50,6 @@
|
||||
:size="$size"
|
||||
:disabled="$disabled"
|
||||
:spinner="$spinner"
|
||||
corners=""
|
||||
data-split="leading"
|
||||
:class="$leadingPadding"
|
||||
/>
|
||||
@@ -64,7 +63,6 @@
|
||||
:color="$color"
|
||||
:size="$size"
|
||||
:disabled="$disabled"
|
||||
corners=""
|
||||
data-split="trailing"
|
||||
:class="$trailingWidth"
|
||||
/>
|
||||
|
||||
@@ -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') }}"
|
||||
|
||||
@@ -14,6 +14,7 @@ dataset('action components', [
|
||||
'loading',
|
||||
'tooltip',
|
||||
'badge',
|
||||
'button',
|
||||
]);
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
|
||||
@@ -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)
|
||||
@@ -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 () {
|
||||
|
||||
@@ -89,8 +89,8 @@ it('never wraps a group onto a second line', function () {
|
||||
});
|
||||
|
||||
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');
|
||||
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');
|
||||
});
|
||||
|
||||
it('draws a choice as connected radios', function () {
|
||||
|
||||
@@ -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 () {
|
||||
@@ -166,11 +253,21 @@ it('anchors its tooltip to itself', function () {
|
||||
|
||||
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;');
|
||||
});
|
||||
|
||||
@@ -32,8 +32,8 @@ it('leaves the corners to the group', function () {
|
||||
});
|
||||
|
||||
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 () {
|
||||
|
||||
Reference in New Issue
Block a user