Merge branch 'worktree-agent-a0d44b9ad5ca7814d'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 10:38:56 +02:00
22 changed files with 1332 additions and 239 deletions
@@ -8,8 +8,37 @@
Standard (the default): the buttons stand apart, and pressing a label button widens it while
its neighbours give way. `connected`: 2px apart with small inner corners, the shape that
replaced M3's segmented button; a selected (aria-pressed) button rounds fully. Give `size`
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.
the size of the buttons inside, so the spacing and corners match.
`selection` is M3's third button-group configuration `single`, `multi`, and either of them
with `required` ("selection-required"):
<x-button-group connected selection="single" required wire:model.live="view" label="View">
<x-button label="Day" value="day" variant="tonal" :selected="$view === 'day'" />
<x-button label="Week" value="week" variant="tonal" :selected="$view === 'week'" />
</x-button-group>
The group owns `aria-pressed` from then on: pressing a button writes the pressed one's `value`
(an array with `multi`) to `wire:model` or `x-model`, deselects the others in `single`, and
with `required` refuses the press that would leave nothing selected. Without a model it reads
the buttons' own `aria-pressed` once and takes it from there. A button with no `value` is
known by its label.
The group manages state and shape, not colour: each `<x-button>` draws its own selected
colours from its own `:selected`, which is why the example binds both from one property. This
is where `<x-group>` and `<x-button-group selection>` part, and neither absorbs the other
`<x-group>` is for a choice whose options are *data*: it renders real radios or checkboxes
from an `options` array, so it posts in a plain form, takes the browser's own keyboard, and
paints its own segments. `<x-button-group selection>` is for buttons you write yourself —
icons, tooltips, mixed content, a `wire:click` of their own — and never becomes a form
control. Reach for `<x-group>` first.
`shape` is M3's "Default shape | Round, square" configuration, and covers every button in the
group so it need not be written on each one: `square` squares a connected group's two ends to
the corner its inner edges take (M3's square table, 4/8/8/16/20dp by size) and gives a
standard group's buttons the square corner scale `<x-button shape="square">` draws. Selection
still morphs the other way — M3 has a toggle inside a group "swap shape square/round on
selection" — so a selected button in a square group rounds.
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
@@ -22,10 +51,21 @@
'connected' => false,
'size' => 'sm',
'label' => null,
'shape' => 'round',
'selection' => null,
'required' => false,
])
@php
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$shape = $shape === 'square' ? 'square' : 'round';
$selection = in_array($selection, ['single', 'multi'], true) ? $selection : null;
$multiple = $selection === 'multi';
$wire = $attributes->wire('model');
$model = $selection !== null && $wire->value() !== false;
// `wire:model` is entangled into the Alpine state below, so it must not also reach the div,
// where Livewire would find no input to bind. `x-model` stays: `x-modelable` pairs with it.
$attributes = $model ? $attributes->whereDoesntStartWith('wire:model') : $attributes;
@endphp
<div
@@ -33,6 +73,62 @@
@if ($label) aria-label="{{ $label }}" @endif
data-button-group="{{ $connected ? 'connected' : 'standard' }}"
data-size="{{ $size }}"
data-shape="{{ $shape }}"
@if ($selection !== null)
data-selection="{{ $selection }}"
@if ($required) data-selection-required @endif
x-data="{
multiple: {{ $multiple ? 'true' : 'false' }},
required: {{ $required ? 'true' : 'false' }},
@if ($model) value: @entangle($wire), @else value: {{ $multiple ? '[]' : 'null' }}, @endif
init() {
this.adopt();
this.$watch('value', () => this.paint());
},
segments() {
return [...this.$el.children].filter((child) => child.matches('button, a'));
},
name(segment) {
return segment.getAttribute('value') ?? segment.textContent.trim();
},
chosen() {
return (this.multiple ? this.value ?? [] : [this.value]).filter((each) => each !== null && each !== undefined && each !== '');
},
adopt() {
const carried = this.multiple ? (this.value ?? []).length > 0 : this.value !== null && this.value !== undefined;
if (! carried) {
const pressed = this.segments().filter((segment) => segment.getAttribute('aria-pressed') === 'true').map((segment) => this.name(segment));
this.value = this.multiple ? pressed : (pressed[0] ?? null);
}
this.paint();
},
press(event) {
const segment = event.target.closest('button, a');
if (! segment || ! this.segments().includes(segment) || segment.disabled || segment.getAttribute('aria-disabled') === 'true') return;
const name = this.name(segment);
const chosen = this.chosen();
const on = chosen.includes(name);
if (on && this.required && (! this.multiple || chosen.length === 1)) return;
this.value = this.multiple
? (on ? chosen.filter((each) => each !== name) : [...chosen, name])
: (on ? null : name);
},
paint() {
const chosen = this.chosen();
this.segments().forEach((segment) => segment.setAttribute('aria-pressed', String(chosen.includes(this.name(segment)))));
},
}"
x-on:click="press($event)"
@unless ($model) x-modelable="value" @endunless
@endif
{{ $attributes->class([
'inline-flex items-center',
'gap-0.5' => $connected,
+17 -1
View File
@@ -20,6 +20,14 @@
`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.
`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.
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
(androidx Compose Material 3, Apache-2.0). --}}
@@ -33,12 +41,14 @@
'external' => false,
'tooltip' => null,
'type' => 'button',
'collapseOnScroll' => false,
])
@php
$size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm';
$color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary';
$extended = filled($label) || $slot->isNotEmpty();
$collapsing = $collapseOnScroll && $extended && filled($icon);
$isLink = filled($link);
$colours = $variant === 'filled'
@@ -68,6 +78,10 @@
'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));
@@ -78,7 +92,9 @@
<x-livewire-material::icon :name="$icon" filled :class="$iconSize" />
@endif
@if ($extended)
@if ($collapsing)
<span data-fab-label><span>{{ $label ?? $slot }}</span></span>
@elseif ($extended)
<span>{{ $label ?? $slot }}</span>
@endif
+11 -2
View File
@@ -15,9 +15,14 @@
you never to reduce. Corners move on the spatial spring and colours on the effects one
(`state-transition-fast`).
`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),
and the chosen segment still rounds fully, so selection reads the same either way.
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`,
`variant`, `multiple`, `inline`. A validation message for the bound property replaces the hint.
`variant`, `shape`, `multiple`, `inline`. A validation message for the bound property replaces
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
@@ -35,6 +40,7 @@
'optionIcon' => 'icon',
'size' => 'sm',
'variant' => 'tonal',
'shape' => 'round',
'multiple' => false,
'inline' => false,
])
@@ -46,6 +52,9 @@
$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'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);
@@ -78,7 +87,7 @@
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
@endif
<div data-button-group="connected" data-size="{{ $size }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
<div data-button-group="connected" data-size="{{ $size }}" data-shape="{{ $shape }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
@foreach ($options as $option)
<label @class([
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
@@ -1,9 +1,48 @@
{{-- A labelled group of items in an `<x-menu>`: "Sort by", "Share with". --}}
{{-- A group of items in an `<x-menu>`, labelled ("Sort by", "Share with"), set apart by a gap, or
both.
@props(['label'])
M3 Expressive gives a menu two ways to break its items into clusters, and they are not
interchangeable:
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
<div aria-hidden="true" class="px-4 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
- `<x-menu-separator />`, a line. M3: "dividers are more subtle and are the right choice for
scrollable menus or text-field dropdowns", and "on web, use dividers to separate items".
The default answer, and the only one for a menu long enough to scroll.
- `<x-menu-group gap>`, the Expressive "Grouped" layout: no line, a gap. M3: "gaps are the
more expressive way to separate item clusters" but "limit to one or two gaps per menu",
"don't vary gap size", and "never use gaps in a scrollable menu" (unsupported).
{{ $slot }}
A gapped cluster sets its items 2px apart (SegmentedMenuTokens.SegmentedGap = 2dp) and holds
them in a box of their own, so the two ends of the cluster round the way the ends of a whole
list round and each cluster reads as one block. Clusters stand 8px apart, the same 8px a
separator keeps above and below its line, so a menu is the same height whichever it uses.
SegmentedMenuTokens' own GroupShape is 8dp where the library's list ends are 12dp; the
library's shape wins, so a cluster's ends and a list's ends match. --}}
@props([
'label' => null,
'gap' => false,
])
@php
$attributes = $attributes
->class([
'py-1 first:pt-0 last:pb-0' => ! $gap,
'not-first:mt-2' => $gap,
])
->merge(array_filter([
'role' => 'group',
'aria-label' => $label,
], fn ($value): bool => filled($value)));
@endphp
<div {{ $attributes }}>
@if (filled($label))
<div aria-hidden="true" class="px-4 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
@endif
@if ($gap)
<div class="space-y-0.5">{{ $slot }}</div>
@else
{{ $slot }}
@endif
</div>
+54 -2
View File
@@ -13,6 +13,14 @@
is there. `keep-open` leaves the menu open when it is activated for a choice the person may
want to change twice.
`submenu` turns the item into a menu of its own: the slot holds `<x-menu-item>`s instead of a
label, and they open in a second popover beside this one, on the item's end, flipping to its
start where the window has no room. The item says so — `aria-haspopup="menu"`,
`aria-expanded`, and a chevron at its end — and keeps the APG menu keyboard: Right, Enter or
Space open it on its first item, Left or Escape close it and come back here, and on a fine
pointer resting on the item opens it. Choosing anything inside closes the whole menu, as it
would from the outer list.
`icon-class` is for an icon whose colour means something of its own, a sport's glyph in the
sport's colour (`icon-class="text-sport-run"`). A colour there paints the icon, a selected
item's too: the icon's own colour then carries no specificity, because which of two colour
@@ -41,12 +49,18 @@
'badge' => null,
'disabled' => false,
'keepOpen' => false,
'submenu' => false,
])
@php
$isLink = filled($link);
$tag = $isLink ? 'a' : 'button';
// A submenu's own popover, named like the menu's: a new id and a new anchor name with every
// render, matched through a morph by the key rather than by either of them.
$key = $submenu ? \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
$anchor = $submenu ? "--material-submenu-{$key}" : null;
$attributes = $attributes
->class([
'group/item state-layer flex w-full min-h-12 cursor-pointer items-center gap-3 px-4 text-start outline-none',
@@ -64,12 +78,19 @@
'aria-current' => $current ? 'page' : null,
'aria-disabled' => $disabled ? 'true' : null,
'tabindex' => '-1',
'x-ref' => $submenu ? 'trigger' : null,
'style' => $submenu ? "anchor-name: {$anchor}" : null,
'aria-haspopup' => $submenu ? 'menu' : null,
'aria-expanded' => $submenu ? 'false' : null,
'aria-controls' => $submenu ? "material-submenu-{$key}" : null,
'x-on:click' => $submenu ? "toggle('first')" : null,
'type' => $isLink ? null : 'button',
'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,
'data-keep-open' => $keepOpen ? true : null,
// Opening a submenu is not choosing anything: the outer menu stays where it was.
'data-keep-open' => $keepOpen || $submenu ? true : null,
], fn ($value): bool => $value !== null));
$iconInk = match (true) {
@@ -88,13 +109,21 @@
};
@endphp
@if ($submenu)
<div x-data="materialSubmenu"
x-on:keydown.right.prevent.stop="open('first')"
x-on:pointerenter="hover($event)"
x-on:pointerleave="unhover()"
>
@endif
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-livewire-material::icon :name="$icon" optical="20" :filled="$selected === true || $current" :class="$leadingIcon" />
@endif
<span class="min-w-0 flex-1">
<span class="block truncate type-body-lg">{{ $label ?? $slot }}</span>
<span class="block truncate type-body-lg">{{ $submenu ? $label : ($label ?? $slot) }}</span>
@if ($description)
<span @class(['block type-body-md', $iconInk])>{{ $description }}</span>
@@ -110,8 +139,31 @@
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" optical="20" :class="'size-5 '.$iconInk" />
@elseif ($submenu)
{{-- M3's submenu marker: it points the way the list opens, and turns over in an RTL page. --}}
<x-livewire-material::icon name="chevron_right" optical="20" :class="'size-5 rtl:-scale-x-100 '.$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 }}>
@if ($submenu)
<div
x-ref="menu"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-submenu-'.substr(md5((string) $label), 0, 10)]) }}
id="material-submenu-{{ $key }}"
popover="auto"
role="menu"
data-submenu
aria-label="{{ $label }}"
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown.stop="navigate($event)"
x-on:click="activate($event)"
class="m-0 mx-1 min-w-28 max-w-70 max-h-[min(18rem,calc(100dvh-2rem))] origin-top overflow-y-auto border-0 p-1 rounded-corner-lg shadow-elevation-2 popover-transition [inset:auto] [position-area:inline-end_span-block-end] [position-try-fallbacks:flip-inline]"
>
{{ $slot }}
</div>
</div>
@endif
+51 -4
View File
@@ -40,6 +40,16 @@
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.
`filter` is M3's menu as a filtering surface ("autocomplete"): a text field at the top of the
list, which stays put while the list scrolls under it, narrowing the items to those whose
label holds what has been typed in the browser, over the items already rendered, so nothing
is fetched and a `wire:click` stays where it was. `filter="Find a person"` names the field;
bare `filter` calls it "Filter". The field, not the list, holds the focus, so a person can
type and steer at once: the arrow keys, Home and End move a highlighted row and say which one
through `aria-activedescendant`, and Enter chooses it the APG combobox keyboard, the same
one `<x-choices searchable>` uses. The list around it stays a `role="menu"` of its own inside
the popover, because a text field is not a thing a menu may contain.
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). --}}
@@ -48,12 +58,16 @@
'label' => null,
'position' => 'bottom-start',
'vibrant' => false,
'filter' => 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}";
$filtering = $filter !== false && $filter !== null && $filter !== '';
$filterLabel = is_string($filter) && filled($filter) ? $filter : __('Filter');
@endphp
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
@@ -68,14 +82,17 @@
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-menu']) }}
id="material-menu-{{ $key }}"
popover="auto"
role="menu"
@if ($label) aria-label="{{ $label }}" @endif
@unless ($filtering) role="menu" @endunless
data-menu
@if ($vibrant) data-vibrant @endif
@if ($label && ! $filtering) 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 max-h-[min(18rem,calc(100dvh-2rem))] overflow-y-auto 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 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'p-1' => ! $filtering,
'my-1 [position-try-fallbacks:flip-block,flip-inline,flip-block_flip-inline]',
'popover-transition',
'bg-surface-container-low text-on-surface' => ! $vibrant,
@@ -86,6 +103,36 @@
'origin-bottom [position-area:top_span-left]' => $position === 'top-end',
])
>
{{ $slot }}
@if ($filtering)
<div data-menu-filter>
<x-livewire-material::icon name="search" optical="20" class="size-5 shrink-0" />
<input
x-ref="filter"
type="text"
role="combobox"
autocomplete="off"
aria-autocomplete="list"
aria-expanded="true"
aria-controls="material-menu-{{ $key }}-list"
aria-label="{{ $filterLabel }}"
placeholder="{{ $filterLabel }}"
x-on:input="refine()"
x-on:keydown.stop="search($event)"
/>
</div>
<div {{ new \Illuminate\View\ComponentAttributeBag(array_filter([
'id' => "material-menu-{$key}-list",
'role' => 'menu',
'aria-label' => $label,
], fn ($value): bool => filled($value))) }} class="p-1">
{{ $slot }}
<p x-ref="empty" hidden class="px-4 py-3 type-body-md text-on-surface-variant">{{ __('Nothing matches') }}</p>
</div>
@else
{{ $slot }}
@endif
</div>
</div>
+14 -4
View File
@@ -34,9 +34,18 @@
and close buttons carry `touch-target`, which reaches M3's 48px without growing the container.
Escape dismisses a snackbar that holds the focus.
Alt+G moves the focus to a snackbar that carries an action, from wherever the page had it
M3 asks for a documented shortcut on the web, since a snackbar never takes the focus itself
and a keyboard has no other way to reach one. It does nothing when the snackbar on screen has
no action.
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. `position`: `bottom` (centred, the default) or `bottom-start`. It lifts
48px for one line and 68px for two (SnackbarTokens.TwoLinesContainerHeight; the site's prose
says 64dp, and the token is the more precise of the two). A description is that second line,
so the container is pinned to 68px whenever one is there rather than left to grow into it. On
a compact window a two-line snackbar with an action wraps the action below the text, which is
the third of M3's five snackbar configurations ("two lines with longer action"). `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
@@ -65,15 +74,16 @@
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"
x-bind:class="current.description ? 'min-h-17' : 'min-h-12'"
class="pointer-events-auto flex w-full max-w-[min(100%,36rem)] flex-wrap 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"
>
<div class="min-w-0 flex-1 py-1.5">
<div x-bind:class="current.description && current.action ? 'max-medium:basis-full' : ''" class="min-w-0 flex-1 py-1.5">
<p class="type-body-md" x-text="current.title"></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 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>
<button type="button" data-toast-action class="state-layer focus-ring touch-target ms-auto 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">
@@ -76,6 +76,47 @@
<x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group>
BLADE,
'A connected group that owns its selection' => <<<'BLADE'
<div x-data="{ view: 'week', marks: ['bold'] }" class="w-full space-y-6">
<x-button-group connected selection="single" required label="View" x-model="view">
<x-button label="Day" value="day" variant="tonal" :selected="false" />
<x-button label="Week" value="week" variant="tonal" :selected="true" />
<x-button label="Month" value="month" variant="tonal" :selected="false" />
</x-button-group>
<x-button-group connected selection="multi" label="Formatting" x-model="marks">
<x-button icon="format_bold" aria-label="Bold" value="bold" variant="tonal" :selected="true" />
<x-button icon="format_italic" aria-label="Italic" value="italic" variant="tonal" :selected="false" />
<x-button icon="format_underlined" aria-label="Underline" value="underline" variant="tonal" :selected="false" />
</x-button-group>
<p class="type-body-md text-on-surface-variant">
View: <code x-text="view"></code> · marks: <code x-text="marks.join(', ') || 'none'"></code>.
The shape follows at once; the colours come from each button's own <code>:selected</code>, which a Livewire render brings back.
</p>
</div>
BLADE,
'Square groups (hold a button down)' => <<<'BLADE'
<x-button-group label="Square standard" size="md" shape="square">
<x-button label="Day" variant="tonal" size="md" />
<x-button label="Week" variant="tonal" size="md" />
<x-button label="Month" variant="tonal" size="md" />
</x-button-group>
<x-button-group label="Square formatting" connected shape="square">
<x-button icon="format_bold" aria-label="Bold" variant="tonal" :selected="true" />
<x-button icon="format_italic" aria-label="Italic" variant="tonal" :selected="false" />
<x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group>
<div x-data="{ density: 'cosy' }" class="w-full">
<x-group label="Density" name="showcase-density" shape="square" x-model="density" inline :options="[
['id' => 'compact', 'name' => 'Compact'],
['id' => 'cosy', 'name' => 'Cosy'],
['id' => 'roomy', 'name' => 'Roomy'],
]" />
</div>
BLADE,
'A choice as a connected group' => <<<'BLADE'
<div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 medium:grid-cols-2">
<x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
@@ -114,6 +155,11 @@
<x-fab icon="add" label="New share" />
<x-fab icon="upload" label="Upload" size="md" variant="filled" />
BLADE,
'An extended FAB that collapses on scroll (scroll the page)' => <<<'BLADE'
<x-fab icon="edit" label="Compose" collapse-on-scroll />
<x-fab icon="upload" label="Upload" size="md" color="secondary" collapse-on-scroll />
<x-fab icon="add" label="New share" size="lg" color="tertiary" collapse-on-scroll />
BLADE,
'FAB menu' => <<<'BLADE'
<div class="flex h-72 w-full items-end justify-end">
<x-fab-menu label="New">
@@ -24,6 +24,19 @@
<x-button label="Sticky, with an event" variant="tonal" x-on:click="materialToast('A new version is ready', { type: 'info', sticky: true, action: { label: 'Reload', event: 'showcase:reload' } })" x-on:showcase:reload.window="materialToast('Reloading…')" />
</div>
BLADE,
'Two lines, and the keyboard shortcut that reaches them' => <<<'BLADE'
<div x-data class="w-full space-y-4">
<div class="flex flex-wrap items-center gap-4">
<x-button label="Two lines" variant="tonal" x-on:click="materialToast('Upload paused', { description: 'The connection dropped at 64%. It carries on when you are back.' })" />
<x-button label="Two lines, with an action" variant="tonal" x-on:click="materialToast('Upload paused', { description: 'The connection dropped at 64%. It carries on when you are back.', action: { label: 'Retry now', handler: () => materialToast('Retrying…') } })" />
</div>
<p class="type-body-md text-on-surface-variant">
A description makes the snackbar 68px, M3's two-line height; below <code>medium</code> the action wraps under the text.
Press <kbd class="rounded-corner-xs bg-surface-container-highest px-1 type-label-md">Alt</kbd>&nbsp;+&nbsp;<kbd class="rounded-corner-xs bg-surface-container-highest px-1 type-label-md">G</kbd> to move the focus to a snackbar that has an action.
</p>
</div>
BLADE,
'Plain tooltips' => <<<'BLADE'
<x-button icon="content_copy" tooltip="Copy link" />
<x-button icon="qr_code_2" tooltip-bottom="Show QR code" />
@@ -36,6 +36,77 @@
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
</x-menu>
BLADE,
'Submenus' => <<<'BLADE'
<x-menu label="Share actions">
<x-slot:trigger>
<x-button label="Share" icon="share" variant="tonal" />
</x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" shortcut="⌘C" />
<x-menu-item label="Send to" icon="send" submenu>
<x-menu-item label="A person" icon="person" />
<x-menu-item label="A team" icon="group" />
<x-menu-item label="Somewhere else" icon="more_horiz" submenu>
<x-menu-item label="Slack" icon="chat" />
<x-menu-item label="Email" icon="mail" />
</x-menu-item>
</x-menu-item>
<x-menu-item label="Export as" icon="download" submenu>
<x-menu-item label="ZIP" icon="folder_zip" />
<x-menu-item label="PDF" icon="picture_as_pdf" />
<x-menu-item label="CSV" icon="table" disabled />
</x-menu-item>
<x-menu-separator />
<x-menu-item label="Delete" icon="delete" />
</x-menu>
BLADE,
'Clusters: a gap, or a divider' => <<<'BLADE'
<x-menu label="Grouped by a gap">
<x-slot:trigger>
<x-button label="A gap" icon-right="arrow_drop_down" variant="outlined" />
</x-slot:trigger>
<x-menu-group gap>
<x-menu-item label="Cut" icon="content_cut" shortcut="⌘X" />
<x-menu-item label="Copy" icon="content_copy" shortcut="⌘C" />
<x-menu-item label="Paste" icon="content_paste" shortcut="⌘V" />
</x-menu-group>
<x-menu-group label="Then" gap>
<x-menu-item label="Rename" icon="edit" />
<x-menu-item label="Delete" icon="delete" />
</x-menu-group>
</x-menu>
<x-menu label="Separated by a divider">
<x-slot:trigger>
<x-button label="A divider" icon-right="arrow_drop_down" variant="outlined" />
</x-slot:trigger>
<x-menu-item label="Cut" icon="content_cut" shortcut="⌘X" />
<x-menu-item label="Copy" icon="content_copy" shortcut="⌘C" />
<x-menu-item label="Paste" icon="content_paste" shortcut="⌘V" />
<x-menu-separator />
<x-menu-item label="Rename" icon="edit" />
<x-menu-item label="Delete" icon="delete" />
</x-menu>
BLADE,
'A menu that filters as you type' => <<<'BLADE'
<x-menu label="Assign to" filter="Find a person">
<x-slot:trigger>
<x-button label="Assign to" icon="person_add" variant="tonal" />
</x-slot:trigger>
<x-menu-item label="Ada Lovelace" icon="person" description="Engineering" />
<x-menu-item label="Grace Hopper" icon="person" description="Engineering" />
<x-menu-item label="Katherine Johnson" icon="person" description="Research" />
<x-menu-item label="Mary Jackson" icon="person" description="Research" />
<x-menu-item label="Radia Perlman" icon="person" description="Networks" />
<x-menu-item label="Barbara Liskov" icon="person" description="Networks" />
<x-menu-separator />
<x-menu-item label="Nobody, for now" icon="person_off" />
</x-menu>
BLADE,
'Icons in their own colour' => <<<'BLADE'
<x-menu label="New plan">
<x-slot:trigger>
@@ -56,7 +127,8 @@
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-menu&gt;</code> with <code>&lt;x-menu-item&gt;</code>, <code>&lt;x-menu-group&gt;</code> and <code>&lt;x-menu-separator&gt;</code>.
Open one with the keyboard too: arrows, Home, End, a letter, Escape.
Open one with the keyboard too: arrows, Home, End, a letter, Escape. A <code>submenu</code> item opens a second list beside it
&mdash; Right to enter it, Left to come back.
</p>
@foreach ($examples as $title => $code)