M3 says to avoid an icon in a snackbar and gives the supporting text no fourth colour, so the state glyph and the 80% description are gone: a type now only picks the announcement role. The 40px action and close buttons take the shared touch-target, Escape dismisses a snackbar that holds the focus, and the host publishes --material-snackbar-height on <html> so a `fab` button sits above the snackbar instead of under it. Plan step 18, actions.md ACT-17, ACT-18, ACT-20, ACT-34, ACT-35. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
244 lines
13 KiB
PHP
244 lines
13 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
|
|
(`state-transition-fast`, resources/css/components/actions.css: 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.
|
|
|
|
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. --}}
|
|
|
|
@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',
|
|
'corners' => null,
|
|
])
|
|
|
|
@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';
|
|
$square = $shape === 'square';
|
|
$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,
|
|
};
|
|
|
|
// 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];
|
|
|
|
$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,
|
|
];
|
|
|
|
$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));
|
|
@endphp
|
|
|
|
<{{ $tag }} {{ $attributes }}>
|
|
@if ($spinnerTarget)
|
|
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
|
|
<x-livewire-material::loading :class="$iconSize.' text-current'" :label="false" />
|
|
</span>
|
|
@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>
|
|
@endif
|
|
|
|
@unless ($iconOnly)
|
|
<span @class(['max-expanded:hidden' => $responsive])>{{ $label ?? $slot }}</span>
|
|
@endunless
|
|
|
|
@if ($iconRight)
|
|
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="$iconSize" />
|
|
@endif
|
|
|
|
@if ($tip !== null)
|
|
<x-livewire-material::tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
|
|
@endif
|
|
</{{ $tag }}>
|