Files
livewire-material/resources/views/components/button.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 247c596c3a Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed
batches. Behaviour stays the same except where UPGRADE.md says otherwise.

PHP: the showcase and error-page stylesheets are prebuilt into
resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import
(first occurrence kept, the order an application's build gives), instead
of Stylesheets::bundle() inlining imports on every request; only the
import walk DesignGuard needs stays. SchemeStylesheet::withProfiles()
replaces three copies of the scheme-plus-profiles loop, material:scheme
leaves spec and contrast checks to the node script that already made
them, and the error page's scheme cache, the hashed view namespace, the
translations path with no lang/ folder and DesignGuard's 1.x-name hints
are gone.

JS: the androidx shape port progress.js and both bin scripts each carried
lives once in resources/js/shapes.js (the generated SVGs are unchanged);
util.js holds ringIndex(), ms(), reopenGuard() and remember(), which
were written out several times; listeners are released through
AbortController; tooltip.js's hoverPopover() serves the rich tooltip too.

CSS: every rule for an element inside the navigation rail queries
`--md-navigation-rail-value` instead of repeating the seven collapsed
conditions under five media branches; badge, alert, progress, slider and
button read one non-inheriting colour-role table (components/color.css);
the dialog chrome, the submenu's popover chrome, the chip's state layer
and touch target, and the visually-hidden inputs use the shared rules
they copied; foundation/tokens.css is folded into foundation.css.

Views: Support\Field and Support\Link replace the error-key, bound-value
and link-attribute blocks copied into the fields and link components;
the timepicker period group, the menu filter and the showcase head are
partials; the datepicker's steppers and entry fields are loops; component
docblocks no longer restate SKILL.md.

Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces
four per-group files, DesignGuardTest and the layout-component tests use
datasets, browser tests share one ready() helper, CSS parsing lives in
ComponentStylesheet alone. docs/audits and the finding IDs citing it are
removed, as are pestphp/pest-plugin-laravel, the unused composer scripts
and check:font; the lint job runs in the feature job, which now installs
node packages so the prebuilt-stylesheet staleness test runs in CI.

Feature suite 1177 passed, Chrome browser suite 299 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:29:21 +02:00

176 lines
8.5 KiB
PHP

{{-- A Material 3 Expressive button label button, icon button or toggle in one component.
`variant` is M3's style: `filled`, `tonal`, `outlined`, `elevated` or `text` (the default).
`color` is the role it is drawn in: `primary` (the default), `secondary`, `tertiary`, `error`,
`success`, `warning`, `info`; `tone` is the same prop under ReStride's name. The shorthands
`primary`, `danger` and `caution` mean filled primary, filled error and filled warning.
Unknown values fall back to the defaults rather than rendering nothing.
`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 (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
readers. Its glyph is filled, as M3 asks of a default icon button. `selected` makes it a
toggle: `true` or `false` sets `aria-pressed` (not on a `link`, which is no toggle give it
`aria-current` instead) and M3's selected colours, and a selected round button turns square
(a selected square icon button turns round); a toggle reads unselected outlined and selected
filled, which is the rest of the same rule. Text buttons are not toggles in M3; a selected
one takes the tonal container.
Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens,
Apache-2.0); 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.
Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external`
or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows
the loading indicator while the button's own `wire:click` runs (or the action named by a
string), drawn in the button's own ink M3 asks an indicator embedded in another component
to take that component's label colour, so it stays visible on a filled or tonal container;
`responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition);
`tooltip`, `tooltip-left`, `tooltip-right`
and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB
pinned in the thumb zone on a compact window (below `medium`, 600px), a filled button from
there one element either way. It lifts 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. A `fab` that is `disabled` is not drawn below `medium`, since M3
removes a FAB whose action is unavailable rather than showing it disabled, and is the disabled
filled button from there; a spinner's own disabling while the action runs leaves it on screen.
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,
'icon' => null,
'iconRight' => null,
'link' => null,
'external' => false,
'noWireNavigate' => false,
'variant' => null,
'color' => null,
'tone' => null,
'primary' => false,
'danger' => false,
'caution' => false,
'size' => 'sm',
'shape' => 'round',
'width' => 'default',
'selected' => null,
'spinner' => null,
'responsive' => false,
'tooltip' => null,
'tooltipLeft' => null,
'tooltipRight' => null,
'tooltipBottom' => null,
'fab' => false,
'disabled' => false,
'type' => 'button',
])
@php
$variant = match (true) {
in_array($variant, ['filled', 'tonal', 'outlined', 'text', 'elevated'], true) => $variant,
$primary || $danger || $caution || $fab => 'filled',
default => 'text',
};
$color = match (true) {
$danger => 'error',
$caution => 'warning',
in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) => $color ?? $tone,
default => 'primary',
};
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$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);
$inert = $disabled && $isLink;
$tip = $tooltip ?? $tooltipLeft ?? $tooltipRight ?? $tooltipBottom;
$tipSide = match (true) {
$tooltipLeft !== null => 'left',
$tooltipRight !== null => 'right',
$tooltipBottom !== null => 'bottom',
default => 'top',
};
$anchor = $tip !== null ? '--material-button-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
$spinnerTarget = match (true) {
$spinner === true, $spinner === 1, $spinner === '1' => $attributes->whereStartsWith('wire:click')->first(),
is_string($spinner) && $spinner !== '' => $spinner,
default => null,
};
// 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' => 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->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,
// Read from the prop, not from `:disabled`: a spinner disables its button while the action
// runs, and that FAB is busy, not unavailable.
'data-md-unavailable' => $fab && $disabled ? true : null,
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
'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))->class([
'md-state-layer',
'md-focus-ring',
'md-touch-target' => in_array($size, ['xs', 'sm'], true),
]);
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($spinnerTarget)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" data-md-button-spinner>
<x-livewire-material::loading :label="false" />
</span>
@endif
@if ($icon)
<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 data-md-button-label @if ($responsive) data-md-responsive @endif>{{ $label ?? $slot }}</span>
@endunless
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :size="$iconSize" />
@endif
@if ($tip !== null)
<x-livewire-material::tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
@endif
</{{ $tag }}>