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
@@ -107,6 +107,112 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
### `<x-button>`
Label button, icon button, toggle and responsive FAB in one component.
| Prop | Default | |
|---|---|---|
| `label` / slot | | the words; without them and with an `icon` it is an icon button |
| `variant` | `text` | `filled`, `tonal`, `outlined`, `elevated`, `text` |
| `color` (alias `tone`) | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
| `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning |
| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px |
| `shape` | `round` | or `square`; both square off further while pressed |
| `icon`, `icon-right` | | Material Symbol names |
| `width` | `default` | icon buttons only: `narrow`, `default`, `wide` |
| `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) |
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `lg`; `fab` is an extended FAB below `sm`, a filled button above |
```blade
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
<x-button icon="delete" tooltip="Delete share" wire:click="delete({{ $share->id }})" />
<x-button icon="favorite" aria-label="Keep" variant="tonal" :selected="$kept" wire:click="toggleKeep" />
```
### `<x-tooltip>`
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead.
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
```blade
<x-menu label="Share actions" position="bottom-end">
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" />
</x-slot:trigger>
<x-menu-group label="Sort by">
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="$set('sort', 'newest')" keep-open />
</x-menu-group>
<x-menu-separator />
<x-menu-item label="Settings" icon="settings" link="{{ route('settings') }}" />
<x-menu-item label="Delete" icon="delete" wire:click="delete" description="Recipients lose access" shortcut="⌘⌫" />
</x-menu>
```
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
### `<x-button-group>`
A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside.
### `<x-group>`
A choice between a few options as a connected button group of native radios (checkboxes with `multiple`):
```blade
<x-group label="Expires after" wire:model.live="expiry" :options="[
['id' => '1h', 'name' => '1 hour'],
['id' => '1d', 'name' => '1 day', 'icon' => 'today'],
['id' => '7d', 'name' => '7 days', 'disabled' => true],
]" hint="Recipients lose access after that" />
```
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
### `<x-split-button>`
```blade
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
</x-split-button>
```
Attributes go to the leading button; the slot is the menu. `variant` (`filled` default, `tonal`, `outlined`, `elevated`), `color`, `size`, `disabled`, `spinner`, `menu-label`, `position`.
### `<x-fab>`
`<x-fab icon="add" tooltip="New share" />``size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4">`). `link`, `external`, `disabled`, `type`.
### `<x-fab-menu>`, `<x-fab-menu-item>`
```blade
<div class="fixed end-4 bottom-4">
<x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
<x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
</x-fab-menu>
</div>
```
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
### `<x-loading>`
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured by class; `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
```blade
<x-loading />
<x-loading contained class="size-8" label="Uploading" />
<div wire:loading.flex wire:target="upload"><x-loading /></div>
```
`<x-button spinner>` shows a decorative one in place of its icon.
## Testing the design
```php
+84
View File
@@ -0,0 +1,84 @@
/*
* Button groups and split buttons: shapes that depend on a button's place among its siblings.
*
* Unlayered on purpose. Each <x-button> draws its own corners and padding as utilities, and a
* rule in any @layer loses to a utility whatever its specificity; the group has to win.
*
* Standard group (`<x-button-group>`): pressing a label button widens it and narrows its
* neighbours by the same amount, so the row keeps its length — Expressive's press expansion
* (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size). Icon buttons keep
* their width.
*
* Connected group (`<x-button-group connected>`, `<x-group>`): 2px apart, the outer corners
* full, the inner corners small, smaller still while pressed, and a selected segment fully round
* (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec).
*
* Split button (`<x-split-button>`): the same idea for two halves; the trailing half turns
* round and its chevron turns over while its menu is open (SplitButton*Tokens).
*/
[data-button-group] {
--group-inner: var(--md-sys-shape-corner-sm);
--group-inner-pressed: var(--md-sys-shape-corner-xs);
}
[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; }
[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; }
[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; }
[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); }
[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); }
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) {
padding-inline: calc(var(--group-pad) + var(--group-grow));
}
[data-button-group='standard'] > :not([data-icon-button]):has(+ :not([data-icon-button]):active:not(:disabled, [aria-disabled='true'])) {
padding-inline-end: calc(var(--group-pad) - var(--group-grow));
}
[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-icon-button]) {
padding-inline-start: calc(var(--group-pad) - var(--group-grow));
}
/*
* Connected segments and split halves take their inner corner from `--group-corner`, so pressing
* or selecting changes one variable and the rounded outer corners stay put.
*/
[data-button-group='connected'] > *,
[data-split] {
--group-corner: var(--group-inner);
border-start-start-radius: var(--group-corner);
border-end-start-radius: var(--group-corner);
border-start-end-radius: var(--group-corner);
border-end-end-radius: var(--group-corner);
}
[data-button-group='connected'] > :active,
[data-split]:active {
--group-corner: var(--group-inner-pressed);
}
[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)),
[data-split='trailing'][aria-expanded='true'] {
--group-corner: var(--md-sys-shape-corner-full);
}
[data-button-group='connected'] > :first-child,
[data-split='leading'] {
border-start-start-radius: var(--md-sys-shape-corner-full);
border-end-start-radius: var(--md-sys-shape-corner-full);
}
[data-button-group='connected'] > :last-child,
[data-split='trailing'] {
border-start-end-radius: var(--md-sys-shape-corner-full);
border-end-end-radius: var(--md-sys-shape-corner-full);
}
[data-split='trailing'] svg {
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
[data-split='trailing'][aria-expanded='true'] svg {
rotate: 180deg;
}
+1
View File
@@ -20,6 +20,7 @@
@import './tokens/font.css';
@import './tokens/theme.css';
@import './tokens/state.css';
@import './components/groups.css';
@layer base {
html {
+2
View File
@@ -9,3 +9,5 @@
*/
import './theme.js'
import './figure.js'
import './tooltip.js'
import './menu.js'
+172
View File
@@ -0,0 +1,172 @@
/**
* `materialMenu`: the behaviour of `<x-menu>` — WAI-ARIA's menu button pattern on a popover.
*
* The menu button is the trigger's first button or link. Its ARIA attributes are written by
* script, which a Livewire morph removes along with anything else the server did not render,
* so they are written again whenever the trigger is used.
*/
const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]'
// A popover="auto" closes on the press that lands on its trigger, and the click that follows
// would open it again. A close this recent is taken as that press.
const REOPEN_GUARD_MS = 250
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialMenu', () => ({
closedAt: -Infinity,
returnFocus: true,
listeners: [],
init() {
const menu = this.$refs.menu
this.label()
// Only closes the browser starts — Escape, a press outside — arrive here alone; open()
// and close() have already done their part, synchronously, because this event is
// queued and a screen reader or a test reading aria-expanded in between would be told
// the menu is shut.
this.listen(menu, 'toggle', (event) => {
const opened = event.newState === 'open'
this.control()?.setAttribute('aria-expanded', String(opened))
if (opened) {
return
}
this.closedAt = performance.now()
if (this.returnFocus && menu.contains(document.activeElement)) {
this.control()?.focus()
}
})
// A press outside closes the menu without pulling focus back to the trigger.
this.listen(document, 'pointerdown', (event) => {
if (!menu.contains(event.target) && !this.$refs.trigger.contains(event.target)) {
this.returnFocus = false
}
})
},
control() {
return this.$refs.trigger.querySelector('button, a[href], [tabindex]')
},
label() {
const control = this.control()
if (!control) {
return
}
control.setAttribute('aria-haspopup', 'menu')
control.setAttribute('aria-controls', this.$refs.menu.id)
control.setAttribute('aria-expanded', String(this.isOpen()))
},
isOpen() {
return this.$refs.menu.matches(':popover-open')
},
open(focus = 'first') {
this.label()
if (!this.isOpen()) {
this.$refs.menu.showPopover()
this.returnFocus = true
}
this.control()?.setAttribute('aria-expanded', 'true')
this.focusItem(focus)
},
close() {
if (this.isOpen()) {
this.$refs.menu.hidePopover()
}
this.control()?.setAttribute('aria-expanded', 'false')
},
toggle(focus = 'first') {
if (this.isOpen()) {
this.close()
} else if (performance.now() - this.closedAt > REOPEN_GUARD_MS) {
this.open(focus)
}
},
items() {
return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.getAttribute('aria-disabled') !== 'true')
},
focusItem(which) {
const items = this.items()
;(which === 'last' ? items.at(-1) : items[0])?.focus()
},
navigate(event) {
const items = this.items()
const current = items.indexOf(document.activeElement)
const move = (index) => {
event.preventDefault()
items[(index + items.length) % items.length]?.focus()
}
switch (event.key) {
case 'ArrowDown':
return move(current + 1)
case 'ArrowUp':
return move(current < 0 ? items.length - 1 : current - 1)
case 'Home':
return move(0)
case 'End':
return move(items.length - 1)
case 'Escape':
this.returnFocus = true
return
case 'Tab':
this.returnFocus = false
this.close()
return
}
// Typeahead: a printable letter moves to the next item whose label starts with it.
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
const letter = event.key.toLowerCase()
const ordered = [...items.slice(current + 1), ...items.slice(0, current + 1)]
const match = ordered.find((item) => item.textContent.trim().toLowerCase().startsWith(letter))
if (match) {
event.preventDefault()
match.focus()
}
}
},
activate(event) {
const item = event.target.closest(ITEMS)
if (!item || item.getAttribute('aria-disabled') === 'true' || item.hasAttribute('data-keep-open')) {
return
}
this.close()
},
listen(target, type, handler) {
target.addEventListener(type, handler)
this.listeners.push(() => target.removeEventListener(type, handler))
},
destroy() {
this.listeners.forEach((remove) => remove())
},
}))
})
+57
View File
@@ -0,0 +1,57 @@
/**
* `materialTooltip`: shows an `<x-tooltip>` popover for the control it belongs to.
*
* The trigger is the popover's parent element (the button, or the standalone wrapper). Hover
* counts only for a pointer that can hover, after a short delay so a pointer passing over a
* toolbar does not flash every label; keyboard focus shows it at once. A press, leaving, blur
* and Escape hide it.
*/
const HOVER_DELAY_MS = 500
document.addEventListener('alpine:init', () => {
window.Alpine.data('materialTooltip', () => ({
timer: null,
listeners: [],
init() {
const tip = this.$el
const trigger = tip.parentElement
const open = () => tip.matches(':popover-open')
const show = (delay) => {
clearTimeout(this.timer)
this.timer = setTimeout(() => {
if (tip.isConnected && !open()) {
tip.showPopover()
}
}, delay)
}
const hide = () => {
clearTimeout(this.timer)
if (open()) {
tip.hidePopover()
}
}
this.listen(trigger, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
this.listen(trigger, 'pointerleave', hide)
this.listen(trigger, 'pointerdown', hide)
this.listen(trigger, 'focusin', () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0))
this.listen(trigger, 'focusout', hide)
this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide())
},
listen(target, type, handler) {
target.addEventListener(type, handler)
this.listeners.push(() => target.removeEventListener(type, handler))
},
destroy() {
clearTimeout(this.timer)
this.listeners.forEach((remove) => remove())
},
}))
})
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor"><path d="M31.49 13.69c.31.23.71.35 1.13.32.98-.07 1.96-.15 2.95-.22 1.26-.1 2.19 1.16 1.71 2.34-.38.92-.75 1.84-1.12 2.76-.32.77-.03 1.66.68 2.09.84.52 1.68 1.04 2.52 1.56 1.08.66 1.09 2.23.01 2.9-.84.52-1.69 1.05-2.53 1.57-.71.44-1 1.33-.68 2.1.38.91.75 1.82 1.13 2.74.48 1.17-.44 2.44-1.7 2.35-.99-.08-1.98-.15-2.97-.22-.83-.06-1.59.49-1.78 1.3-.24.96-.47 1.92-.7 2.88-.3 1.23-1.79 1.72-2.76.9-.76-.64-1.52-1.28-2.27-1.92-.64-.54-1.57-.54-2.21 0-.75.64-1.5 1.28-2.26 1.92-.96.83-2.45.34-2.76-.89-.23-.96-.47-1.93-.71-2.89-.2-.81-.95-1.36-1.79-1.29-.98.07-1.96.15-2.95.22-1.26.1-2.19-1.16-1.71-2.34.38-.92.75-1.84 1.12-2.76.32-.77.03-1.66-.68-2.09-.84-.52-1.68-1.04-2.52-1.56-1.08-.66-1.09-2.23-.01-2.9.84-.52 1.69-1.05 2.53-1.57.71-.44 1-1.33.68-2.1-.38-.91-.75-1.82-1.13-2.74-.48-1.17.44-2.44 1.7-2.35.99.08 1.98.15 2.97.22.83.06 1.59-.49 1.78-1.3.24-.96.47-1.92.7-2.88.3-1.23 1.79-1.72 2.76-.9.76.64 1.52 1.28 2.27 1.92.64.54 1.57.54 2.21 0 .75-.64 1.5-1.28 2.26-1.92.96-.83 2.45-.34 2.76.89.23.96.47 1.93.71 2.89.1.4.34.74.66.97Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,42 @@
{{-- An M3 Expressive button group: related `<x-button>`s in a row.
<x-button-group size="md" label="Text formatting">
<x-button icon="format_bold" aria-label="Bold" variant="tonal" :selected="$bold" wire:click="toggleBold" />
</x-button-group>
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.
Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The
shapes are resources/css/components/groups.css. --}}
@props([
'connected' => false,
'size' => 'sm',
'label' => null,
])
@php
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
@endphp
<div
role="group"
@if ($label) aria-label="{{ $label }}" @endif
data-button-group="{{ $connected ? 'connected' : 'standard' }}"
data-size="{{ $size }}"
{{ $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),
]) }}
>
{{ $slot }}
</div>
+226
View File
@@ -0,0 +1,226 @@
{{-- A Material 3 Expressive button label button, icon button or toggle in one component.
`variant` is M3's style: `filled`, `tonal`, `outlined`, `elevated` or `text` (the default).
`color` is the role it is drawn in: `primary` (the default), `secondary`, `tertiary`, `error`,
`success`, `warning`, `info`; `tone` is the same prop under ReStride's name. The shorthands
`primary`, `danger` and `caution` mean filled primary, filled error and filled warning.
Unknown values fall back to the defaults rather than rendering nothing.
`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.
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` 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.
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
on-surface-variant, which its own source marks as wrong.
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 `lg`; `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 below `sm`, a filled button above it one element either way.
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. --}}
@props([
'label' => null,
'icon' => null,
'iconRight' => null,
'link' => null,
'external' => false,
'noWireNavigate' => false,
'variant' => null,
'color' => null,
'tone' => null,
'primary' => false,
'danger' => false,
'caution' => false,
'size' => 'sm',
'shape' => 'round',
'width' => 'default',
'selected' => null,
'spinner' => null,
'responsive' => false,
'tooltip' => null,
'tooltipLeft' => null,
'tooltipRight' => null,
'tooltipBottom' => null,
'fab' => false,
'disabled' => false,
'type' => 'button',
'corners' => null,
])
@php
$variant = match (true) {
in_array($variant, ['filled', 'tonal', 'outlined', 'text', 'elevated'], true) => $variant,
$primary || $danger || $caution || $fab => 'filled',
default => 'text',
};
$color = match (true) {
$danger => 'error',
$caution => 'warning',
in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) => $color ?? $tone,
default => 'primary',
};
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$square = $shape === 'square';
$width = in_array($width, ['narrow', 'default', 'wide'], true) ? $width : 'default';
$iconOnly = filled($icon) && blank($label) && $slot->isEmpty();
$isLink = filled($link);
$inert = $disabled && $isLink;
$tip = $tooltip ?? $tooltipLeft ?? $tooltipRight ?? $tooltipBottom;
$tipSide = match (true) {
$tooltipLeft !== null => 'left',
$tooltipRight !== null => 'right',
$tooltipBottom !== null => 'bottom',
default => 'top',
};
$anchor = $tip !== null ? '--material-button-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
$spinnerTarget = match (true) {
$spinner === true, $spinner === 1, $spinner === '1' => $attributes->whereStartsWith('wire:click')->first(),
is_string($spinner) && $spinner !== '' => $spinner,
default => null,
};
// Every colour class written out whole, so Tailwind compiles it.
$ink = [
'primary' => 'text-primary', 'secondary' => 'text-secondary', 'tertiary' => 'text-tertiary',
'error' => 'text-error', 'success' => 'text-success', 'warning' => 'text-warning', 'info' => 'text-info',
];
$fill = [
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary',
'tertiary' => 'bg-tertiary text-on-tertiary', 'error' => 'bg-error text-on-error',
'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
];
$container = [
'primary' => 'bg-secondary-container text-on-secondary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container',
'tertiary' => 'bg-tertiary-container text-on-tertiary-container', 'error' => 'bg-error-container text-on-error-container',
'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container',
'info' => 'bg-info-container text-on-info-container',
];
$containerSelected = ['primary' => 'bg-secondary text-on-secondary'] + $fill;
$quietInk = ['primary' => 'text-on-surface-variant'] + $ink;
$colours = match ($variant) {
'filled' => $selected === false ? 'bg-surface-container text-on-surface-variant' : $fill[$color],
'tonal' => $selected === true ? $containerSelected[$color] : $container[$color],
'outlined' => $selected === true ? 'bg-inverse-surface text-inverse-on-surface border-transparent' : 'border-outline-variant '.$quietInk[$color],
'elevated' => $selected === true ? $fill[$color] : 'bg-surface-container-low '.$ink[$color],
'text' => match (true) {
$iconOnly => $selected === true ? $ink[$color] : $quietInk[$color],
default => $selected === true ? $container[$color] : $ink[$color],
},
};
$squareCorners = ['xs' => 'rounded-corner-md', 'sm' => 'rounded-corner-md', 'md' => 'rounded-corner-lg', 'lg' => 'rounded-corner-xl', 'xl' => 'rounded-corner-xl'];
$pressed = ['xs' => 'active:rounded-corner-sm', 'sm' => 'active:rounded-corner-sm', 'md' => 'active:rounded-corner-md', 'lg' => 'active:rounded-corner-lg', 'xl' => 'active:rounded-corner-lg'];
$corner = match (true) {
$iconOnly && $selected === true => $square ? 'rounded-corner-full' : $squareCorners[$size],
$selected === true, $square => $squareCorners[$size],
default => 'rounded-corner-full',
};
$dimensions = $iconOnly
? [
'xs' => ['narrow' => 'h-8 w-7', 'default' => 'size-8', 'wide' => 'h-8 w-10'],
'sm' => ['narrow' => 'h-10 w-8', 'default' => 'size-10', 'wide' => 'h-10 w-13'],
'md' => ['narrow' => 'h-14 w-12', 'default' => 'size-14', 'wide' => 'h-14 w-18'],
'lg' => ['narrow' => 'h-24 w-16', 'default' => 'size-24', 'wide' => 'h-24 w-32'],
'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'],
][$size][$width]
: [
'xs' => 'h-8 gap-2 px-3 type-label-lg',
'sm' => 'h-10 gap-2 px-4 type-label-lg',
'md' => 'h-14 gap-2 px-6 type-title-md',
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
][$size];
$iconSize = $iconOnly
? ['xs' => 'size-5', 'sm' => 'size-6', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size]
: ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
$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',
$dimensions,
// A composite component (a split button's halves) passes the corners its place needs.
$corners ?? $corner.' '.$pressed[$size],
$colours,
$outline => $variant === 'outlined',
'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true),
'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated',
// Below 48px the touch target reaches past the button, as M3 requires.
'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),
'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, below sm only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares.
'max-sm:fixed max-sm:end-4 max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-sm:z-30 max-sm:h-14 max-sm:gap-2 max-sm:px-4 max-sm:rounded-corner-lg max-sm:type-title-md max-sm:bg-primary-container max-sm:text-on-primary-container max-sm:shadow-elevation-3' => $fab,
];
$tag = $isLink ? 'a' : 'button';
$attributes = $attributes
->class($classes)
->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
'aria-disabled' => $inert ? 'true' : null,
'tabindex' => $inert ? '-1' : null,
'type' => $isLink ? null : $type,
'disabled' => ! $isLink && $disabled ? true : null,
'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null,
'aria-pressed' => $selected === null ? null : ($selected ? 'true' : 'false'),
'data-icon-button' => $iconOnly ? true : null,
'wire:loading.attr' => $spinnerTarget ? 'disabled' : null,
'wire:target' => $spinnerTarget,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($spinnerTarget)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
<x-loading :class="$iconSize" :label="false" />
</span>
@endif
@if ($icon)
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
<x-icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
</span>
@endif
@unless ($iconOnly)
<span @class(['max-lg:hidden' => $responsive])>{{ $label ?? $slot }}</span>
@endunless
@if ($iconRight)
<x-icon :name="$iconRight" :class="$iconSize" />
@endif
@if ($tip !== null)
<x-tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
@endif
</{{ $tag }}>
@@ -0,0 +1,47 @@
{{-- One action in an `<x-fab-menu>`: a 56px pill with an icon and a label, rising into place as
the menu opens. `link` makes it an anchor (with `wire:navigate` unless `external`); `color`
should match the menu's. --}}
@props([
'label' => null,
'icon' => null,
'color' => 'primary',
'link' => null,
'external' => false,
])
@php
$isLink = filled($link);
$tag = $isLink ? 'a' : 'button';
$colours = [
'primary' => 'bg-primary-container text-on-primary-container',
'secondary' => 'bg-secondary-container text-on-secondary-container',
'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
][in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'];
$attributes = $attributes
->class([
'state-layer inline-flex h-14 shrink-0 cursor-pointer items-center gap-2 rounded-corner-full px-6 whitespace-nowrap type-title-md shadow-elevation-3 outline-none',
'focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary',
'transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-2 starting:opacity-0',
$colours,
])
->merge(array_filter([
'role' => 'menuitem',
'tabindex' => '-1',
'type' => $isLink ? null : 'button',
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-icon :name="$icon" class="size-6" />
@endif
<span>{{ $label ?? $slot }}</span>
</{{ $tag }}>
@@ -0,0 +1,77 @@
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
<div class="fixed end-4 bottom-4">
<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" />
</x-fab-menu>
</div>
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`.
FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px
above the close button. --}}
@props([
'icon' => 'add',
'label' => null,
'color' => 'primary',
'position' => 'top-end',
])
@php
$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}";
$colours = [
'primary' => 'bg-primary-container text-on-primary-container aria-expanded:bg-primary aria-expanded:text-on-primary',
'secondary' => 'bg-secondary-container text-on-secondary-container aria-expanded:bg-secondary aria-expanded:text-on-secondary',
'tertiary' => 'bg-tertiary-container text-on-tertiary-container aria-expanded:bg-tertiary aria-expanded:text-on-tertiary',
][$color];
@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')"
>
<button
type="button"
@if ($label) aria-label="{{ $label }}" @endif
@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',
$colours,
])
>
<x-icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
<x-icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
</button>
</span>
<div
x-ref="menu"
id="material-fab-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 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',
])
>
{{ $slot }}
</div>
</div>
+78
View File
@@ -0,0 +1,78 @@
{{-- An M3 Expressive floating action button: the screen's primary action.
With only an `icon` it is a FAB — `size` `sm` 56px (the default; M3's deprecated small FAB is
gone, so this is the baseline FAB), `md` 80px, `lg` 96px. With a `label` it is an extended
FAB at the same three heights. `color` is `primary` (the default), `secondary` or `tertiary`,
drawn in its container, or in the colour itself with `variant="filled"`.
It does not place itself: put it where the layout wants it, e.g.
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens
(androidx Compose Material 3, Apache-2.0). --}}
@props([
'icon' => null,
'label' => null,
'size' => 'sm',
'color' => 'primary',
'variant' => 'container',
'link' => null,
'external' => false,
'tooltip' => null,
'disabled' => false,
'type' => 'button',
])
@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();
$isLink = filled($link);
$colours = $variant === 'filled'
? ['primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary'][$color]
: ['primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container'][$color];
$dimensions = $extended
? ['sm' => 'h-14 min-w-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' => '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];
$anchor = $tooltip !== null ? '--material-fab-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null;
$tag = $isLink ? 'a' : 'button';
$attributes = $attributes
->class([
'state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap',
'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast',
$dimensions,
$colours,
'disabled:cursor-not-allowed disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none',
])
->merge(array_filter([
'href' => $isLink ? $link : null,
'target' => $isLink && $external ? '_blank' : null,
'rel' => $isLink && $external ? 'noopener' : null,
'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null,
'type' => $isLink ? null : $type,
'disabled' => ! $isLink && $disabled ? true : null,
'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null,
'style' => $anchor ? "anchor-name: {$anchor}" : null,
], fn ($value): bool => $value !== null));
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-icon :name="$icon" :class="$iconSize" />
@endif
@if ($extended)
<span>{{ $label ?? $slot }}</span>
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</{{ $tag }}>
@@ -0,0 +1,97 @@
{{-- A choice of a few options as an M3 Expressive connected button group the successor of the
segmented button.
<x-group label="Theme" wire:model.live="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
['id' => 'dark', 'name' => 'Dark', 'icon' => 'dark_mode'],
]" />
Native radios under the segments (checkboxes with `multiple`), so `wire:model` and `x-model`
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.
ReStride's props, kept: `label`, `hint`, `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. --}}
@props([
'label' => null,
'hint' => null,
'name' => null,
'options' => [],
'optionValue' => 'id',
'optionLabel' => 'name',
'optionIcon' => 'icon',
'size' => 'sm',
'variant' => 'tonal',
'multiple' => false,
'inline' => false,
])
@php
$model = $attributes->whereStartsWith('wire:model')->first();
$name ??= $model;
$messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : [];
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$segment = [
'xs' => 'h-8 gap-2 px-3 type-label-lg',
'sm' => 'h-10 gap-2 px-4 type-label-lg',
'md' => 'h-14 gap-2 px-6 type-title-md',
'lg' => 'h-24 gap-3 px-12 type-headline-sm',
'xl' => 'h-34 gap-4 px-16 type-headline-lg',
][$size];
$iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size];
$colours = match ($variant) {
'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary',
'outlined' => 'border border-outline-variant text-on-surface-variant has-checked:border-transparent has-checked:bg-inverse-surface has-checked:text-inverse-on-surface',
default => 'bg-secondary-container text-on-secondary-container has-checked:bg-secondary has-checked:text-on-secondary',
};
@endphp
<fieldset {{ $attributes->only(['class', 'wire:key'])->class('min-w-0') }}>
@if (filled($label))
<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])>
@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,
$segment,
$colours,
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
'has-disabled:cursor-not-allowed has-disabled:bg-on-surface/10 has-disabled:text-on-surface/38',
])>
<input
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
type="{{ $multiple ? 'checkbox' : 'radio' }}"
name="{{ $multiple ? $name.'[]' : $name }}"
value="{{ data_get($option, $optionValue) }}"
@disabled(data_get($option, 'disabled'))
class="peer sr-only"
/>
@if (filled(data_get($option, $optionIcon)))
<x-icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
@endif
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
</label>
@endforeach
</div>
@if ($messages !== [])
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
@endif
</fieldset>
@@ -0,0 +1,43 @@
{{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while
it turns, for a wait that has no known length.
In the text colour — primary unless the caller colours it — and 48px unless sized. `contained`
puts it on a primary-container circle, for a spinner over content. It is a `progressbar`
named `label` ("Loading" by default); pass `:label="false"` where something else already
says what is happening, as a button's own spinner does.
Pure SVG with SMIL, so it animates in every engine without script. Under
`prefers-reduced-motion` the shape rests. The geometry is androidx Compose Material 3's
LoadingIndicator and Morph, ported in bin/loading-indicator.mjs (Apache-2.0):
LoadingIndicatorTokens' 38px indicator in a 48px container, a morph every 650ms on a spring,
a quarter turn per morph and a full turn every 4666ms. --}}
@props([
'contained' => false,
'label' => null,
])
@php
$sized = preg_match('/(^|\s)(size|w|h)-/', (string) $attributes->get('class')) === 1;
$coloured = preg_match('/(^|\s)text-(?!(xs|sm|base|lg|xl|[2-9]xl|left|center|right|start|end)(\s|$))/', (string) $attributes->get('class')) === 1;
$decorative = $label === false;
$label = $decorative ? null : ($label ?? __('Loading'));
$attributes = $attributes
->class([
'inline-flex shrink-0 items-center justify-center',
'size-12' => ! $sized,
'rounded-corner-full bg-primary-container text-on-primary-container' => $contained,
'text-primary' => ! $contained && ! $coloured,
])
->merge(array_filter([
'role' => $decorative ? null : 'progressbar',
'aria-label' => $label,
'aria-hidden' => $decorative ? 'true' : null,
]));
@endphp
<span {{ $attributes }}>
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['class' => 'size-full motion-reduce:hidden', 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['class' => 'hidden size-full motion-reduce:block', 'aria-hidden' => 'true', 'focusable' => 'false'])) }}
</span>
@@ -0,0 +1,9 @@
{{-- A labelled group of items in an `<x-menu>`: "Sort by", "Share with". --}}
@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>
{{ $slot }}
</div>
@@ -0,0 +1,81 @@
{{-- One item in an `<x-menu>`: an action, a link, or a choice.
`label`, a leading `icon`, an `icon-right`, a `description` 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. `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 want to change twice.
44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to
12px at the ends of the list. --}}
@props([
'label' => null,
'icon' => null,
'iconRight' => null,
'description' => null,
'shortcut' => null,
'link' => null,
'external' => false,
'noWireNavigate' => false,
'selected' => null,
'disabled' => false,
'keepOpen' => false,
])
@php
$isLink = filled($link);
$tag = $isLink ? 'a' : 'button';
$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',
'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',
'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,
'pointer-events-none text-on-surface/38' => $disabled,
])
->merge(array_filter([
'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox',
'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'),
'aria-disabled' => $disabled ? 'true' : null,
'tabindex' => '-1',
'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,
], fn ($value): bool => $value !== null));
$iconInk = match (true) {
$disabled => 'text-on-surface/38',
$selected === true => 'text-on-tertiary-container',
default => 'text-on-surface-variant',
};
@endphp
<{{ $tag }} {{ $attributes }}>
@if ($icon)
<x-icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
@endif
<span class="min-w-0 flex-1">
<span class="block truncate type-body-lg">{{ $label ?? $slot }}</span>
@if ($description)
<span @class(['block type-body-md', $iconInk])>{{ $description }}</span>
@endif
</span>
@if ($shortcut)
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
@endif
@if ($iconRight)
<x-icon :name="$iconRight" :class="'size-5 '.$iconInk" />
@endif
</{{ $tag }}>
@@ -0,0 +1,3 @@
{{-- A line between groups of items in an `<x-menu>`. --}}
<hr role="separator" {{ $attributes->class('mx-3 my-1 h-px border-0 bg-outline-variant') }} />
+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>
@@ -0,0 +1,71 @@
{{-- An M3 Expressive split button: an action, and a menu of its alternatives.
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
<x-menu-item label="Download as ZIP" wire:click="downloadZip" />
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
</x-split-button>
Attributes (wire:click, spinner…) go to the leading button; the slot is the menu. `variant` is
`filled` (the default), `tonal`, `outlined` or `elevated` a text split button does not exist
in M3 with `color` and `size` as on `<x-button>`. The halves sit 2px apart; the trailing
one rounds fully and turns its chevron over while the menu is open. `menu-label` names the
trailing button and the menu for screen readers.
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. --}}
@props([
'label' => null,
'icon' => null,
'variant' => 'filled',
'color' => 'primary',
'size' => 'sm',
'disabled' => false,
'spinner' => null,
'menuLabel' => null,
'position' => 'bottom-end',
])
@php
$variant = in_array($variant, ['filled', 'tonal', 'outlined', 'elevated'], true) ? $variant : 'filled';
$size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm';
$menuLabel ??= __('More options');
$leadingPadding = ['xs' => 'pe-2.5', 'sm' => 'pe-3', 'md' => '', 'lg' => '', 'xl' => ''][$size];
$trailingWidth = ['xs' => 'w-12', 'sm' => 'w-12', 'md' => '', 'lg' => '', 'xl' => ''][$size];
@endphp
<div data-button-group="split" data-size="{{ $size }}" {{ $attributes->only('class')->class('inline-flex items-center gap-0.5') }}>
<x-button
{{ $attributes->except('class') }}
:label="$label"
:icon="$icon"
:variant="$variant"
:color="$color"
:size="$size"
:disabled="$disabled"
:spinner="$spinner"
corners=""
data-split="leading"
:class="$leadingPadding"
/>
<x-menu :position="$position" :label="$menuLabel">
<x-slot:trigger>
<x-button
icon="keyboard_arrow_down"
:aria-label="$menuLabel"
:variant="$variant"
:color="$color"
:size="$size"
:disabled="$disabled"
corners=""
data-split="trailing"
:class="$trailingWidth"
/>
</x-slot:trigger>
{{ $slot }}
</x-menu>
</div>
@@ -0,0 +1,51 @@
{{-- An M3 plain tooltip: a short label for a control, in the inverse surface.
Two ways in. Inside a component that already names its own anchor `<x-button tooltip="…">`
passes `anchor` it is a bare popover. Standalone, it wraps its trigger:
<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
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.
It is aria-hidden: an icon button takes the same words as its aria-label, and a labelled
control would otherwise read them twice. --}}
@props([
'text',
'side' => 'top',
'anchor' => null,
])
@php
$side = in_array($side, ['top', 'bottom', 'left', 'right'], true) ? $side : 'top';
$standalone = $anchor === null;
$anchor ??= '--material-tooltip-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
@endphp
@if ($standalone)
<span class="inline-flex" style="anchor-name: {{ $anchor }}">
{{ $slot }}
@endif
<span
popover="manual"
aria-hidden="true"
x-data="materialTooltip"
style="position-anchor: {{ $anchor }}"
@class([
'pointer-events-none m-0 max-w-50 overflow-visible border-0 rounded-corner-xs bg-inverse-surface px-2 py-1 text-center whitespace-normal type-body-sm text-inverse-on-surface [inset:auto]',
'opacity-0 transition-[opacity,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'my-1 [position-area:top] [position-try-fallbacks:flip-block]' => $side === 'top',
'my-1 [position-area:bottom] [position-try-fallbacks:flip-block]' => $side === 'bottom',
'mx-1 [position-area:left] [position-try-fallbacks:flip-inline]' => $side === 'left',
'mx-1 [position-area:right] [position-try-fallbacks:flip-inline]' => $side === 'right',
])
>{{ $text }}</span>
@if ($standalone)
</span>
@endif
@@ -0,0 +1,19 @@
{{-- One showcase example: the Blade in `code`, rendered, with the source beside it one string,
so what is shown and what is written can never disagree. --}}
@props(['title' => null, 'code', 'stack' => false])
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4">
@if ($title)
<h3 class="type-title-md">{{ $title }}</h3>
@endif
<div @class(['flex flex-wrap items-center gap-4' => ! $stack, 'space-y-4' => $stack])>
{!! \Illuminate\Support\Facades\Blade::render($code) !!}
</div>
<details>
<summary class="w-fit cursor-pointer rounded-corner-xs type-label-md text-on-surface-variant focus-ring">Blade</summary>
<pre class="mt-2 overflow-x-auto rounded-corner-sm bg-surface-container-highest p-3 type-body-sm"><code>{{ trim($code) }}</code></pre>
</details>
</div>
+2
View File
@@ -12,5 +12,7 @@
@include('livewire-material::showcase.sections.elevation')
@include('livewire-material::showcase.sections.motion')
@include('livewire-material::showcase.sections.icons')
@include('livewire-material::showcase.sections.buttons')
@include('livewire-material::showcase.sections.menus')
</main>
@endsection
+2 -2
View File
@@ -18,12 +18,12 @@
<a href="{{ route('livewire-material.showcase') }}" class="type-title-lg">Livewire Material</a>
<nav class="flex flex-wrap gap-x-4 gap-y-1 type-label-lg text-on-surface-variant" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons'] as $anchor => $section)
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach
</nav>
<div class="ms-auto flex rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data>
<div class="ms-auto flex rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak>
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
<button
type="button"
@@ -0,0 +1,139 @@
@php
$examples = [
'Variants' => <<<'BLADE'
<x-button label="Filled" variant="filled" />
<x-button label="Tonal" variant="tonal" />
<x-button label="Outlined" variant="outlined" />
<x-button label="Elevated" variant="elevated" />
<x-button label="Text" />
BLADE,
'Sizes, with an icon' => <<<'BLADE'
<x-button label="Extra small" icon="upload" variant="filled" size="xs" />
<x-button label="Small" icon="upload" variant="filled" />
<x-button label="Medium" icon="upload" variant="filled" size="md" />
<x-button label="Large" icon="upload" variant="filled" size="lg" />
<x-button label="Extra large" icon="upload" variant="filled" size="xl" />
BLADE,
'Square, and pressed (hold the pointer down)' => <<<'BLADE'
<x-button label="Round" variant="tonal" size="md" />
<x-button label="Square" variant="tonal" size="md" shape="square" />
BLADE,
'Colours' => <<<'BLADE'
<x-button label="Primary" primary />
<x-button label="Secondary" variant="filled" color="secondary" />
<x-button label="Tertiary" variant="filled" color="tertiary" />
<x-button label="Delete" icon="delete" danger />
<x-button label="Take down" caution />
<x-button label="Success" variant="tonal" color="success" />
<x-button label="Info" variant="outlined" color="info" />
BLADE,
'Toggle buttons' => <<<'BLADE'
<x-button label="Filled" variant="filled" :selected="false" />
<x-button label="Selected" variant="filled" :selected="true" />
<x-button label="Tonal" variant="tonal" :selected="false" />
<x-button label="Selected" variant="tonal" :selected="true" />
<x-button label="Outlined" variant="outlined" :selected="false" />
<x-button label="Selected" variant="outlined" :selected="true" />
BLADE,
'Icon buttons, and their tooltips' => <<<'BLADE'
<x-button icon="favorite" tooltip="Standard" />
<x-button icon="favorite" tooltip="Filled" variant="filled" />
<x-button icon="favorite" tooltip="Tonal" variant="tonal" />
<x-button icon="favorite" tooltip="Outlined" variant="outlined" />
<x-button icon="favorite" tooltip="Selected" variant="filled" :selected="true" />
<x-button icon="favorite" tooltip="Selected, square" variant="tonal" shape="square" :selected="true" />
BLADE,
'Icon button sizes and widths' => <<<'BLADE'
<x-button icon="share" tooltip="XS narrow" variant="tonal" size="xs" width="narrow" />
<x-button icon="share" tooltip="S" variant="tonal" />
<x-button icon="share" tooltip="M wide" variant="tonal" size="md" width="wide" />
<x-button icon="share" tooltip="L" variant="tonal" size="lg" />
<x-button icon="share" tooltip="XL wide" variant="tonal" size="xl" width="wide" />
BLADE,
'Links and disabled' => <<<'BLADE'
<x-button label="Opens a page" link="#buttons" variant="outlined" />
<x-button label="Leaves the site" link="https://m3.material.io" external icon-right="open_in_new" />
<x-button label="Disabled" variant="filled" disabled />
<x-button label="Disabled link" link="#buttons" variant="tonal" disabled />
<x-button icon="delete" tooltip="Disabled" variant="outlined" disabled />
BLADE,
'Loading indicator' => <<<'BLADE'
<x-loading />
<x-loading contained />
<x-loading class="size-24 text-tertiary" label="Uploading" />
<x-loading contained class="size-8" />
BLADE,
'Button groups: standard (press a button) and connected' => <<<'BLADE'
<x-button-group label="Standard" size="md">
<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="Formatting" connected>
<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>
BLADE,
'A choice as a connected group (<x-group>)' => <<<'BLADE'
<div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 md:grid-cols-2">
<x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
['id' => 'dark', 'name' => 'Dark', 'icon' => 'dark_mode'],
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
]" />
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Choose any" :options="[
['id' => 'mon', 'name' => 'Mon'],
['id' => 'tue', 'name' => 'Tue'],
['id' => 'wed', 'name' => 'Wed'],
['id' => 'thu', 'name' => 'Thu', 'disabled' => true],
]" />
</div>
BLADE,
'Split buttons' => <<<'BLADE'
<x-split-button label="Download all" icon="download" menu-label="Download options">
<x-menu-item label="As a ZIP" icon="folder_zip" />
<x-menu-item label="One by one" icon="download" />
</x-split-button>
<x-split-button label="Share" variant="tonal" size="md" menu-label="Share options">
<x-menu-item label="Copy link" icon="content_copy" />
<x-menu-item label="Email" icon="mail" />
</x-split-button>
<x-split-button label="Save" variant="outlined" size="xs" menu-label="Save options">
<x-menu-item label="Save as draft" />
</x-split-button>
BLADE,
'FABs and extended FABs' => <<<'BLADE'
<x-fab icon="add" tooltip="New share" />
<x-fab icon="edit" size="md" color="secondary" tooltip="Edit" />
<x-fab icon="upload" size="lg" color="tertiary" tooltip="Upload" />
<x-fab icon="add" label="New share" />
<x-fab icon="upload" label="Upload" size="md" variant="filled" />
BLADE,
'FAB menu' => <<<'BLADE'
<div class="flex h-72 w-full items-end justify-end">
<x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" />
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" />
<x-fab-menu-item label="Paste text" icon="content_paste" />
</x-fab-menu>
</div>
BLADE,
];
@endphp
<section id="buttons" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Buttons</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-button&gt;</code>: label buttons, icon buttons and toggles in M3 Expressive's five sizes.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
@@ -2,16 +2,19 @@
id="icons"
class="scroll-mt-24 space-y-6"
x-data="{
names: {{ json_encode(\NoNameWeb\LivewireMaterial\Support\SvgFile::symbolNames()) }},
base: '{{ rtrim(route('livewire-material.showcase', [], false), '/') }}/symbols',
names: [],
query: '',
filled: false,
async load() {
this.names = await (await fetch('{{ route('livewire-material.symbols', [], false) }}')).json();
},
get matches() {
const query = this.query.trim().toLowerCase().replaceAll(' ', '_');
return (query === '' ? this.names : this.names.filter((name) => name.includes(query))).slice(0, 120);
},
}"
x-intersect.once="load()"
>
<h2 class="type-headline-md">Icons</h2>
@@ -37,7 +40,7 @@
<span
class="size-6 bg-current"
x-bind:style="{
mask: `url('${base}/${filled ? 'filled' : 'outlined'}/${name}.svg') center / contain no-repeat`,
mask: `url('{{ rtrim(route('livewire-material.showcase', [], false), '/') }}/symbols/${filled ? 'filled' : 'outlined'}/${name}.svg') center / contain no-repeat`,
}"
></span>
<code class="w-full break-all type-label-sm text-on-surface-variant" x-text="name"></code>
@@ -0,0 +1,53 @@
@php
$examples = [
'A menu' => <<<'BLADE'
<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" shortcut="⌘C" />
<x-menu-item label="Download" icon="download" />
<x-menu-item label="Rename" icon="edit" description="Change what recipients see" />
<x-menu-separator />
<x-menu-item label="Delete" icon="delete" />
</x-menu>
<x-menu label="Sort" position="bottom-end">
<x-slot:trigger>
<x-button label="Sort" icon-right="arrow_drop_down" variant="outlined" />
</x-slot:trigger>
<x-menu-group label="Sort by">
<x-menu-item label="Newest" :selected="true" keep-open />
<x-menu-item label="Largest" :selected="false" keep-open />
<x-menu-item label="Expiring soon" :selected="false" keep-open />
</x-menu-group>
<x-menu-separator />
<x-menu-item label="Reset" icon="restart_alt" disabled />
</x-menu>
<x-menu label="Vibrant" vibrant>
<x-slot:trigger>
<x-button label="Vibrant" variant="tonal" color="tertiary" />
</x-slot:trigger>
<x-menu-item label="Upload files" icon="upload_file" />
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
</x-menu>
BLADE,
];
@endphp
<section id="menus" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Menus</h2>
<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.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>