Merge branch 'worktree-agent-a0d44b9ad5ca7814d'
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> + <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><x-menu></code> with <code><x-menu-item></code>, <code><x-menu-group></code> and <code><x-menu-separator></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
|
||||
— Right to enter it, Left to come back.
|
||||
</p>
|
||||
|
||||
@foreach ($examples as $title => $code)
|
||||
|
||||
Reference in New Issue
Block a user