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 410s),
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'