Merge branch 'worktree-agent-a6b15d4e17dad5970'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:17:26 +02:00
33 changed files with 603 additions and 165 deletions
+12 -5
View File
@@ -5,9 +5,15 @@
text from `description` or the slot, and an `actions` slot for one or two text buttons.
`color` (alias `tone`): `info` (the default), `success`, `warning`, `error`, `primary`,
`secondary`, `tertiary`, or `neutral` for surface-container-high. `icon` replaces the state's
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser.
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser,
40px drawn with the 48px target M3 asks for.
Errors and warnings are `role="alert"` and announced at once; the rest are `role="status"`. --}}
It is a `role="status"`, whatever its colour: an alert is usually part of the page as it
renders, and `role="alert"` is an assertive live region that some screen readers announce over
the page title on load — and that a Livewire morph re-announces. `assertive` opts into
`role="alert"` for the case the prop is named after: a notice put on screen in answer to
something the person just did. M3 publishes no banner; the nearest thing it does publish, the
snackbar, says polite and never assertive. --}}
@props([
'title' => null,
@@ -16,6 +22,7 @@
'tone' => null,
'icon' => null,
'dismissible' => false,
'assertive' => false,
])
@php
@@ -35,7 +42,7 @@
@endphp
<div
role="{{ in_array($color, ['error', 'warning'], true) ? 'alert' : 'status' }}"
role="{{ $assertive ? 'alert' : 'status' }}"
@if ($dismissible) x-data="{ shown: true }" x-show="shown" x-transition.opacity @endif
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
>
@@ -58,8 +65,8 @@
</div>
@if ($dismissible)
<button type="button" class="state-layer focus-ring -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
<x-livewire-material::icon name="close" class="size-5" />
<button type="button" class="state-layer focus-ring touch-target -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
<x-livewire-material::icon name="close" optical="20" class="size-5" />
</button>
@endif
</div>
+10 -7
View File
@@ -4,8 +4,11 @@
- `<x-badge />` — the small badge, a 6px dot: something new, no number.
- `<x-badge value="3" />` — the large badge, 16px tall, label-small: a count. `max` caps what is
shown ("99+").
Both are `error` by default, as M3 draws them. `floating` pins one to the top-end corner of a
`relative` parent — an icon or an icon button:
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:
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
@@ -16,12 +19,12 @@
and two without a hue of their own:
- `neutral` — neutral ink on every variant: on-surface-variant with surface text as a dot or
count (the ink an outline badge already writes in), surface-container-high with
on-surface-variant text when `tonal`, the outline-variant edge when `outline`.
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" />`.
The value is `value`, or the slot, which renders as HTML — an icon beside the word:
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
`<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> 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. --}}
@@ -65,7 +68,7 @@
$color === 'plain' => '',
! $status, $solid => $filled[$color],
$tonal => $container[$color],
default => 'border-outline-variant text-on-surface-variant',
default => 'border-outline text-on-surface-variant',
};
$attributes = $attributes
@@ -76,8 +79,8 @@
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
'border' => $outline && ! $tonal && ! $solid && ! $dot,
$paint => $paint !== '',
'absolute top-0.5 end-0.5' => $floating && $dot,
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
'absolute top-0 end-0' => $floating && $dot,
'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
])
->merge(array_filter([
'aria-label' => $label,
@@ -11,6 +11,10 @@
the size of the buttons inside, so the spacing and corners match. For a choice bound to a
property, `<x-group>` draws a connected group of radios.
A group never wraps to a second line — M3's rule, and the press expansion only reaches a
neighbour on the same line anyway. Where a row is too long for its window the answer is a
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. --}}
@@ -32,7 +36,6 @@
{{ $attributes->class([
'inline-flex items-center',
'gap-0.5' => $connected,
'flex-wrap' => ! $connected,
'gap-[18px]' => ! $connected && $size === 'xs',
'gap-3' => ! $connected && $size === 'sm',
'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true),
+27 -14
View File
@@ -9,13 +9,18 @@
`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.
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. `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). Text buttons are not toggles in M3; a selected one takes the tonal container.
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
@@ -24,11 +29,15 @@
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); `responsive` hides the label below `expanded` (840px, where M3 lets buttons reposition);
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.
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. --}}
@@ -146,7 +155,7 @@
'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'],
][$size][$width]
: [
'xs' => 'h-8 gap-2 px-3 type-label-lg',
'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',
@@ -157,12 +166,16 @@
? ['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',
'transition-[border-radius,background-color,color,box-shadow,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'state-transition-fast',
$dimensions,
// A composite component (a split button's halves) passes the corners its place needs.
$corners ?? $corner.' '.$pressed[$size],
@@ -170,13 +183,13 @@
$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.
'after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2' => in_array($size, ['xs', 'sm'], true),
// 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)+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,
'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';
@@ -206,13 +219,13 @@
<{{ $tag }} {{ $attributes }}>
@if ($spinnerTarget)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
<x-livewire-material::loading :class="$iconSize" :label="false" />
<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" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-medium:size-6' => $fab])" />
<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
@@ -221,7 +234,7 @@
@endunless
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :class="$iconSize" />
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="$iconSize" />
@endif
@if ($tip !== null)
+31 -14
View File
@@ -1,6 +1,6 @@
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
<div class="fixed end-4 bottom-4">
<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
<x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" wire:click="uploadFolder" />
@@ -10,11 +10,18 @@
Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a
round close button in the colour itself while the list is open above it, end-aligned; the
list is a `popover="auto"` menu with the menu keyboard of `<x-menu>`. `label` names the FAB
for screen readers. Give the items the same `color`. Like `<x-menu>`'s, the list is keyed for
Livewire, so it stays open through a render of the component around it.
and the menu for screen readers, and defaults to "Toggle menu" — the icons inside the button
are aria-hidden, so without it the trigger has no name at all. Give the items the same
`color`. Like `<x-menu>`'s, the list is keyed for Livewire, so it stays open through a render
of the component around it.
It does not place itself. M3's margin from the edge of the window is 16dp, and 24dp from the
`large` class up: `fixed end-4 bottom-4 large:end-6 large:bottom-6`. A window too short for
the list scrolls it; the items rise into place as it opens and sink back as it closes.
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
above the close button. --}}
above the close button. The FAB's corner and colours morph on the two default springs at once
(`state-transition-default`, resources/css/components/actions.css). --}}
@props([
'icon' => 'add',
@@ -24,6 +31,10 @@
])
@php
// The trigger's only content is two icons, which are aria-hidden, so without a name it is an
// unnamed button. M3 asks the FAB's label to say what menu it opens; "Toggle menu" is its own
// fallback wording for the close button.
$label = filled($label) ? $label : __('Toggle menu');
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$anchor = "--material-fab-menu-{$key}";
@@ -43,15 +54,15 @@
>
<button
type="button"
@if ($label) aria-label="{{ $label }}" @endif
aria-label="{{ $label }}"
@class([
'group/fab state-layer focus-ring inline-flex size-14 shrink-0 cursor-pointer items-center justify-center rounded-corner-lg shadow-elevation-3 hover:shadow-elevation-4',
'transition-[border-radius,background-color,color,box-shadow] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default aria-expanded:rounded-corner-full',
'state-transition-default aria-expanded:rounded-corner-full',
$colours,
])
>
<x-livewire-material::icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
<x-livewire-material::icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
<x-livewire-material::icon :name="$icon" filled class="size-6 group-aria-expanded/fab:hidden" />
<x-livewire-material::icon name="close" filled optical="20" class="hidden size-5 group-aria-expanded/fab:block" />
</button>
</span>
@@ -61,17 +72,23 @@
id="material-fab-menu-{{ $key }}"
popover="auto"
role="menu"
@if ($label) aria-label="{{ $label }}" @endif
data-fab-menu
aria-label="{{ $label }}"
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@class([
'm-0 flex-col gap-1 overflow-visible border-0 bg-transparent p-0 open:flex [inset:auto]',
'mb-2 items-end [position-area:top_span-left]' => $position === 'top-end',
'mb-2 items-start [position-area:top_span-right]' => $position === 'top-start',
'mt-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
'mt-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
'm-0 flex-col gap-1 border-0 bg-transparent p-2 open:flex [inset:auto]',
// A landscape phone has no room for six 56px items: the list scrolls, the FAB below it
// does not. The 8px padding is the gap M3 leaves above the FAB and the room the items'
// elevation needs inside the scroll box; the negative margins put it back where it was.
'max-h-[calc(100dvh-8rem)] overflow-y-auto',
'transition-[display,overlay] transition-discrete duration-(--md-sys-motion-spatial-fast-duration)',
'-mx-2 -mt-2 items-end [position-area:top_span-left]' => $position === 'top-end',
'-mx-2 -mt-2 items-start [position-area:top_span-right]' => $position === 'top-start',
'-mx-2 -mb-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
'-mx-2 -mb-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
])
>
{{ $slot }}
+15 -5
View File
@@ -9,6 +9,17 @@
`<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>`.
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").
There is no `disabled`: M3 says never to disable a FAB — "if its action is unavailable,
remove the FAB entirely instead" — so hide it (`@if`), or leave it out of the render, rather
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
rail flattens a nested FAB to elevation 0.
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
(androidx Compose Material 3, Apache-2.0). --}}
@@ -21,7 +32,6 @@
'link' => null,
'external' => false,
'tooltip' => null,
'disabled' => false,
'type' => 'button',
])
@@ -36,7 +46,7 @@
: ['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-14 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-4 px-7 rounded-corner-xl type-headline-sm'][$size]
? ['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];
@@ -49,7 +59,6 @@
'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,
'disabled:cursor-not-allowed disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none',
])
->merge(array_filter([
'href' => $isLink ? $link : null,
@@ -57,7 +66,8 @@
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : $type,
'disabled' => ! $isLink && $disabled ? true : null,
// The hook a place uses to draw a nested FAB its own way: a rail flattens it to 0dp.
'data-fab' => true,
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
@@ -65,7 +75,7 @@
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-livewire-material::icon :name="$icon" :class="$iconSize" />
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
@endif
@if ($extended)
+14 -5
View File
@@ -10,7 +10,10 @@
bind as on any input, the arrow keys move the choice, and a screen reader announces a group.
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.
`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`).
ReStride's props, kept: `label`, `hint`, `hint-class`, `name` (needed with `x-model`, which
names no property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`,
@@ -43,9 +46,12 @@
$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';
// 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-3 type-label-lg',
'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',
@@ -57,6 +63,8 @@
: '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',
@@ -74,8 +82,9 @@
@foreach ($options as $option)
<label @class([
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
'transition-[border-radius,background-color,color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'min-w-0 flex-1' => ! $inline,
'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',
@@ -91,7 +100,7 @@
/>
@if (filled(data_get($option, $optionIcon)))
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
<x-livewire-material::icon :name="data_get($option, $optionIcon)" :optical="$optical" :class="$iconSize" />
@endif
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
@@ -3,7 +3,7 @@
@props(['label'])
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
<div aria-hidden="true" class="px-3 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
<div aria-hidden="true" class="px-4 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
{{ $slot }}
</div>
+18 -9
View File
@@ -4,11 +4,13 @@
under the label and a `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link`
makes it an anchor, with `wire:navigate` unless `external` or `no-wire-navigate`. `selected`
(true or false) makes it a `menuitemcheckbox` with `aria-checked`; a selected item takes
Expressive's selected shape and tertiary-container. `current` is for a menu of places rather
Expressive's selected shape and tertiary-container, and a `check` at its end unless it has an
`icon-right` of its own — M3 asks for a cue beyond the colour and the shape. `current` is for a menu of places rather
than choices — a section picker — and marks the page you are on: `aria-current="page"`, the
selected shape in secondary-container, the colour M3 gives the navigation indicator. `badge`
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, out of
reach. `keep-open` leaves the menu open when it is activated — for a choice the person may
draws `<x-badge>` at the end of the row: `true` for a dot, or a count. `disabled` keeps it in the list, reachable by the
keyboard but not selectable — M3 keeps a disabled item focusable so a person can find out it
is there. `keep-open` leaves the menu open when it is activated — for a choice the person may
want to change twice.
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
@@ -17,8 +19,12 @@
utilities wins depends on the order Tailwind emits them. A disabled item's icon stays
disabled.
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
12px at the ends of the list. --}}
48px tall, body-large label, 20px icons, 16px either side (SegmentedMenuTokens' Expressive
spacing), 4px corners that open to 12px at the ends of the list — the corner on the spatial
spring and the colour on the effects one (`state-transition-fast`). The row is 48px rather
than SegmentedMenuTokens' 44px: M3's menu page publishes 48dp and asks for ≥48×48 targets
inside an item's slots, and the library's own select and choices menus are 48px, so the more
binding source wins over the more specific one. --}}
@props([
'label' => null,
@@ -43,9 +49,9 @@
$attributes = $attributes
->class([
'group/item state-layer flex w-full min-h-11 cursor-pointer items-center gap-3 px-3 text-start outline-none',
'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none',
'rounded-corner-xs first:rounded-t-corner-md last:rounded-b-corner-md',
'transition-[border-radius,background-color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'state-transition-fast',
'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary',
'py-2' => filled($description),
'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true,
@@ -84,7 +90,7 @@
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-livewire-material::icon :name="$icon" :filled="$selected === true || $current" :class="$leadingIcon" />
<x-livewire-material::icon :name="$icon" optical="20" :filled="$selected === true || $current" :class="$leadingIcon" />
@endif
<span class="min-w-0 flex-1">
@@ -103,6 +109,9 @@
@endif
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :class="'size-5 '.$iconInk" />
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
@elseif ($selected === true)
{{-- The third cue M3 recommends, so a chosen item is not told by colour and shape alone. --}}
<x-livewire-material::icon name="check" optical="20" :class="'size-5 '.$iconInk" />
@endif
</{{ $tag }}>
@@ -1,3 +1,4 @@
{{-- A line between groups of items in an `<x-menu>`. --}}
{{-- A line between groups of items in an `<x-menu>`: 1px, with M3's 8px above and below it, inset
to the 16px the items keep either side. --}}
<hr role="separator" {{ $attributes->class('mx-3 my-1 h-px border-0 bg-outline-variant') }} />
<hr role="separator" {{ $attributes->class('mx-4 my-2 h-px border-0 bg-outline-variant') }} />
+14 -6
View File
@@ -32,6 +32,14 @@
of the loop iteration around it, which would give every child component after the menu the
same key.
A menu too long for the window scrolls, as M3 asks, rather than running off the edge of the
top layer where nothing can reach it: 18rem at most, and less on a short window. The arrow
keys, Home, End and typeahead bring the item they move to into view, and a disabled item is
among them: M3 keeps one reachable so a person can find out that it exists.
It opens by growing out of the corner nearest its trigger and fades as it goes
(`popover-transition`), which is the transition M3 asks to tie a menu to what opened it.
The container is Expressive's standard menu (surface-container-low, 16px corner, elevation
2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from
androidx Compose Material 3 (Apache-2.0). --}}
@@ -67,15 +75,15 @@
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@class([
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'm-0 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'popover-transition',
'bg-surface-container-low text-on-surface' => ! $vibrant,
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
'[position-area:bottom_span-right]' => $position === 'bottom-start',
'[position-area:bottom_span-left]' => $position === 'bottom-end',
'[position-area:top_span-right]' => $position === 'top-start',
'[position-area:top_span-left]' => $position === 'top-end',
'origin-top [position-area:bottom_span-right]' => $position === 'bottom-start',
'origin-top [position-area:bottom_span-left]' => $position === 'bottom-end',
'origin-bottom [position-area:top_span-right]' => $position === 'top-start',
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
])
>
{{ $slot }}
@@ -15,6 +15,12 @@
action's `wire:click` too — and resources/js/rich-tooltip.js keeps showing and hiding the
element on the page rather than one the morph took away.
resources/js/rich-tooltip.js points the trigger at the bubble with `aria-describedby`, and a
`persistent` one also carries `aria-haspopup="dialog"` and `aria-expanded`: the explanation is
the whole point of a rich tooltip, and without the association a screen reader reads only the
trigger's own label. The trigger arrives in a slot, so only script can find the element in it
that takes the focus.
RichTooltipTokens (androidx Compose Material 3, Apache-2.0): title-small subhead and body-medium
text in on-surface-variant, label-large actions in primary. --}}
@@ -13,7 +13,11 @@
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. The corners are resources/css/components/groups.css. --}}
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
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. --}}
@props([
'label' => null,
+1 -1
View File
@@ -17,7 +17,7 @@
<div {{ $attributes->class('flex flex-col gap-1 rounded-corner-lg bg-surface-container p-4') }}>
<div class="flex items-center gap-2 type-label-lg text-on-surface-variant">
@if ($icon)
<x-livewire-material::icon :name="$icon" class="size-5" />
<x-livewire-material::icon :name="$icon" optical="20" class="size-5" />
@endif
<span>{{ $title }}</span>
</div>
+29 -27
View File
@@ -7,32 +7,50 @@
dispatches from a Livewire component — and for `window.materialToast(title, options)` from
JavaScript (`{ type, description, timeout, sticky, action: { label, handler, event } }`).
Toasts queue and show in turn, each for its `timeout` (4s by default; M3 asks for 4–10s),
paused while the pointer or focus is on it. A toast with an action or no timeout gets a close
button. Pressing the action closes the snackbar, calls `handler` and dispatches `event` (a
name) on `window`; both may be given.
paused while the pointer or focus is on it. A toast with an `action` has no timeout at all,
as M3 requires — it waits to be read and acted on — unless the caller writes a `timeout` out.
A toast with an action or no timeout gets a close button. Pressing the action closes the
snackbar, calls `handler` and dispatches `event` (a name) on `window`; both may be given.
`sticky: true` keeps a toast until it is dismissed or its action pressed, without holding up
the queue: a toast that arrives meanwhile shows in its place, and the sticky one comes back
once the queue is empty. One is kept at a time; a newer sticky toast replaces it.
The live region is the host itself, not the snackbar: a region must be in the page before its
contents change for a screen reader to announce them reliably, and the snackbar comes and
goes. It is `aria-live="polite" aria-atomic="true"`, as M3 asks for a snackbar (never
assertive); a `type` picks the region's role — `alert` for an error or a warning, `status`
otherwise — and the explicit `aria-live` keeps even those polite.
Hooks for tests and styling: `data-toast` on the snackbar on screen, `data-toast-action` on its
action button.
`@persist` keeps the host across wire:navigate, so a toast dispatched with `redirectTo` is
still on screen when the next page arrives.
There is no state icon: M3 says to avoid one in a snackbar ("use a dialog instead if an icon
feels necessary"), and both lines of the message are plain inverse-on-surface — M3 gives the
supporting text no fourth colour and tells the two lines apart by position. The 40px action
and close buttons carry `touch-target`, which reaches M3's 48px without growing the container.
Escape dismisses a snackbar that holds the focus.
M3's snackbar (SnackbarTokens, androidx Compose Material 3, Apache-2.0): inverse surface,
body-medium text, a label-large action in inverse-primary, extra-small corners, elevation 3,
48px for one line. A type draws its state icon in the inverse state colour. `position`:
`bottom` (centred, the default) or `bottom-start`. It lifts above a bottom bar through
`--material-bottom-bar`. A compact window (below `medium`, 600px) gets the full-width snackbar;
from `medium` it hugs its line length instead, as M3 asks. --}}
48px for one line. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts
above a bottom bar through `--material-bottom-bar`, and publishes its own height as
`--material-snackbar-height` so a FAB can lift clear of it — M3: a snackbar appears above a
FAB, never in front of or behind one. A compact window (below `medium`, 600px) gets the
full-width snackbar; from `medium` it hugs its line length instead, as M3 asks. --}}
@props(['position' => 'bottom'])
@persist('material-toast')
<div
x-data="materialSnackbar"
x-bind:role="current && (current.type === 'error' || current.type === 'warning') ? 'alert' : 'status'"
role="status"
aria-live="polite"
aria-atomic="true"
{{ $attributes->class([
'pointer-events-none fixed inset-x-4 z-50 flex bottom-[calc(var(--material-bottom-bar,0px)+1rem)]',
'justify-center' => $position !== 'bottom-start',
@@ -43,40 +61,24 @@
<div
x-bind:key="current.id"
data-toast
x-bind:role="current.type === 'error' || current.type === 'warning' ? 'alert' : 'status'"
aria-live="polite"
x-on:mouseenter="pause()"
x-on:mouseleave="resume()"
x-on:focusin="pause()"
x-on:focusout="resume()"
class="pointer-events-auto flex min-h-12 w-full max-w-[min(100%,36rem)] items-center gap-3 rounded-corner-xs bg-inverse-surface py-1.5 ps-4 pe-2 text-inverse-on-surface shadow-elevation-3 transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-4 starting:opacity-0 medium:w-auto medium:min-w-86"
>
<template x-if="icon(current.type)">
<span class="flex shrink-0" x-bind:class="{
'text-inverse-success': current.type === 'success',
'text-inverse-error': current.type === 'error',
'text-inverse-warning': current.type === 'warning',
'text-inverse-info': current.type === 'info',
}">
<span x-show="current.type === 'success'"><x-livewire-material::icon name="check_circle" filled class="size-6" /></span>
<span x-show="current.type === 'error'"><x-livewire-material::icon name="error" filled class="size-6" /></span>
<span x-show="current.type === 'warning'"><x-livewire-material::icon name="warning" filled class="size-6" /></span>
<span x-show="current.type === 'info'"><x-livewire-material::icon name="info" filled class="size-6" /></span>
</span>
</template>
<div class="min-w-0 flex-1 py-1.5">
<p class="type-body-md" x-text="current.title"></p>
<p class="type-body-md opacity-80" x-show="current.description" x-text="current.description"></p>
<p class="type-body-md" x-show="current.description" x-text="current.description"></p>
</div>
<template x-if="current.action">
<button type="button" data-toast-action class="state-layer focus-ring h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
<button type="button" data-toast-action class="state-layer focus-ring touch-target h-10 shrink-0 rounded-corner-full px-3 type-label-lg text-inverse-primary" x-text="current.action.label" x-on:click="act()"></button>
</template>
<template x-if="current.action || ! current.timeout">
<button type="button" class="state-layer focus-ring inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
<x-livewire-material::icon name="close" class="size-5" />
<button type="button" class="state-layer focus-ring touch-target inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="dismiss()">
<x-livewire-material::icon name="close" optical="20" class="size-5" />
</button>
</template>
</div>
+3 -2
View File
@@ -5,8 +5,9 @@
<x-tooltip text="Copy link"><button …>…</button></x-tooltip>
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and
hides on leave, blur, press and Escape. A phone has no hover, so a control there carries its
It shows after a short hover on a pointer that can hover, at once on keyboard focus, and hides
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
positioning on `side` (`top`, `bottom`, `left`, `right`), flipping when there is no room.
@@ -12,7 +12,7 @@
<x-badge value="Draft" color="neutral" tonal />
<x-badge value="Archived" color="neutral" outline />
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
<x-badge tonal color="tertiary"><x-icon name="bolt" filled class="size-3" /> Pro</x-badge>
<x-badge tonal color="tertiary"><x-icon name="bolt" filled optical="20" class="size-3" /> Pro</x-badge>
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
BLADE,
'Snackbars' => <<<'BLADE'