`<x-button fab>` is an extended FAB below `medium` and a filled button from there, but a disabled one was still drawn as the FAB below `medium`: the compact FAB's rule set no state of its own, so the button stood greyed out and fixed over the content. M3 never disables a FAB: "if its action is unavailable, remove the FAB entirely". A `fab` given `disabled` (a `<button disabled>`, or a link's `aria-disabled="true"`) now renders `data-md-unavailable`, and button.css draws it `display: none` below `medium`, which also takes it out of the accessibility tree and the Tab order; from `medium` it is the disabled filled button it was. The mark comes from the prop, not from `:disabled`, because a `spinner` puts `disabled` on its button while the action runs, and that FAB is busy rather than unavailable: hidden, it would vanish instead of showing its loading indicator. The docblocks and the skill's button table say so. A browser test renders an enabled fab with a slow spinner action, a disabled one and a disabled link on a 393px window: the disabled two are not rendered, the busy one stays on screen with its indicator, and at 600px both disabled ones are the disabled button again; it fails without the change in Chrome, Firefox and Safari (and in Chrome with `:disabled` alone as the condition, on the busy FAB). A feature test reads the mark and the rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
179 lines
8.7 KiB
PHP
179 lines
8.7 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,
|
|
'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))->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 }}>
|