Files
livewire-material/resources/views/components/button.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 4ad257034e 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
2026-09-14 14:50:58 +02:00

170 lines
8.1 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.
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,
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'aria-disabled' => $inert ? 'true' : null,
'tabindex' => $inert ? '-1' : null,
'type' => $isLink ? null : $type,
'disabled' => ! $isLink && $disabled ? true : null,
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
// A link is not a toggle: ARIA defines aria-pressed for buttons only. A selected link keeps
// the selected look; `aria-current` is the caller's to set (`:aria-current="'page'"`).
'aria-pressed' => $selected === null || $isLink ? null : ($selected ? 'true' : 'false'),
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
'wire:target' => $spinnerTarget,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($spinnerTarget)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" 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 }}>