Merge branch 'worktree-agent-a9b4c522a39c6ce62'

# Conflicts:
#	tests/Feature/StylesheetsTest.php
#	workbench/resources/css/app.css
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 15:41:37 +02:00
37 changed files with 2791 additions and 881 deletions
+25 -36
View File
@@ -7,10 +7,10 @@
Both are `error` by default, as M3 draws them, and an `outline` status label draws its edge in
`outline`, not `outline-variant`: M3 asks a badge for 3:1 against what is behind it, and
`outline-variant` is the decorative role dividers use. `floating` pins one to the top-end
corner of a `relative` parent, on M3's anchor geometry — the dot flush in the corner, the
count 2px above it — an icon or an icon button:
corner of a `position: relative` parent, on M3's anchor geometry — the dot flush in the corner,
the count 2px above it — an icon or an icon button:
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
<span style="position: relative; display: inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
colour's container ("Expired" in error-container), `solid` in the colour itself (a label that
@@ -21,13 +21,17 @@
count (the ink an outline badge already writes in), surface-container-high with
on-surface-variant text when `tonal`, the outline edge when `outline`.
- `plain` no background, text or border colour at all, only shape, size and type, so the
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
caller's CSS paints it.
The value is `value`, or the slot, which renders as HTML — an icon beside the word:
`<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
`<x-badge tonal><x-icon name="bolt" size="12" /> Pro</x-badge>`. With neither it is a dot.
A count or dot says nothing to a screen reader on its own: give the icon's control a label
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
that includes it ("Notifications, 4 new"), or pass `label` here.
Drawn by resources/css/components/badge.css from `data-md-dot` (the small badge),
`data-md-variant` (a status label: `solid`, `tonal` or `outline`, the first of them that is
set, in that order), `data-md-color` and `data-md-floating`. --}}
@props([
'value' => null,
@@ -54,38 +58,23 @@
$markup = false;
}
$filled = [
'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',
'neutral' => 'bg-on-surface-variant text-surface',
];
$container = [
'primary' => 'bg-primary-container text-on-primary-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',
'neutral' => 'bg-surface-container-high text-on-surface-variant',
];
$paint = match (true) {
$color === 'plain' => '',
! $status, $solid => $filled[$color],
$tonal => $container[$color],
default => 'border-outline text-on-surface-variant',
// `solid` wins over `tonal`, and either over `outline`, as the three have always resolved.
$variant = match (true) {
! $status => null,
(bool) $solid => 'solid',
(bool) $tonal => 'tonal',
default => 'outline',
};
$attributes = $attributes
->class([
'inline-flex shrink-0 items-center justify-center whitespace-nowrap',
'size-1.5 rounded-corner-full' => $dot,
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
'border' => $outline && ! $tonal && ! $solid && ! $dot,
$paint => $paint !== '',
'absolute top-0 end-0' => $floating && $dot,
'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
])
->merge(array_filter([
'aria-label' => $label,
'aria-hidden' => $label === null && ! $status ? 'true' : null,
]));
$attributes = $attributes->merge(array_filter([
'data-md-badge' => true,
'data-md-dot' => $dot ? true : null,
'data-md-variant' => $variant,
'data-md-color' => $color,
'data-md-floating' => $floating ? true : null,
'aria-label' => $label,
'aria-hidden' => $label === null && ! $status ? 'true' : null,
], fn ($value): bool => $value !== null));
@endphp
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
@@ -45,7 +45,7 @@
smaller `size` or fewer buttons, or two groups stacked.
Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The
shapes are resources/css/components/groups.css. --}}
spacing, shapes and press expansion are resources/css/components/button-group.css. --}}
@props([
'connected' => false,
@@ -71,12 +71,12 @@
<div
role="group"
@if ($label) aria-label="{{ $label }}" @endif
data-button-group="{{ $connected ? 'connected' : 'standard' }}"
data-size="{{ $size }}"
data-shape="{{ $shape }}"
data-md-button-group="{{ $connected ? 'connected' : 'standard' }}"
data-md-size="{{ $size }}"
data-md-shape="{{ $shape }}"
@if ($selection !== null)
data-selection="{{ $selection }}"
@if ($required) data-selection-required @endif
data-md-selection="{{ $selection }}"
@if ($required) data-md-selection-required @endif
x-data="{
multiple: {{ $multiple ? 'true' : 'false' }},
required: {{ $required ? 'true' : 'false' }},
@@ -129,13 +129,7 @@
x-on:click="press($event)"
@unless ($model) x-modelable="value" @endunless
@endif
{{ $attributes->class([
'inline-flex items-center',
'gap-0.5' => $connected,
'gap-[18px]' => ! $connected && $size === 'xs',
'gap-3' => ! $connected && $size === 'sm',
'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true),
]) }}
{{ $attributes }}
>
{{ $slot }}
</div>
+42 -116
View File
@@ -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)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
<x-livewire-material::loading :class="$iconSize.' text-current'" :label="false" />
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" data-md-button-spinner>
<x-livewire-material::loading :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 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 @class(['max-expanded:hidden' => $responsive])>{{ $label ?? $slot }}</span>
<span data-md-button-label @if ($responsive) data-md-responsive @endif>{{ $label ?? $slot }}</span>
@endunless
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="$iconSize" />
<x-livewire-material::icon :name="$iconRight" :size="$iconSize" />
@endif
@if ($tip !== null)
+30 -41
View File
@@ -6,8 +6,8 @@
drawn in its container, or in the colour itself with `variant="filled"`.
It does not place itself: put it where the layout wants it, e.g.
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
`<div style="position: fixed; inset-inline-end: 16px; bottom: 16px"><x-fab icon="add" tooltip="New share" /></div>`.
For a create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
The glyph is filled, not outlined: M3 says so twice, in the FAB's anatomy and again in its
guidelines ("icon should be filled (not outlined) and unambiguous").
@@ -17,19 +17,19 @@
than greying it out. A form-submit FAB that must not be pressed twice takes the caller's own
`wire:loading.attr="disabled"`.
`data-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation
`data-md-fab` marks the root, so a place a FAB sits in can draw it its own way: a navigation
rail flattens a nested FAB to elevation 0.
`collapse-on-scroll` is M3's extended FAB that "can collapse to a FAB on scroll-down and
re-expand to extended on scroll-up": while the window scrolls down it shrinks to the FAB of
its size, and it extends again when the page scrolls back up or reaches the top. The label
closes and fades while the width follows it on the spatial spring (resources/css/components/
actions.css); under reduced motion the two swap outright. The label stays in the page,
clipped rather than removed, so the collapsed FAB keeps its accessible name. It needs an
`icon` — a FAB with no glyph is no FAB — and does nothing on a plain FAB.
closes and fades while the width follows it on the spatial spring; under reduced motion the
two swap outright. The label stays in the page, clipped rather than removed, so the collapsed
FAB keeps its accessible name. It needs an `icon` — a FAB with no glyph is no FAB — and does
nothing on a plain FAB.
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
(androidx Compose Material 3, Apache-2.0). --}}
(androidx Compose Material 3, Apache-2.0), drawn by resources/css/components/fab.css. --}}
@props([
'icon' => null,
@@ -47,53 +47,42 @@
@php
$size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm';
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
$variant = $variant === 'filled' ? 'filled' : 'container';
$extended = filled($label) || $slot->isNotEmpty();
$collapsing = $collapseOnScroll && $extended && filled($icon);
$isLink = filled($link);
$colours = $variant === 'filled'
? ['primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary'][$color]
: ['primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container'][$color];
$dimensions = $extended
? ['sm' => 'h-14 min-w-20 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-4 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-5 px-7 rounded-corner-xl type-headline-sm'][$size]
: ['sm' => 'size-14 rounded-corner-lg', 'md' => 'size-20 rounded-corner-lg-increased', 'lg' => 'size-24 rounded-corner-xl'][$size];
$iconSize = ['sm' => 'size-6', 'md' => 'size-7', 'lg' => 'size-8'][$size];
$iconSize = ['sm' => 24, 'md' => 28, 'lg' => 32][$size];
$anchor = $tooltip !== null ? '--material-fab-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
$tag = $isLink ? 'a' : 'button';
$attributes = $attributes
->class([
'state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
$dimensions,
$colours,
])
->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : $type,
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
'data-fab' => true,
'x-data' => $collapsing ? 'materialFab' : null,
'x-bind:data-collapsed' => $collapsing ? "collapsed ? '' : null" : null,
'data-fab-collapsible' => $collapsing ? true : null,
'data-fab-size' => $collapsing ? $size : null,
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
$attributes = $attributes->merge(array_filter([
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
'data-md-fab' => true,
'data-md-size' => $size,
'data-md-color' => $color,
'data-md-variant' => $variant,
'data-md-extended' => $extended ? true : null,
'data-md-collapse-on-scroll' => $collapsing ? true : null,
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : $type,
'x-data' => $collapsing ? 'materialFab' : null,
'x-bind:data-md-collapsed' => $collapsing ? "collapsed ? '' : null" : null,
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
<x-livewire-material::icon :name="$icon" filled :size="$iconSize" />
@endif
@if ($collapsing)
<span data-fab-label><span>{{ $label ?? $slot }}</span></span>
<span data-md-fab-label><span>{{ $label ?? $slot }}</span></span>
@elseif ($extended)
<span>{{ $label ?? $slot }}</span>
@endif
+26 -47
View File
@@ -11,9 +11,8 @@
The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the
default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless
`inline`. An option with `'disabled' => true` greys its own segment. At `xs` and `sm` a
segment carries `touch-target` and a 48px minimum width, which M3 asks for by name and tells
you never to reduce. Corners move on the spatial spring and colours on the effects one
(`state-transition-fast`).
segment keeps a 48px target and a 48px minimum width, which M3 asks for by name and tells
you never to reduce. Corners move on the spatial spring and colours on the effects one.
`shape` is M3's "Default shape | Round, square" configuration: `square` squares the two ends
of the group to the corner its inner edges take (M3's square table, 4/8/8/16/20dp by size),
@@ -25,9 +24,11 @@
the hint.
`hint-class` adds classes to the hint, as on `<x-field>`: a colour there paints it
(`hint-class="text-warning"` for a hint that warns). The hint's own colour then carries no
specificity, as the field's does in the components layer, because which of two colour
utilities wins depends on the order Tailwind emits them. --}}
(`hint-class="md-ink-warning"` for a hint that warns), because a caller's class outranks the
package's layer.
Drawn by resources/css/components/group.css, with the connected shapes of
resources/css/components/button-group.css. --}}
@props([
'label' => null,
@@ -52,76 +53,54 @@
$errorKey = $model ?? (filled($attributes->get('name')) ? str_replace(['[]', '[', ']'], ['', '.', ''], (string) $attributes->get('name')) : null);
$messages = $errorKey !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($errorKey)) : [];
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$variant = in_array($variant, ['tonal', 'filled', 'outlined'], true) ? $variant : 'tonal';
// M3's "Default shape | Round, square": a square group's ends take the corner its inner edges
// take; the chosen segment still rounds fully, which is the cue selection has always carried.
$shape = $shape === 'square' ? 'square' : 'round';
// M3: an xs or sm connected segment keeps a 48px target and a 48px minimum width, whatever
// its 32px/40px container measures. From md the segment is wider than that on its own.
$small = in_array($size, ['xs', 'sm'], true);
$segment = [
'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];
// A 20px glyph comes from M3's 20px cut, which the icon component picks from the size.
$iconSize = ['xs' => 20, 'sm' => 20, 'md' => 24, 'lg' => 32, 'xl' => 40][$size];
$hintClasses = filled($hintClass)
? \Illuminate\Support\Arr::toCssClasses(['mt-1 type-body-sm [:where(&)]:text-on-surface-variant', $hintClass])
: 'mt-1 type-body-sm text-on-surface-variant';
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
// A 20px glyph comes from M3's 20px cut, which is drawn for that size rather than scaled to it.
$optical = $iconSize === 'size-5' ? '20' : '24';
$colours = match ($variant) {
'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary',
'outlined' => 'border border-outline-variant text-on-surface-variant has-checked:border-transparent has-checked:bg-inverse-surface has-checked:text-inverse-on-surface',
default => 'bg-secondary-container text-on-secondary-container has-checked:bg-secondary has-checked:text-on-secondary',
};
$root = $attributes->only(['class', 'wire:key'])->merge(array_filter([
'data-md-group' => true,
'data-md-size' => $size,
'data-md-variant' => $variant,
'data-md-shape' => $shape,
'data-md-multiple' => $multiple ? true : null,
'data-md-inline' => $inline ? true : null,
], fn ($value): bool => $value !== null));
@endphp
<fieldset {{ $attributes->only(['class', 'wire:key'])->class('min-w-0') }}>
<fieldset {{ $root }}>
@if (filled($label))
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
<legend data-md-group-legend>{{ $label }}</legend>
@endif
<div data-button-group="connected" data-size="{{ $size }}" data-shape="{{ $shape }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
<div data-md-button-group="connected" data-md-size="{{ $size }}" data-md-shape="{{ $shape }}">
@foreach ($options as $option)
<label @class([
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
'state-transition-fast',
'flex-1' => ! $inline,
$small ? 'touch-target min-w-12' : 'min-w-0',
$segment,
$colours,
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
'has-disabled:cursor-not-allowed has-disabled:bg-on-surface/10 has-disabled:text-on-surface/38',
])>
<label data-md-group-segment>
<input
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
type="{{ $multiple ? 'checkbox' : 'radio' }}"
name="{{ $multiple ? $name.'[]' : $name }}"
value="{{ data_get($option, $optionValue) }}"
@disabled(data_get($option, 'disabled'))
class="peer sr-only"
/>
@if (filled(data_get($option, $optionIcon)))
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :optical="$optical" :class="$iconSize" />
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :size="$iconSize" />
@endif
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
<span data-md-group-label>{{ data_get($option, $optionLabel) }}</span>
</label>
@endforeach
</div>
@if ($messages !== [])
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
<p data-md-group-error>{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="{{ $hintClasses }}">{{ $hint }}</p>
<p data-md-group-hint @if (filled($hintClass)) class="{{ $hintClass }}" @endif>{{ $hint }}</p>
@endif
</fieldset>
+15 -21
View File
@@ -1,16 +1,17 @@
{{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while
it turns, for a wait that has no known length.
In the text colour — primary unless the caller colours it — and 48px unless sized. `contained`
puts it on a primary-container circle, for a spinner over content. It is a `progressbar`
named `label` ("Loading" by default); pass `:label="false"` where something else already
says what is happening, as a button's own spinner does.
In the text colour — primary unless the caller colours it — and 48px unless the caller's CSS
sizes it. `contained` puts it on a primary-container circle, for a spinner over content. It
is a `progressbar` named `label` ("Loading" by default); pass `:label="false"` where something
else already says what is happening, as a button's own spinner does.
Pure SVG with SMIL, so it animates in every engine without script. Under
`prefers-reduced-motion` the shape rests. The geometry is androidx Compose Material 3's
LoadingIndicator and Morph, ported in bin/loading-indicator.mjs (Apache-2.0):
LoadingIndicatorTokens' 38px indicator in a 48px container, a morph every 650ms on a spring,
a quarter turn per morph and a full turn every 4666ms. --}}
a quarter turn per morph and a full turn every 4666ms. Drawn by
resources/css/components/loading.css. --}}
@props([
'contained' => false,
@@ -18,26 +19,19 @@
])
@php
$sized = preg_match('/(^|\s)(size|w|h)-/', (string) $attributes->get('class')) === 1;
$coloured = preg_match('/(^|\s)text-(?!(xs|sm|base|lg|xl|[2-9]xl|left|center|right|start|end)(\s|$))/', (string) $attributes->get('class')) === 1;
$decorative = $label === false;
$label = $decorative ? null : ($label ?? __('Loading'));
$attributes = $attributes
->class([
'inline-flex shrink-0 items-center justify-center',
'size-12' => ! $sized,
'rounded-corner-full bg-primary-container text-on-primary-container' => $contained,
'text-primary' => ! $contained && ! $coloured,
])
->merge(array_filter([
'role' => $decorative ? null : 'progressbar',
'aria-label' => $label,
'aria-hidden' => $decorative ? 'true' : null,
]));
$attributes = $attributes->merge(array_filter([
'data-md-loading' => true,
'data-md-contained' => $contained ? true : null,
'role' => $decorative ? null : 'progressbar',
'aria-label' => $label,
'aria-hidden' => $decorative ? 'true' : null,
], fn ($value): bool => $value !== null));
@endphp
<span {{ $attributes }}>
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['class' => 'size-full motion-reduce:hidden', 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['class' => 'hidden size-full motion-reduce:block', 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['data-md-loading-animated' => true, 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['data-md-loading-still' => true, 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
</span>
@@ -5,16 +5,16 @@
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
</x-split-button>
Attributes (wire:click, spinner…) go to the leading button; the slot is the menu. `variant` is
`filled` (the default), `tonal`, `outlined` or `elevated` a text split button does not exist
in M3 with `color` and `size` as on `<x-button>`. The halves sit 2px apart; the trailing
one rounds fully and turns its chevron over while the menu is open. `menu-label` names the
trailing button and the menu for screen readers.
Attributes (wire:click, spinner…) go to the leading button, a `class` to the pair; the slot is
the menu. `variant` is `filled` (the default), `tonal`, `outlined` or `elevated` a text split
button does not exist in M3 with `color` and `size` as on `<x-button>`. The halves sit 2px
apart; the trailing one rounds fully and turns its chevron over while the menu is open.
`menu-label` names the trailing button and the menu for screen readers.
Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button
keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px
wide there. resources/css/components/groups.css carries the rest of the spec, which is all
geometry: the inner corners (4/4/4/8/12px, growing under the finger, not shrinking as a
wide there. resources/css/components/split-button.css carries the rest of the spec, which is
all geometry: the inner corners (4/4/4/8/12px, growing under the finger, not shrinking as a
connected group's do), the trailing icon's own size (22/22/26/38/50px, which the icon-button
table has no entry for), the 1/1/2/3/6px nudge that sits the chevron towards the leading half,
and the standard motion scheme M3 asks for while it turns over. --}}
@@ -35,12 +35,9 @@
$variant = in_array($variant, ['filled', 'tonal', 'outlined', 'elevated'], true) ? $variant : 'filled';
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$menuLabel ??= __('More options');
$leadingPadding = ['xs' => 'pe-2.5', 'sm' => 'pe-3', 'md' => '', 'lg' => '', 'xl' => ''][$size];
$trailingWidth = ['xs' => 'w-12', 'sm' => 'w-12', 'md' => '', 'lg' => '', 'xl' => ''][$size];
@endphp
<div data-button-group="split" data-size="{{ $size }}" {{ $attributes->only('class')->class('inline-flex items-center gap-0.5') }}>
<div data-md-split-button data-md-size="{{ $size }}" {{ $attributes->only('class') }}>
<x-livewire-material::button
{{ $attributes->except('class') }}
:label="$label"
@@ -50,9 +47,7 @@
:size="$size"
:disabled="$disabled"
:spinner="$spinner"
corners=""
data-split="leading"
:class="$leadingPadding"
data-md-split="leading"
/>
<x-livewire-material::menu :position="$position" :label="$menuLabel">
@@ -64,9 +59,7 @@
:color="$color"
:size="$size"
:disabled="$disabled"
corners=""
data-split="trailing"
:class="$trailingWidth"
data-md-split="trailing"
/>
</x-slot:trigger>
@@ -84,7 +84,7 @@
<button
type="button"
x-data
data-icon-button
data-md-icon-button
data-theme-toggle="{{ $mode }}"
@if ($mode === 'cycle')
aria-label="{{ __('Theme') }}"
+5 -10
View File
@@ -9,8 +9,9 @@
at once on a press or Escape; leaving or blurring lets it stand for M3's 1.5s. Only one
tooltip is on screen at a time, as M3 asks. A phone has no hover, so a control there carries its
words. The bubble is a `popover="manual"` in the top layer — never clipped by an
`overflow-hidden` parent, never widening a scroll container — placed by CSS anchor
`overflow: hidden` parent, never widening a scroll container — placed by CSS anchor
positioning on `side` (`top`, `bottom`, `left`, `right`), flipping when there is no room.
Drawn by resources/css/components/tooltip.css.
It is aria-hidden: an icon button takes the same words as its aria-label, and a labelled
control would otherwise read them twice. --}}
@@ -28,7 +29,7 @@
@endphp
@if ($standalone)
<span class="inline-flex" style="anchor-name: {{ $anchor }}">
<span data-md-tooltip-anchor style="anchor-name: {{ $anchor }}">
{{ $slot }}
@endif
@@ -36,15 +37,9 @@
popover="manual"
aria-hidden="true"
x-data="materialTooltip"
data-md-tooltip
data-md-side="{{ $side }}"
style="position-anchor: {{ $anchor }}"
@class([
'pointer-events-none m-0 max-w-50 overflow-visible border-0 rounded-corner-xs bg-inverse-surface px-2 py-1 text-center whitespace-normal type-body-sm text-inverse-on-surface [inset:auto]',
'opacity-0 transition-[opacity,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'my-1 [position-area:top] [position-try-fallbacks:flip-block]' => $side === 'top',
'my-1 [position-area:bottom] [position-try-fallbacks:flip-block]' => $side === 'bottom',
'mx-1 [position-area:left] [position-try-fallbacks:flip-inline]' => $side === 'left',
'mx-1 [position-area:right] [position-try-fallbacks:flip-inline]' => $side === 'right',
])
>{{ $text }}</span>
@if ($standalone)