Add cards, lists, dialogs, sheets and the rest of M3's containment
tests / lint (push) Failing after 2m6s
tests / feature (8.4) (push) Successful in 1m3s
tests / feature (8.5) (push) Successful in 1m7s
tests / browser (safari, webkit) (push) Successful in 3m14s
tests / browser (chrome, chromium) (push) Successful in 2m9s
tests / browser (firefox, firefox) (push) Successful in 2m29s
tests / lint (push) Failing after 2m6s
tests / feature (8.4) (push) Successful in 1m3s
tests / feature (8.5) (push) Successful in 1m7s
tests / browser (safari, webkit) (push) Successful in 3m14s
tests / browser (chrome, chromium) (push) Successful in 2m9s
tests / browser (firefox, firefox) (push) Successful in 2m29s
<x-card> (filled, elevated, outlined), <x-list> and <x-list-item> (plain or M3 Expressive's segmented list), <x-divider>, <x-collapse> on <details>, <x-modal> on the native <dialog>, <x-drawer> as a side sheet or list-detail pane, and <x-bottom-sheet> with drag to dismiss. Dialogs and sheets bind to a Livewire flag or id and write back false or null on close, or use the surrounding Alpine scope. Rows open from anywhere on them through data-list-row and data-list-open. DesignGuard now also reports Blade directives written inside a component tag, where they do not compile. Browser test helpers wait for a complete document with Alpine and Livewire running: in Firefox, networkidle alone could return before a repeated visit had loaded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
e57063b0f4
commit
fef20a9178
@@ -0,0 +1,75 @@
|
||||
{{-- An M3 bottom sheet: secondary content or actions anchored to the bottom of the screen.
|
||||
|
||||
Modal by default: over a scrim, the page inert and still, dismissed by the scrim, Escape, or a
|
||||
downward drag on its handle (or anywhere on the sheet when its content is scrolled to the top).
|
||||
`standard` makes it part of the page instead — no scrim, nothing inert, and it stays until closed.
|
||||
The open state works as for `<x-drawer>`: `wire:model` (a flag or an id, written back `false` or
|
||||
`null` on close) or `open` in the surrounding Alpine scope; `close()` is in scope inside it.
|
||||
|
||||
SheetBottomTokens (androidx Compose Material 3, Apache-2.0): surface-container-low, extra-large
|
||||
top corners, elevation 1, a 32×4px drag handle in on-surface-variant; 640px wide at most, centred
|
||||
on a wide screen; it rises on emphasized decelerate. `title` and `actions` as on a dialog.
|
||||
`height` caps it (default: 90% of the screen); content scrolls inside. --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
'standard' => false,
|
||||
'height' => '90dvh',
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$id = $attributes->get('id') ?? 'material-bottom-sheet-'.substr(md5($model.'|'.$title), 0, 10);
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
...materialBottomSheet({{ $standard ? 'true' : 'false' }}),
|
||||
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
||||
}"
|
||||
x-on:keydown.window.escape="if (open && ! standard) close()"
|
||||
>
|
||||
@unless ($standard)
|
||||
<div x-cloak x-show="open" x-transition.opacity.duration.200ms x-on:click="close()" class="fixed inset-0 z-40 bg-scrim/32" aria-hidden="true"></div>
|
||||
@endunless
|
||||
|
||||
<section
|
||||
x-cloak
|
||||
x-show="open"
|
||||
x-ref="sheet"
|
||||
@unless ($standard) x-trap.inert.noscroll="open" @endunless
|
||||
x-transition:enter="transition-[translate] duration-(--md-sys-motion-spatial-default-duration) ease-emphasized-decelerate"
|
||||
x-transition:enter-start="translate-y-full"
|
||||
x-transition:enter-end="translate-y-0"
|
||||
x-transition:leave="transition-[translate] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate"
|
||||
x-transition:leave-start="translate-y-0"
|
||||
x-transition:leave-end="translate-y-full"
|
||||
x-bind:style="dragged ? { translate: `0 ${dragged}px`, transition: 'none' } : {}"
|
||||
x-on:pointerdown="dragStart($event)"
|
||||
id="{{ $id }}"
|
||||
role="dialog"
|
||||
@unless ($standard) aria-modal="true" @endunless
|
||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||
style="--sheet-max-height: {{ $height }}"
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[env(safe-area-inset-bottom)] text-on-surface shadow-elevation-1',
|
||||
$attributes->get('class'),
|
||||
]) }}
|
||||
>
|
||||
<div class="flex shrink-0 cursor-grab justify-center py-4 active:cursor-grabbing" data-drag-handle>
|
||||
<button type="button" class="h-1 w-8 rounded-corner-full bg-on-surface-variant/40 outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary" aria-label="{{ __('Close') }}" x-on:click="close()"></button>
|
||||
</div>
|
||||
|
||||
<div x-ref="body" class="min-h-0 flex-1 overflow-y-auto px-6 pb-6">
|
||||
@if (filled($title))
|
||||
<h2 id="{{ $id }}-title" class="mb-4 type-title-lg">{{ $title }}</h2>
|
||||
@endif
|
||||
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@isset($actions)
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6">{{ $actions }}</div>
|
||||
@endisset
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
{{-- An M3 card: content and actions about one subject.
|
||||
|
||||
`variant` is M3's three (FilledCardTokens, ElevatedCardTokens, OutlinedCardTokens, androidx
|
||||
Compose Material 3, Apache-2.0): `filled` (surface-container-highest, the default),
|
||||
`elevated` (surface-container-low at elevation 1) and `outlined` (surface, an outline-variant
|
||||
edge); all with a medium corner. maryUI's props keep their meaning: `title`, `subtitle`,
|
||||
`separator` (a divider under the header), and the `menu` (top-end, beside the title),
|
||||
`figure` (full-bleed media on top) and `actions` (end-aligned, under the content) slots.
|
||||
|
||||
A card that opens something is a row: give it `data-list-row` and one `data-list-open`
|
||||
control inside (the title link or a button), and a press anywhere on it reaches that control
|
||||
while its other buttons keep their own (resources/js/list-rows.js). It answers with a state
|
||||
layer and its corner opening a step. Never a stretched link, and never a whole-card `<a>`
|
||||
around buttons.
|
||||
|
||||
Do not pass a `bg-*` class to change its fill — it races the card's own in Tailwind's emit
|
||||
order; use `variant`, or colour a wrapper inside. --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
'subtitle' => null,
|
||||
'variant' => 'filled',
|
||||
'separator' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$variant = in_array($variant, ['filled', 'elevated', 'outlined'], true) ? $variant : 'filled';
|
||||
@endphp
|
||||
|
||||
<div
|
||||
data-card
|
||||
{{ $attributes->class([
|
||||
'relative flex flex-col overflow-hidden rounded-corner-md text-on-surface',
|
||||
'bg-surface-container-highest' => $variant === 'filled',
|
||||
'bg-surface-container-low shadow-elevation-1' => $variant === 'elevated',
|
||||
'border border-outline-variant bg-surface' => $variant === 'outlined',
|
||||
]) }}
|
||||
>
|
||||
@isset($figure)
|
||||
<div class="shrink-0 overflow-hidden [&>img]:w-full [&>img]:object-cover">{{ $figure }}</div>
|
||||
@endisset
|
||||
|
||||
<div class="flex flex-1 flex-col gap-4 p-4">
|
||||
@if ($title || $subtitle || isset($menu))
|
||||
<div>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
@if ($title)
|
||||
<h3 class="type-title-md">{{ $title }}</h3>
|
||||
@endif
|
||||
|
||||
@if ($subtitle)
|
||||
<p class="mt-0.5 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@isset($menu)
|
||||
<div class="-me-2 -mt-2 flex shrink-0 items-center gap-1">{{ $menu }}</div>
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
@if ($separator)
|
||||
<x-divider class="mt-4" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($slot->isNotEmpty())
|
||||
<div class="flex-1 type-body-md">{{ $slot }}</div>
|
||||
@endif
|
||||
|
||||
@isset($actions)
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">{{ $actions }}</div>
|
||||
@endisset
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,42 @@
|
||||
{{-- A section that opens to show more: an FAQ answer, advanced settings.
|
||||
|
||||
Not an M3 component; built on the native `<details>` so it opens without script, keeps its
|
||||
state through a Livewire morph (`wire:ignore.self` stops the server's HTML from closing it),
|
||||
and is announced as a disclosure. Drawn in M3's terms: a title-medium `title` (or a
|
||||
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
|
||||
and — where the browser supports animating `details` content (`interpolate-size`) — a height
|
||||
that eases open. `open` starts it open. `variant`: `plain` (the default, on the surface around
|
||||
it) or `filled` (a surface-container tile with a large corner). --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
'icon' => null,
|
||||
'open' => false,
|
||||
'variant' => 'plain',
|
||||
])
|
||||
|
||||
<details
|
||||
wire:ignore.self
|
||||
@if ($open) open @endif
|
||||
{{ $attributes->class([
|
||||
'group/collapse [interpolate-size:allow-keywords]',
|
||||
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
|
||||
]) }}
|
||||
>
|
||||
<summary @class([
|
||||
'state-layer focus-ring flex min-h-12 cursor-pointer list-none items-center gap-3 rounded-[inherit] type-title-md [&::-webkit-details-marker]:hidden',
|
||||
'px-4' => $variant === 'filled',
|
||||
])>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="size-6 text-on-surface-variant" />
|
||||
@endif
|
||||
|
||||
<span class="min-w-0 flex-1">{{ $heading ?? $title }}</span>
|
||||
|
||||
<x-icon name="expand_more" class="size-6 text-on-surface-variant transition-[rotate] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast group-open/collapse:rotate-180" />
|
||||
</summary>
|
||||
|
||||
<div @class(['pb-4 type-body-md text-on-surface-variant', 'px-4' => $variant === 'filled', 'pt-1'])>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,24 @@
|
||||
{{-- An M3 divider: a thin outline-variant line between groups of content (DividerTokens).
|
||||
|
||||
Horizontal by default; `vertical` stands it between items in a row (give the row a height).
|
||||
`inset` indents it from the start by 16px, as under a list's leading icon; `middle` from both
|
||||
ends. It is `role="separator"`; with `decorative` it is hidden from assistive tech. --}}
|
||||
|
||||
@props([
|
||||
'vertical' => false,
|
||||
'inset' => false,
|
||||
'middle' => false,
|
||||
'decorative' => false,
|
||||
])
|
||||
|
||||
<div
|
||||
@if ($decorative) aria-hidden="true" @else role="separator" aria-orientation="{{ $vertical ? 'vertical' : 'horizontal' }}" @endif
|
||||
{{ $attributes->class([
|
||||
'shrink-0 bg-outline-variant',
|
||||
'h-px w-auto' => ! $vertical,
|
||||
'w-px self-stretch' => $vertical,
|
||||
'ms-4' => $inset && ! $vertical,
|
||||
'mx-4' => $middle && ! $vertical,
|
||||
'my-2' => $middle && $vertical,
|
||||
]) }}
|
||||
></div>
|
||||
@@ -0,0 +1,137 @@
|
||||
{{-- An M3 side sheet: detail or controls that slide in from the edge over a scrim; on a phone it is
|
||||
the whole screen. With `pane`, from `xl` it is a list-detail pane beside the list instead.
|
||||
|
||||
The open state is the Livewire property in `wire:model` (entangled live, so a detail held in the
|
||||
URL follows a close) — a flag or an id — and closing writes back `false` or `null`. Without
|
||||
`wire:model` it reads and writes `open` in the Alpine scope around it. `close()` is in scope for
|
||||
anything inside the sheet, so it can draw its own close button.
|
||||
|
||||
As a sheet it is modal: the page inert and still (`x-trap.inert.noscroll`), and it enters on
|
||||
emphasized decelerate rather than a spring — a sheet anchored to the edge that overshot would
|
||||
open a gap. M3's modal side sheet: surface-container-low, a large corner on its inner edge,
|
||||
elevation 1; `side` `end` (the default) or `start`; `width` from `sm` (a caller's `w-*` would
|
||||
race the sheet's own).
|
||||
|
||||
As a pane (`pane`, from `xl`) nothing is covered: the page renders the drawer after its list in
|
||||
an `xl:flex xl:items-start xl:gap-6` row, the drawer sticks under the top of the viewport, the
|
||||
list stays usable and another row swaps what it shows — no scrim, no trap, no inert page. While
|
||||
closed it takes no room. `pane-width` sizes the pane (the sheet's width by default). The body is
|
||||
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
|
||||
never by the viewport.
|
||||
|
||||
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
|
||||
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
'subtitle' => null,
|
||||
'separator' => false,
|
||||
'side' => 'end',
|
||||
'right' => true,
|
||||
'withCloseButton' => false,
|
||||
'closeOnEscape' => true,
|
||||
'withoutBackdropClose' => false,
|
||||
'width' => '25rem',
|
||||
'pane' => false,
|
||||
'paneWidth' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
|
||||
$start = $side === 'start';
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
||||
wide: false,
|
||||
close() { this.open = typeof this.open === 'boolean' ? false : null; },
|
||||
@if ($pane)
|
||||
init() {
|
||||
const query = window.matchMedia('(min-width: 80rem)');
|
||||
this.wide = query.matches;
|
||||
query.addEventListener('change', (event) => this.wide = event.matches);
|
||||
},
|
||||
@endif
|
||||
}"
|
||||
@if ($closeOnEscape) x-on:keydown.window.escape="if (open && ! wide) close()" @endif
|
||||
data-sheet="{{ $id }}"
|
||||
@if ($pane)
|
||||
x-bind:class="! open && 'xl:hidden'"
|
||||
class="xl:sticky xl:top-[calc(env(safe-area-inset-top)+1.25rem)] xl:shrink-0 xl:self-start"
|
||||
data-pane
|
||||
@endif
|
||||
>
|
||||
<div
|
||||
x-cloak
|
||||
x-show="open"
|
||||
x-transition.opacity.duration.200ms
|
||||
@if (! $withoutBackdropClose) x-on:click="close()" @endif
|
||||
@class(['fixed inset-0 z-40 bg-scrim/32', 'xl:hidden' => $pane])
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<aside
|
||||
x-cloak
|
||||
x-show="open"
|
||||
x-trap.inert.noscroll="open && ! wide"
|
||||
x-transition:enter="transition-[translate,opacity] duration-(--md-sys-motion-spatial-default-duration) ease-emphasized-decelerate"
|
||||
x-transition:enter-start="{{ $pane ? ($start ? 'max-xl:-translate-x-full xl:opacity-0' : 'max-xl:translate-x-full xl:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
|
||||
x-transition:enter-end="translate-x-0 opacity-100"
|
||||
x-transition:leave="transition-[translate,opacity] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate"
|
||||
x-transition:leave-start="translate-x-0 opacity-100"
|
||||
x-transition:leave-end="{{ $pane ? ($start ? 'max-xl:-translate-x-full xl:opacity-0' : 'max-xl:translate-x-full xl:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
|
||||
id="{{ $id }}"
|
||||
x-bind:role="wide ? 'region' : 'dialog'"
|
||||
x-bind:aria-modal="wide ? null : 'true'"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth ?? $width }}"
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||
'fixed top-[env(safe-area-inset-top)] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
|
||||
'end-0 sm:rounded-s-corner-lg' => ! $start,
|
||||
'start-0 sm:rounded-e-corner-lg' => $start,
|
||||
'sm:w-(--sheet-width) sm:max-w-[calc(100vw-4rem)]',
|
||||
'xl:relative xl:top-0 xl:z-auto xl:max-h-[calc(100dvh-2.5rem-env(safe-area-inset-top))] xl:w-(--pane-width) xl:max-w-none xl:rounded-corner-lg xl:bg-surface-container xl:shadow-none' => $pane,
|
||||
$attributes->get('class'),
|
||||
]) }}
|
||||
>
|
||||
@if (filled($title) || $withCloseButton)
|
||||
<div class="mb-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
@if (filled($title))
|
||||
<h2 id="{{ $id }}-title" class="type-title-lg">{{ $title }}</h2>
|
||||
@endif
|
||||
|
||||
@if (filled($subtitle))
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($withCloseButton)
|
||||
<span class="-me-3 -mt-2 inline-flex shrink-0">
|
||||
<x-button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($separator)
|
||||
<x-divider class="mt-4" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="@container flex-1">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
|
||||
@isset($actions)
|
||||
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6">
|
||||
{{ $actions }}
|
||||
</div>
|
||||
@endisset
|
||||
</aside>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
{{-- One M3 list item: a headline with what leads it, what supports it and what trails it.
|
||||
|
||||
<x-list-item title="holiday-photos.zip" description="248 MB · expires in 3 days" icon="folder_zip" trailing="3 files" />
|
||||
<x-list-item title="Settings" icon="settings" icon-right="chevron_right" link="/settings" />
|
||||
|
||||
`title` (or the slot) is the body-large headline; `overline` sits above it (label-small),
|
||||
`description` under it (body-medium, up to two lines). The leading element is one of `icon`,
|
||||
`avatar` (an image URL, or initials in primary-container) or `image` (a 56px thumbnail), or a
|
||||
`leading` slot (a checkbox, a switch). The trailing element is `trailing` text (label-small),
|
||||
`icon-right`, or an `end` slot for controls (a menu, a switch). One-, two- and three-line
|
||||
heights (56, 72, 88px) follow from what is given (ListTokens, androidx Compose Material 3, Apache-2.0).
|
||||
|
||||
`link` makes the whole item the link. Otherwise, to make it open something while its trailing
|
||||
controls keep their own presses, give it `data-list-row` and put `data-list-open` on the one
|
||||
control that opens — see resources/js/list-rows.js. `selected` (true) is M3's selected item,
|
||||
in secondary-container; `disabled` greys it. --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
'overline' => null,
|
||||
'description' => null,
|
||||
'icon' => null,
|
||||
'avatar' => null,
|
||||
'image' => null,
|
||||
'trailing' => null,
|
||||
'iconRight' => null,
|
||||
'link' => null,
|
||||
'external' => false,
|
||||
'noWireNavigate' => false,
|
||||
'selected' => false,
|
||||
'disabled' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$isLink = filled($link);
|
||||
$lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0);
|
||||
$initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
|
||||
@endphp
|
||||
|
||||
<div
|
||||
role="listitem"
|
||||
data-list-item
|
||||
@if ($isLink) data-list-row @endif
|
||||
@if ($selected) data-selected @endif
|
||||
{{ $attributes->class([
|
||||
'relative flex items-center gap-3 px-4 text-on-surface',
|
||||
'min-h-14 py-2' => $lines === 0,
|
||||
'min-h-18 py-2.5' => $lines === 1,
|
||||
'min-h-22 py-2.5' => $lines === 2,
|
||||
'pointer-events-none text-on-surface/38' => $disabled,
|
||||
]) }}
|
||||
>
|
||||
@isset($leading)
|
||||
<div class="flex shrink-0 items-center">{{ $leading }}</div>
|
||||
@elseif ($avatar)
|
||||
@if ($initials)
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-corner-full bg-primary-container type-title-md text-on-primary-container" aria-hidden="true">{{ $avatar }}</span>
|
||||
@else
|
||||
<img src="{{ $avatar }}" alt="" class="size-10 shrink-0 rounded-corner-full object-cover" />
|
||||
@endif
|
||||
@elseif ($image)
|
||||
<img src="{{ $image }}" alt="" class="size-14 shrink-0 rounded-corner-sm object-cover" />
|
||||
@elseif ($icon)
|
||||
<x-icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
@endif
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
@if ($overline)
|
||||
<p @class(['type-label-sm', 'text-on-surface-variant' => ! $selected])>{{ $overline }}</p>
|
||||
@endif
|
||||
|
||||
@if ($isLink)
|
||||
<a
|
||||
href="{{ $link }}"
|
||||
data-list-open
|
||||
@if ($external) target="_blank" rel="noopener" @elseif (! $noWireNavigate) wire:navigate @endif
|
||||
class="block truncate type-body-lg outline-none"
|
||||
>{{ $title ?? $slot }}</a>
|
||||
@else
|
||||
<p class="truncate type-body-lg">{{ $title ?? $slot }}</p>
|
||||
@endif
|
||||
|
||||
@if ($description)
|
||||
<p @class(['line-clamp-2 type-body-md', 'text-on-surface-variant' => ! $selected])>{{ $description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@isset($end)
|
||||
<div class="flex shrink-0 items-center gap-1">{{ $end }}</div>
|
||||
@endisset
|
||||
|
||||
@if ($trailing)
|
||||
<span @class(['shrink-0 type-label-sm', 'text-on-surface-variant' => ! $selected])>{{ $trailing }}</span>
|
||||
@endif
|
||||
|
||||
@if ($iconRight)
|
||||
<x-icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
{{-- An M3 list: `<x-list-item>`s, one under another.
|
||||
|
||||
Plain by default — the items sit on the surface around them, with `dividers` between them if
|
||||
asked. `segmented` is M3 Expressive's list: each item its own surface-container tile, 2px
|
||||
apart, with small corners that open to large at the ends and while hovered, pressed or
|
||||
selected (ListTokens, androidx Compose Material 3, Apache-2.0).
|
||||
|
||||
It is a `role="list"`; for keyboard walking between rows, `j`/`k` or arrows, the application
|
||||
can use `data-list` (resources/js/list-rows.js marks rows; a list keyboard arrives with the
|
||||
list-detail pane). `label` names the list. --}}
|
||||
|
||||
@props([
|
||||
'segmented' => false,
|
||||
'dividers' => false,
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
<div
|
||||
role="list"
|
||||
data-list="{{ $segmented ? 'segmented' : 'plain' }}"
|
||||
@if ($label) aria-label="{{ $label }}" @endif
|
||||
{{ $attributes->class([
|
||||
'flex flex-col',
|
||||
'gap-0.5' => $segmented,
|
||||
'divide-y divide-outline-variant' => $dividers && ! $segmented,
|
||||
]) }}
|
||||
>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
@@ -0,0 +1,113 @@
|
||||
{{-- An M3 dialog, on the native `<dialog>` opened with `showModal()`.
|
||||
|
||||
Native because it gets the hard parts right on its own: the top layer above everything, the
|
||||
rest of the page inert, focus moved in and handed back, Escape. The open state is the Livewire
|
||||
property in `wire:model` (entangled live) — a flag (`$confirmingDelete`) or an id
|
||||
(`$deletingShareId`) — and closing writes back whichever "closed" means for it, `false` or
|
||||
`null`. Without `wire:model` it reads and writes `open` in the Alpine scope around it:
|
||||
|
||||
<div x-data="{ open: false }">
|
||||
<x-button label="Delete" x-on:click="open = true" />
|
||||
<x-modal title="Delete this share?">…</x-modal>
|
||||
</div>
|
||||
|
||||
`wire:ignore.self`, because `showModal()` sets the `open` attribute, which the server's HTML
|
||||
does not have: without it the next Livewire render morphs the attribute away and the dialog
|
||||
shuts under the person using it. The contents still morph.
|
||||
|
||||
M3's basic dialog (DialogTokens, androidx Compose Material 3, Apache-2.0): surface-container-
|
||||
high, extra-large corner, elevation 3, a headline-small `title`, body-medium `subtitle` in
|
||||
on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon
|
||||
above a centred title, as M3 draws a dialog with a hero icon. `fullscreen` makes a dialog that
|
||||
holds a form take the whole screen below `sm`, with a close button and the title in a top bar
|
||||
clear of the notch. `persistent` ignores Escape and the scrim, for a dialog that must be
|
||||
answered. It opens on the fast spatial spring and closes at once, as M3's do. --}}
|
||||
|
||||
@props([
|
||||
'title' => null,
|
||||
'subtitle' => null,
|
||||
'icon' => null,
|
||||
'separator' => false,
|
||||
'persistent' => false,
|
||||
'fullscreen' => false,
|
||||
'boxClass' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$id = $attributes->get('id') ?? 'material-dialog-'.substr(md5($model.'|'.$title), 0, 10);
|
||||
@endphp
|
||||
|
||||
<dialog
|
||||
wire:ignore.self
|
||||
@if ($model !== null)
|
||||
x-data="{
|
||||
open: @entangle($attributes->wire('model')).live,
|
||||
close() { this.open = typeof this.open === 'boolean' ? false : null },
|
||||
}"
|
||||
@else
|
||||
x-data="{ close() { this.open = false } }"
|
||||
@endif
|
||||
x-effect="open ? ($el.open || $el.showModal()) : ($el.open && $el.close())"
|
||||
x-on:cancel.prevent="{{ $persistent ? '' : 'close()' }}"
|
||||
x-on:close="if (open) close()"
|
||||
@if (! $persistent) x-on:click.self="close()" @endif
|
||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->merge(['id' => $id]) }}
|
||||
@class([
|
||||
'm-auto max-h-[calc(100dvh-3rem)] w-[calc(100vw-3rem)] max-w-[35rem] min-w-70 overflow-visible bg-transparent p-0 text-on-surface',
|
||||
'backdrop:bg-scrim/32',
|
||||
'opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
|
||||
'max-sm:m-0 max-sm:h-dvh max-sm:max-h-none max-sm:w-full max-sm:max-w-none max-sm:min-w-0' => $fullscreen,
|
||||
$attributes->get('class'),
|
||||
])
|
||||
>
|
||||
<div @class([
|
||||
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
|
||||
'max-sm:h-full max-sm:rounded-none max-sm:p-0 max-sm:pt-[env(safe-area-inset-top)]' => $fullscreen,
|
||||
$boxClass,
|
||||
])>
|
||||
@if ($fullscreen)
|
||||
<div class="flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden">
|
||||
<x-button icon="close" :tooltip="__('Close')" x-on:click="close()" />
|
||||
|
||||
@if (filled($title))
|
||||
<span class="truncate type-title-lg">{{ $title }}</span>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div @class(['min-h-0 flex-1', 'max-sm:overflow-y-auto max-sm:px-6 max-sm:pb-6' => $fullscreen])>
|
||||
@if (filled($title) || $icon)
|
||||
<div @class(['mb-4', 'max-sm:hidden' => $fullscreen && ! $icon, 'text-center' => $icon])>
|
||||
@if ($icon)
|
||||
<x-icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
|
||||
@endif
|
||||
|
||||
@if (filled($title))
|
||||
<h2 id="{{ $id }}-title" class="type-headline-sm">{{ $title }}</h2>
|
||||
@endif
|
||||
|
||||
@if (filled($subtitle))
|
||||
<p class="mt-4 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
|
||||
@endif
|
||||
|
||||
@if ($separator)
|
||||
<x-divider class="mt-4" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="type-body-md text-on-surface-variant">{{ $slot }}</div>
|
||||
</div>
|
||||
|
||||
@isset($actions)
|
||||
<div @class([
|
||||
'flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6',
|
||||
'max-sm:border-t max-sm:border-outline-variant max-sm:px-6 max-sm:py-4' => $fullscreen,
|
||||
])>
|
||||
{{ $actions }}
|
||||
</div>
|
||||
@endisset
|
||||
</div>
|
||||
</dialog>
|
||||
Reference in New Issue
Block a user