Add buttons, menus and the rest of M3 Expressive's actions
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s

<x-button> (label buttons, icon buttons and toggles in five sizes, with
filled, tonal, outlined, elevated and text variants in any colour role),
<x-tooltip>, <x-menu> with items, groups and separators, <x-button-group>,
<x-group> as a connected button group, <x-split-button>, <x-fab>,
<x-fab-menu> and <x-loading>. Sizes, colours and shapes come from
androidx Compose Material 3's tokens; the loading indicator ports its
Morph into SVG + SMIL.

Menus follow WAI-ARIA's menu button pattern on popovers placed by CSS
anchor positioning. Browser tests run in Chromium, Firefox and WebKit.
The showcase fetches the icon names on demand: inlined, they tripped
Pest's test server into HTTP 431s under Firefox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 06:09:28 +02:00
co-authored by Claude Opus 5
parent b48e879254
commit cd64f4f371
46 changed files with 2968 additions and 36 deletions
+68
View File
@@ -0,0 +1,68 @@
{{-- An M3 Expressive menu: a list of actions that opens from a trigger.
<x-menu label="Share actions">
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" />
</x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" wire:click="copy" />
<x-menu-separator />
<x-menu-item label="Delete" icon="delete" wire:click="delete" />
</x-menu>
The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded,
aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor
positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping
when there is no room; a click outside or Escape closes it. The keyboard is WAI-ARIA's menu
button: Enter, Space or ArrowDown open on the first item, ArrowUp on the last; arrows, Home,
End and typing a letter move between items; Tab closes; activating an item closes the menu
unless the item says `keep-open`, and Escape returns focus to the trigger.
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). --}}
@props([
'label' => null,
'position' => 'bottom-start',
'vibrant' => false,
])
@php
$position = in_array($position, ['bottom-start', 'bottom-end', 'top-start', 'top-end'], true) ? $position : 'bottom-start';
$key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$anchor = "--material-menu-{$key}";
@endphp
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
<span x-ref="trigger" class="inline-flex" style="anchor-name: {{ $anchor }}"
x-on:click="toggle('first')"
x-on:keydown.down.prevent="open('first')"
x-on:keydown.up.prevent="open('last')"
>{{ $trigger }}</span>
<div
x-ref="menu"
id="material-menu-{{ $key }}"
popover="auto"
role="menu"
@if ($label) aria-label="{{ $label }}" @endif
tabindex="-1"
style="position-anchor: {{ $anchor }}"
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]',
'my-1 [position-try-fallbacks: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',
'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',
])
>
{{ $slot }}
</div>
</div>