From 4ad257034e3869e84548155974653f94926e9b6a Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:50:58 +0200 Subject: [PATCH] Draw the button without Tailwind Plan step 36. 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 . 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/button.css | 452 ++++++++++++++++++ resources/css/components/groups.css | 6 +- resources/css/components/toolbar.css | 6 +- resources/views/components/button.blade.php | 158 ++---- .../views/components/split-button.blade.php | 2 - .../views/components/theme-toggle.blade.php | 2 +- .../Components/ActionStylesheetsTest.php | 1 + tests/Feature/Components/AppBarTest.php | 8 +- tests/Feature/Components/ButtonGroupTest.php | 4 +- tests/Feature/Components/ButtonTest.php | 248 +++++++--- tests/Feature/Components/SplitButtonTest.php | 4 +- 12 files changed, 687 insertions(+), 205 deletions(-) create mode 100644 resources/css/components/button.css diff --git a/resources/css/components.css b/resources/css/components.css index f965e9d3..ca6f3e08 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -15,6 +15,7 @@ @import './components/loading.css'; @import './components/tooltip.css'; @import './components/badge.css'; +@import './components/button.css'; /* Inputs, selection and data */ diff --git a/resources/css/components/button.css b/resources/css/components/button.css new file mode 100644 index 00000000..8fdbc7b7 --- /dev/null +++ b/resources/css/components/button.css @@ -0,0 +1,452 @@ +/* + * : 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 ``; `--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; + } + } +} diff --git a/resources/css/components/groups.css b/resources/css/components/groups.css index de21ac79..33a595bf 100644 --- a/resources/css/components/groups.css +++ b/resources/css/components/groups.css @@ -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)); } diff --git a/resources/css/components/toolbar.css b/resources/css/components/toolbar.css index 14e95ab2..48af9a08 100644 --- a/resources/css/components/toolbar.css +++ b/resources/css/components/toolbar.css @@ -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); } diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php index 4e8fab32..8a0f8c4a 100644 --- a/resources/views/components/button.blade.php +++ b/resources/views/components/button.blade.php @@ -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) - - + + @endif @if ($icon) - - + + @endif @unless ($iconOnly) - $responsive])>{{ $label ?? $slot }} + {{ $label ?? $slot }} @endunless @if ($iconRight) - + @endif @if ($tip !== null) diff --git a/resources/views/components/split-button.blade.php b/resources/views/components/split-button.blade.php index ff430b88..d5c3f6c2 100644 --- a/resources/views/components/split-button.blade.php +++ b/resources/views/components/split-button.blade.php @@ -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" /> diff --git a/resources/views/components/theme-toggle.blade.php b/resources/views/components/theme-toggle.blade.php index 206762a8..5ae93846 100644 --- a/resources/views/components/theme-toggle.blade.php +++ b/resources/views/components/theme-toggle.blade.php @@ -84,7 +84,7 @@