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

<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:
Andreas Reinhold / reini
2026-09-13 07:28:05 +02:00
co-authored by Claude Opus 5
parent e57063b0f4
commit fef20a9178
30 changed files with 1490 additions and 16 deletions
+26
View File
@@ -391,6 +391,32 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
`tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with `tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with
`x-figure`), `empty-state`. `x-figure`), `empty-state`.
**Phase 4 is done (2026-09-13).** What changed from the steps above:
- **`<x-progress>` was built by a separate agent** from Compose's `ProgressIndicator.kt` and
`WavyProgressIndicator.kt`: server-rendered SVG first frame, then an Alpine component that ports
the drawing and keyframes and animates only while something moves and it is on screen; the SVG
is `wire:ignore` and a MutationObserver on the root's `data-value` turns a morph or a `bind`
expression into motion. Flat circular indeterminate has no track, as in Compose.
- **`<x-toast>` listens from the moment `snackbar.js` loads**, not on its Alpine component, and holds
toasts until the host registers — a toast dispatched before Alpine starts is shown, not lost.
`@persist` keeps the host across `wire:navigate`.
- **`<x-badge>` is M3's dot and count** (`floating` pins it to an icon) **plus a status label**
(`tonal`, `outline`), which M3 lacks and every app needs. `<x-alert>`, `<x-stat>` and
`<x-empty-state>` are built from M3's parts; `<x-rich-tooltip>` is transient or `persistent`.
- **A commit went out broken and was fixed forward:** `648ad8e` staged `material.js` and the
showcase index while they held the progress agent's temporary lines, without its files.
**With agents in the same tree, stage by explicit path and read the diff of shared files first.**
- **Browser test lessons (all three engines, locally):**
- `waitForEvent('networkidle')` can return before a repeated visit has loaded in Firefox (an
empty document, then a page still streaming in). Every helper now also asserts
`document.readyState === 'complete'` and that Alpine and Livewire exist; assertions retry.
- Firefox runs Playwright's evaluate in a sandbox: an event built there has a `detail` the page
cannot read, and assignments to Alpine's reactive proxies do not trigger. Go through the page's
realm with `window.eval("…")` (or `Livewire.dispatch`).
- Pest retries a failing `assertScript`; a script that changes state must not be written so
that a retry starts from the changed state.
### Phase 5 — Containment ### Phase 5 — Containment
24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row 24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row
@@ -289,6 +289,64 @@ Shows on hover and keyboard focus; `persistent` opens it on press and keeps it u
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing. "Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
### `<x-card>`
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link.
```blade
<x-card variant="outlined" data-list-row wire:key="share-{{ $share->id }}">
<a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate class="type-title-md">{{ $share->name }}</a>
<x-slot:actions><x-button label="Copy link" wire:click="copy({{ $share->id }})" /></x-slot:actions>
</x-card>
```
### `<x-list>`, `<x-list-item>`
`<x-list>`: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). `<x-list-item>`: `title` (or slot), `overline`, `description`, leading `icon` / `avatar` (image URL or initials) / `image` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content.
```blade
<x-list segmented label="Files">
@foreach ($files as $file)
<x-list-item :title="$file->name" :description="$file->size" icon="description" wire:key="file-{{ $file->id }}">
<x-slot:end><x-button icon="download" tooltip="Download" wire:click="download({{ $file->id }})" /></x-slot:end>
</x-list-item>
@endforeach
</x-list>
```
### `<x-divider>`
`<x-divider />` — outline-variant line; `vertical`, `inset` (16px start), `middle`, `decorative` (hidden from assistive tech).
### `<x-collapse>`
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
### `<x-modal>`
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
```blade
<x-modal wire:model="deletingId" title="Delete this share?" subtitle="Recipients lose access at once." icon="delete">
<x-slot:actions>
<x-button label="Cancel" x-on:click="close()" />
<x-button label="Delete" danger wire:click="delete" />
</x-slot:actions>
</x-modal>
```
Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent` (no Escape or scrim), `fullscreen` (whole screen below `sm`, for forms), `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead.
### `<x-drawer>`
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
### `<x-bottom-sheet>`
An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`90dvh`), `actions` slot.
## Testing the design ## Testing the design
```php ```php
@@ -301,7 +359,7 @@ it('uses only what compiles', function () {
}); });
``` ```
It fails on maryUI tags, daisyUI classes, colours the theme does not declare and unknown symbol names, with `path:line` for each. It fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each.
## Conventions ## Conventions
+101
View File
@@ -0,0 +1,101 @@
/*
* Rows a person can go into (`data-list-row`, resources/js/list-rows.js), and M3 Expressive lists.
*
* A row answers a pointer with M3's state layer — hover 8%, press and focus 10% — but not while the
* pointer is on one of its own controls, which light only themselves. Hover only under
* `(hover: hover)`, because a touch screen keeps the last hover after a tap. The opener draws no
* focus ring inside a row; the row draws it, inset, so keyboard focus shows which row Enter opens.
* `data-selected` is M3's selected list item, in secondary-container.
*
* Unlayered where a component paints its fill as a utility (`<x-card>`): anything in a @layer loses
* to a utility whatever its specificity.
*/
@layer components {
:where([data-list-row]) {
cursor: pointer;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast),
border-radius var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
}
@media (hover: hover) {
:where([data-list-row]:not([data-card]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover))) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
:where([data-list-row]:not([data-card]):has([data-list-open]:focus-visible)) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
:where([data-list-row]:not([data-card]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active))) {
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
}
[data-list-row] [data-list-open]:focus-visible {
outline: none;
}
[data-list='segmented'] > [data-list-item] {
background-color: var(--md-sys-color-surface-container);
}
:is([data-list-row], [data-list-item])[data-selected]:not([data-card]) {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
/* A card that opens answers with its container one tone up and its corner opening a step. */
[data-card][data-list-row] {
transition-property: border-radius, background-color, box-shadow;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-default);
}
@media (hover: hover) {
[data-card][data-list-row]:hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) {
border-radius: var(--md-sys-shape-corner-lg);
box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
[data-card][data-list-row]:active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active)) {
box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
[data-card][data-list-row]:has([data-list-open]:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/*
* M3 Expressive's segmented list (`<x-list segmented>`): each item its own surface, 2px apart,
* extra-small corners that open to large at the ends, and to large while hovered, pressed or
* selected (ListTokens, androidx Compose Material 3, Apache-2.0).
*/
[data-list='segmented'] > [data-list-item] {
border-radius: var(--md-sys-shape-corner-xs);
}
[data-list='segmented'] > [data-list-item]:first-child {
border-start-start-radius: var(--md-sys-shape-corner-lg);
border-start-end-radius: var(--md-sys-shape-corner-lg);
}
[data-list='segmented'] > [data-list-item]:last-child {
border-end-start-radius: var(--md-sys-shape-corner-lg);
border-end-end-radius: var(--md-sys-shape-corner-lg);
}
@media (hover: hover) {
[data-list='segmented'] > [data-list-item][data-list-row]:hover {
border-radius: var(--md-sys-shape-corner-md);
}
}
[data-list='segmented'] > [data-list-item]:is([data-selected], [data-list-row]:active) {
border-radius: var(--md-sys-shape-corner-lg);
}
+1
View File
@@ -21,6 +21,7 @@
@import './tokens/theme.css'; @import './tokens/theme.css';
@import './tokens/state.css'; @import './tokens/state.css';
@import './components/groups.css'; @import './components/groups.css';
@import './components/list.css';
@layer base { @layer base {
html { html {
+60
View File
@@ -0,0 +1,60 @@
/**
* `materialBottomSheet(standard)`: the behaviour of `<x-bottom-sheet>`, spread into its x-data
* alongside `open` (entangled with Livewire, or the surrounding Alpine scope's).
*
* A downward drag follows the pointer and, released past a quarter of the sheet's height or
* flicked down, closes it; otherwise it springs back. The drag starts on the handle, or anywhere
* on the sheet while its content is scrolled to the top, so scrolling the content still scrolls.
*/
const DISMISS_FRACTION = 0.25
const FLICK_PX_PER_MS = 0.5
window.materialBottomSheet = (standard = false) => ({
standard,
dragged: 0,
close() {
this.dragged = 0
this.open = typeof this.open === 'boolean' ? false : null
},
dragStart(event) {
const sheet = this.$refs.sheet
const onHandle = event.target.closest('[data-drag-handle]')
const atTop = this.$refs.body.scrollTop <= 0
if (event.button !== 0 || (!onHandle && !atTop) || event.target.closest('input, textarea, select, [contenteditable]')) {
return
}
const startY = event.clientY
let lastY = startY
let lastAt = performance.now()
let velocity = 0
const move = (moveEvent) => {
const now = performance.now()
velocity = (moveEvent.clientY - lastY) / Math.max(now - lastAt, 1)
lastY = moveEvent.clientY
lastAt = now
this.dragged = Math.max(0, moveEvent.clientY - startY)
}
const end = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', end)
window.removeEventListener('pointercancel', end)
if (this.dragged > sheet.offsetHeight * DISMISS_FRACTION || (this.dragged > 0 && velocity > FLICK_PX_PER_MS)) {
this.close()
} else {
this.dragged = 0
}
}
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', end)
window.addEventListener('pointercancel', end)
},
})
+119
View File
@@ -0,0 +1,119 @@
/**
* A row opens from anywhere on it.
*
* A row is `data-list-row` — an `<x-list-item>`, an `<x-card>`, a table row — and exactly one
* control inside it is its opener, `data-list-open`: the title button that opens a sheet, or the
* link that goes to the item's page. A click anywhere else on the row is handed to the opener, so
* the whole row is the target while the opener stays a real control — the tab stop, the name a
* screen reader reads, and the owner of the `wire:click` or the `href`. The row's other controls
* keep their own clicks. How a row looks while that happens is resources/css/components/list.css.
*
* Not a stretched link (`::after { inset: 0 }`): Safari makes no containing block of a <tr>, so in
* a table every overlay would cover the whole table; and not one <button> around the row, which
* could hold no other buttons. The listeners sit on `document` and are added once.
*/
/** Anything that answers a click itself — the row's own controls, and the opener. */
const CONTROLS = 'a, button, input, select, textarea, label, summary, [contenteditable]'
/** How far a pointer may travel between press and release and still be a click. */
const DRAG_PX = 6
/**
* Where the last press went down, so a click that was really a drag — a pan across a map inside
* a card — is not taken for a click. Cleared by the click it belongs to, so a click with no press
* behind it (a script's `.click()`) is never a drag.
*/
let pressedAt = null
/**
* The opener a click on a row should reach, or null when the click is not the
* row's to hand on: it landed on a control, outside any row, or it ends a text
* selection somebody was making.
*/
const openerFor = (event) => {
if (event.defaultPrevented || !(event.target instanceof Element)) {
return null
}
if (event.target.closest(CONTROLS)) {
return null
}
const opener = event.target.closest('[data-list-row]')?.querySelector('[data-list-open]')
if (!opener) {
return null
}
if (!(window.getSelection()?.isCollapsed ?? true)) {
return null
}
return opener
}
/** A link opens in a new tab the way it would have under the same click. */
const openInNewTab = (opener) => {
if (opener instanceof HTMLAnchorElement && opener.href) {
window.open(opener.href, '_blank', 'noopener')
}
}
document.addEventListener('pointerdown', (event) => {
pressedAt = event.isPrimary ? { x: event.clientX, y: event.clientY } : null
}, { capture: true, passive: true })
document.addEventListener('click', (event) => {
const from = pressedAt
pressedAt = null
if (event.button !== 0) {
return
}
const opener = openerFor(event)
if (!opener) {
return
}
if (from && Math.hypot(event.clientX - from.x, event.clientY - from.y) > DRAG_PX) {
return
}
// Cmd, Ctrl or Shift on a row that goes somewhere is a new tab, as on the link
// itself. On a row that opens a drawer it is nothing: a drawer has no address.
if (event.metaKey || event.ctrlKey || event.shiftKey) {
openInNewTab(opener)
return
}
if (event.altKey) {
return
}
// A script's click, which Livewire honours on both halves: `wire:click` runs,
// and a `wire:navigate` link navigates in place rather than reloading the page.
opener.click()
})
// The middle button is not a `click`, and on a row that goes somewhere it means
// what it means on a link.
document.addEventListener('auxclick', (event) => {
if (event.button !== 1) {
return
}
const opener = openerFor(event)
if (opener) {
openInNewTab(opener)
}
})
// iOS Safari paints `:active` on an element that is not a link or a button only
// when some touch listener exists, and a row is neither — without this the press
// layer in list.css never shows on an iPhone.
document.addEventListener('touchstart', () => {}, { passive: true })
+2
View File
@@ -14,3 +14,5 @@ import './menu.js'
import './snackbar.js' import './snackbar.js'
import './rich-tooltip.js' import './rich-tooltip.js'
import './progress.js' import './progress.js'
import './list-rows.js'
import './bottom-sheet.js'
@@ -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>
+76
View File
@@ -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>
+137
View File
@@ -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>
+29
View File
@@ -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>
+113
View File
@@ -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>
+1
View File
@@ -16,5 +16,6 @@
@include('livewire-material::showcase.sections.menus') @include('livewire-material::showcase.sections.menus')
@include('livewire-material::showcase.sections.communication') @include('livewire-material::showcase.sections.communication')
@include('livewire-material::showcase.sections.progress') @include('livewire-material::showcase.sections.progress')
@include('livewire-material::showcase.sections.containment')
</main> </main>
@endsection @endsection
+5 -5
View File
@@ -14,16 +14,16 @@
</head> </head>
<body class="min-h-screen bg-surface font-sans text-on-surface antialiased"> <body class="min-h-screen bg-surface font-sans text-on-surface antialiased">
<header class="sticky top-0 z-10 border-b border-divider bg-surface-container"> <header class="sticky top-0 z-10 border-b border-divider bg-surface-container">
<div class="mx-auto flex max-w-6xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3"> <div class="mx-auto flex max-w-6xl items-center gap-x-6 px-4 py-3">
<a href="{{ route('livewire-material.showcase') }}" class="type-title-lg">Livewire Material</a> <a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
<nav class="flex flex-wrap gap-x-4 gap-y-1 type-label-lg text-on-surface-variant" aria-label="Sections"> <nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress'] as $anchor => $section) @foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a> <a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach @endforeach
</nav> </nav>
<div class="ms-auto flex rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak> <div class="ms-auto flex shrink-0 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) @foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
<button <button
type="button" type="button"
@@ -0,0 +1,126 @@
@php
$examples = [
'Cards' => <<<'BLADE'
<div class="grid w-full gap-4 md:grid-cols-3">
<x-card title="Filled" subtitle="surface-container-highest">
The default card.
<x-slot:actions><x-button label="Open" /></x-slot:actions>
</x-card>
<x-card title="Elevated" subtitle="surface-container-low, elevation 1" variant="elevated">
<x-slot:menu><x-button icon="more_vert" tooltip="More" /></x-slot:menu>
For content that floats a little.
</x-card>
<x-card title="Outlined" subtitle="an outline-variant edge" variant="outlined" separator>
Quiet, on the surface itself.
</x-card>
</div>
BLADE,
'A card that opens from anywhere on it' => <<<'BLADE'
<x-card variant="outlined" data-list-row class="w-full max-w-sm">
<a href="#containment" data-list-open class="type-title-md">holiday-photos.zip</a>
<p class="mt-1 text-on-surface-variant">248 MB · expires in 3 days</p>
<x-slot:actions>
<x-button label="Copy link" icon="content_copy" x-on:click="materialToast('Link copied', { type: 'success' })" />
</x-slot:actions>
</x-card>
BLADE,
'Lists' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2">
<x-list dividers label="Files">
<x-list-item title="holiday-photos.zip" description="248 MB" icon="folder_zip" trailing="3 days" />
<x-list-item title="contract.pdf" overline="Password protected" description="1.2 MB · downloaded twice" icon="picture_as_pdf" trailing="1 hour" />
<x-list-item title="Settings" icon="settings" icon-right="chevron_right" link="#containment" />
</x-list>
<x-list segmented label="Recipients">
<x-list-item title="Anna Müller" description="Downloaded yesterday" avatar="AM" :selected="true" />
<x-list-item title="Ben Keller" description="Not opened yet" avatar="BK" link="#containment" />
<x-list-item title="Chiara Rossi" avatar="CR">
<x-slot:end><x-button icon="more_vert" tooltip="More" /></x-slot:end>
</x-list-item>
</x-list>
</div>
BLADE,
'Dividers and collapse' => <<<'BLADE'
<div class="w-full space-y-4">
<x-collapse title="How long do links last?" icon="schedule" open>
Until the expiry you chose, from one hour to thirty days.
</x-collapse>
<x-divider />
<x-collapse title="Can recipients upload files?" variant="filled">
No. They can only download what you shared.
</x-collapse>
<div class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
</div>
BLADE,
'Dialogs' => <<<'BLADE'
<div x-data="{ open: false }">
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
<x-modal title="Delete this share?" subtitle="Recipients lose access at once. This cannot be undone.">
<x-slot:actions>
<x-button label="Cancel" x-on:click="close()" />
<x-button label="Delete" danger x-on:click="close()" />
</x-slot:actions>
</x-modal>
</div>
<div x-data="{ open: false }">
<x-button label="With a hero icon" variant="tonal" x-on:click="open = true" />
<x-modal title="Reset the password?" icon="lock_reset">
Recipients with the old password will need the new one.
<x-slot:actions>
<x-button label="Cancel" x-on:click="close()" />
<x-button label="Reset" x-on:click="close()" />
</x-slot:actions>
</x-modal>
</div>
<div x-data="{ open: false }">
<x-button label="Full screen on a phone" variant="tonal" x-on:click="open = true" />
<x-modal title="Share settings" fullscreen>
A form that needs the room.
<x-slot:actions><x-button label="Save" variant="filled" x-on:click="close()" /></x-slot:actions>
</x-modal>
</div>
BLADE,
'Sheets' => <<<'BLADE'
<div x-data="{ open: false }">
<x-button label="Side sheet" variant="tonal" x-on:click="open = true" />
<x-drawer title="Share details" subtitle="holiday-photos.zip" with-close-button separator>
<x-list>
<x-list-item title="Size" trailing="248 MB" />
<x-list-item title="Expires" trailing="in 3 days" />
<x-list-item title="Downloads" trailing="12" />
</x-list>
<x-slot:actions><x-button label="Delete" icon="delete" x-on:click="close()" /></x-slot:actions>
</x-drawer>
</div>
<div x-data="{ open: false }">
<x-button label="Bottom sheet" variant="tonal" x-on:click="open = true" />
<x-bottom-sheet title="Share via">
<x-list>
<x-list-item title="Copy link" icon="content_copy" />
<x-list-item title="Email" icon="mail" />
<x-list-item title="QR code" icon="qr_code_2" />
</x-list>
</x-bottom-sheet>
</div>
BLADE,
];
@endphp
<section id="containment" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Containment</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-card&gt;</code>, <code>&lt;x-list&gt;</code> and <code>&lt;x-list-item&gt;</code>, <code>&lt;x-divider&gt;</code>, <code>&lt;x-collapse&gt;</code>,
<code>&lt;x-modal&gt;</code>, <code>&lt;x-drawer&gt;</code> and <code>&lt;x-bottom-sheet&gt;</code>.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
+28
View File
@@ -102,6 +102,10 @@ class DesignGuard
foreach ($this->iconNames($contents) as [$line, $name]) { foreach ($this->iconNames($contents) as [$line, $name]) {
$violations[] = "{$where}:{$line} unknown Material Symbol `{$name}`"; $violations[] = "{$where}:{$line} unknown Material Symbol `{$name}`";
} }
foreach ($this->directivesInComponentTags($contents) as [$line, $directive]) {
$violations[] = "{$where}:{$line} Blade directive `{$directive}` inside a component tag, where it does not compile";
}
} }
foreach (explode("\n", $contents) as $index => $text) { foreach (explode("\n", $contents) as $index => $text) {
@@ -250,6 +254,30 @@ class DesignGuard
return $unknown; return $unknown;
} }
/**
* Blade compiles a component tag before its directives, so `<x-icon @class([...])>` or
* `x-show="ok(@js($v))"` on a component reaches the browser as literal text. Use `:class`
* and `{{ }}` there instead.
*
* @return list<array{0: int, 1: string}>
*/
protected function directivesInComponentTags(string $contents): array
{
preg_match_all('/<x-[\w.:-]+((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
$found = [];
foreach ($tags as $tag) {
preg_match_all('/(?<![\w@])@(class|style|js|json|if|unless|isset|foreach|disabled|checked|selected|readonly|required|entangle)\b/', $tag[1][0], $directives, PREG_OFFSET_CAPTURE);
foreach ($directives[0] as [$directive, $offset]) {
$found[] = [substr_count(substr($contents, 0, $tag[1][1] + $offset), "\n") + 1, $directive];
}
}
return $found;
}
protected function relative(string $path): string protected function relative(string $path): string
{ {
$base = rtrim(base_path(), '/').'/'; $base = rtrim(base_path(), '/').'/';
+2 -1
View File
@@ -4,7 +4,8 @@ const MORE = '#menus [aria-label="More"]';
function showcase() function showcase()
{ {
return visit('/material')->waitForEvent('networkidle'); return visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
} }
function focused(string $expression): string function focused(string $expression): string
+6 -3
View File
@@ -3,7 +3,8 @@
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')"; const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
it('shows a toast as a snackbar, then the next in turn', function () { it('shows a toast as a snackbar, then the next in turn', function () {
$page = visit('/material')->waitForEvent('networkidle'); $page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })"); $page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
@@ -18,7 +19,7 @@ it('shows a toast as a snackbar, then the next in turn', function () {
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () { it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
$page = visit('/material')->waitForEvent('networkidle') $page = visit('/material')->waitForEvent('networkidle')
->assertScript("typeof window.Livewire !== 'undefined' && typeof window.Alpine !== 'undefined'"); ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate // Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
// carries a detail object Firefox's sandbox will not let the page read. // carries a detail object Firefox's sandbox will not let the page read.
@@ -28,7 +29,8 @@ it('shows a toast dispatched as a browser event, as the Toasts concern does', fu
}); });
it('runs a toast\'s action and dismisses it', function () { it('runs a toast\'s action and dismisses it', function () {
$page = visit('/material')->waitForEvent('networkidle'); $page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })"); $page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
@@ -41,6 +43,7 @@ it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')"; $bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
visit('/material')->waitForEvent('networkidle') visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->click('#communication button:has-text("Press for details")') ->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')"); ->assertScript("{$bubble}.matches(':popover-open')");
}); });
+157
View File
@@ -0,0 +1,157 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
class OverlayProbe extends Component
{
public bool $confirming = false;
public ?int $deletingId = null;
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function render(): string
{
return <<<'BLADE'
<div class="space-y-4 p-4">
<p>confirming: <span id="confirming">{{ var_export($confirming, true) }}</span></p>
<p>deleting: <span id="deleting">{{ var_export($deletingId, true) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-button label="Confirm" wire:click="$set('confirming', true)" />
<x-button label="Delete 7" wire:click="$set('deletingId', 7)" />
<x-modal wire:model="confirming" title="Are you sure?">
<x-slot:actions>
<x-button label="Re-render" wire:click="touch" />
</x-slot:actions>
</x-modal>
<x-modal wire:model="deletingId" title="Delete share?" />
</div>
BLADE;
}
}
function overlayProbe()
{
Livewire::component('overlay-probe', OverlayProbe::class);
Route::middleware('web')->get('/overlay-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:overlay-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/overlay-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
function containment()
{
return visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('writes back what closed means: false for a flag, null for an id', function () {
$page = overlayProbe();
$page->click('button:has-text("Confirm")')
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
$page->click('dialog[open] button:has-text("Re-render")')
->assertSeeIn('#renders', '1')
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
$page->keys('dialog[open] button:has-text("Re-render")', 'Escape')
->assertScript("! [...document.querySelectorAll('dialog')].some((d) => d.open)")
->assertSeeIn('#confirming', 'false');
$page->click('button:has-text("Delete 7")')
->assertSeeIn('#deleting', '7')
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Delete share?'))");
$page->script("document.querySelector('dialog[open]').dispatchEvent(new Event('cancel', { cancelable: true }))");
$page->assertSeeIn('#deleting', 'NULL');
});
it('closes a showcase dialog on Escape and gives focus back to its trigger', function () {
// Opened from the keyboard: Safari does not focus a button on click, so after a click there
// is nothing for the dialog to hand focus back to.
$page = containment();
$page->script("[...document.querySelectorAll('#containment button')].find((b) => b.textContent.trim() === 'Basic dialog').focus()");
$page->keys(':focus', 'Enter')
->assertScript("document.querySelector('#containment dialog').open");
$page->keys('#containment dialog[open] button:has-text("Cancel")', 'Escape')
->assertScript("! document.querySelector('#containment dialog').open")
->assertScript("document.activeElement.textContent.trim() === 'Basic dialog'");
});
it('slides a side sheet in, traps the page, and closes on the scrim', function () {
$sheet = "document.querySelector('#containment aside[role=\"dialog\"]')";
$page = containment()
->click('#containment button:has-text("Side sheet")')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
$page->script("document.querySelector('[data-sheet] > [aria-hidden=\"true\"]').click()");
$page->assertScript("getComputedStyle({$sheet}).display === 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null");
});
it('dismisses a bottom sheet dragged down past a quarter of its height', function () {
$sheet = "document.querySelector('#containment section[role=\"dialog\"]')";
$page = containment()
->click('#containment button:has-text("Bottom sheet")')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->wait(0.5);
$page->script(<<<JS
(() => {
const handle = {$sheet}.querySelector('[data-drag-handle]');
const box = handle.getBoundingClientRect();
const x = box.x + box.width / 2, y = box.y + box.height / 2;
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 }));
window.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y + 400 }));
window.dispatchEvent(new PointerEvent('pointerup', { clientX: x, clientY: y + 400 }));
})()
JS);
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment();
$page->script("window.__opened = 0; document.querySelector('#containment [data-card][data-list-row] [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->click('#containment [data-card][data-list-row] p')
->assertScript('window.__opened === 1');
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
->assertScript('window.__opened === 1');
});
+19 -6
View File
@@ -38,6 +38,9 @@ function onProgress(string $label, string $body): string
return <<<JS return <<<JS
(async () => { (async () => {
const el = document.querySelector('#progress [aria-label="{$label}"]') const el = document.querySelector('#progress [aria-label="{$label}"]')
// Writes go through the page's own realm: Firefox runs this script in a sandbox whose
// assignments do not reach Alpine's reactive proxies.
const set = (value) => window.eval(`Alpine.\$data(document.querySelector('#progress [aria-label="{$label}"]')).progress = \${JSON.stringify(value)}`)
el.scrollIntoView({ block: 'center' }) el.scrollIntoView({ block: 'center' })
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const active = () => el.querySelector('path[stroke="currentColor"]') const active = () => el.querySelector('path[stroke="currentColor"]')
@@ -50,7 +53,10 @@ function onProgress(string $label, string $body): string
function progressShowcase(array $options = []) function progressShowcase(array $options = [])
{ {
return visit('/material', $options)->waitForEvent('networkidle'); // networkidle alone can return before a repeated visit has even loaded in Firefox; the
// assertion retries until the page is complete and Alpine has started.
return visit('/material', $options)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
} }
it('draws a linear indicator to its value', function () { it('draws a linear indicator to its value', function () {
@@ -85,9 +91,16 @@ it('moves to a new bound value instead of jumping', function () {
progressShowcase() progressShowcase()
->assertScript(onProgress('Bound', <<<'JS' ->assertScript(onProgress('Bound', <<<'JS'
const before = reach() const before = reach()
Alpine.$data(el).progress = 80 set(80)
await pause(80) // The first frame that shows movement, not a timer: under load a timer fires late enough
const during = reach() // that the 330ms spring has nearly arrived, and a jump would look the same as a fast move.
// A jump's first moved frame is already at the target; a move's is on the way.
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
let during = reach()
for (let i = 0; i < 60 && during <= before + 0.01; i++) {
await frame()
during = reach()
}
return Math.abs(before - 0.3) < 0.01 && during > before + 0.01 && during < 0.79 return Math.abs(before - 0.3) < 0.01 && during > before + 0.01 && during < 0.79
JS)) JS))
->wait(1) ->wait(1)
@@ -96,13 +109,13 @@ it('moves to a new bound value instead of jumping', function () {
it('turns indeterminate when a bound value is null, and back', function () { it('turns indeterminate when a bound value is null, and back', function () {
progressShowcase()->assertScript(onProgress('Bound', <<<'JS' progressShowcase()->assertScript(onProgress('Bound', <<<'JS'
Alpine.$data(el).progress = null set(null)
await pause(100) await pause(100)
const indeterminate = !el.hasAttribute('aria-valuenow') && !el.hasAttribute('data-value') const indeterminate = !el.hasAttribute('aria-valuenow') && !el.hasAttribute('data-value')
const first = active().getAttribute('d') const first = active().getAttribute('d')
await pause(200) await pause(200)
const moving = active().getAttribute('d') !== first const moving = active().getAttribute('d') !== first
Alpine.$data(el).progress = 50 set(50)
await pause(100) await pause(100)
return indeterminate && moving && el.getAttribute('aria-valuenow') === '50' && Math.abs(reach() - 0.5) < 0.01 return indeterminate && moving && el.getAttribute('aria-valuenow') === '50' && Math.abs(reach() - 0.5) < 0.01
JS)); JS));
+40
View File
@@ -0,0 +1,40 @@
<?php
it('draws M3\'s three cards', function (string $variant, string $classes) {
expect((string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>"))->toContain($classes)->toContain('rounded-corner-md')->toContain('data-card');
})->with([
'filled' => ['filled', 'bg-surface-container-highest'],
'elevated' => ['elevated', 'bg-surface-container-low shadow-elevation-1'],
'outlined' => ['outlined', 'border border-outline-variant bg-surface'],
]);
it('is filled unless it knows the variant', function () {
expect((string) $this->blade('<x-card variant="glass">Body</x-card>'))->toContain('bg-surface-container-highest');
});
it('lays out a header, menu, media, body and actions', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-card title="holiday-photos.zip" subtitle="248 MB" separator>
<x-slot:figure><img src="/x.png" alt=""></x-slot:figure>
<x-slot:menu><button></button></x-slot:menu>
Expires in 3 days.
<x-slot:actions><button>Copy link</button></x-slot:actions>
</x-card>
BLADE);
expect($html)
->toContain('<img src="/x.png" alt="">')
->toContain('<h3 class="type-title-md">holiday-photos.zip</h3>')
->toContain('248 MB')
->toContain('<button>⋮</button>')
->toContain('role="separator"')
->toContain('Expires in 3 days.')
->toContain('<button>Copy link</button>')
->and(strpos($html, 'Expires'))->toBeLessThan(strpos($html, 'Copy link'));
});
it('passes row attributes through', function () {
expect((string) $this->blade('<x-card data-list-row><a href="/s/1" data-list-open>Open</a></x-card>'))
->toContain('data-list-row')
->toContain('data-list-open');
});
+14
View File
@@ -0,0 +1,14 @@
<?php
it('discloses on the native details element, kept open through a morph', function () {
$html = (string) $this->blade('<x-collapse title="How long do links last?" icon="schedule" open variant="filled">Until the expiry.</x-collapse>');
expect($html)
->toContain('<details')
->toContain('wire:ignore.self')
->toContain(' open ')
->toContain('rounded-corner-lg bg-surface-container')
->toContain('How long do links last?')
->toContain('Until the expiry.')
->toContain('group-open/collapse:rotate-180');
});
+8
View File
@@ -0,0 +1,8 @@
<?php
it('separates horizontally or vertically, inset on request', function () {
expect((string) $this->blade('<x-divider />'))->toContain('role="separator"')->toContain('aria-orientation="horizontal"')->toContain('h-px')->toContain('bg-outline-variant')
->and((string) $this->blade('<x-divider vertical />'))->toContain('aria-orientation="vertical"')->toContain('w-px')
->and((string) $this->blade('<x-divider inset />'))->toContain('ms-4')
->and((string) $this->blade('<x-divider decorative />'))->toContain('aria-hidden="true"')->not->toContain('role="separator"');
});
+46
View File
@@ -0,0 +1,46 @@
<?php
it('draws a plain list, with dividers on request', function () {
expect((string) $this->blade('<x-list label="Files" dividers><x-list-item title="a.zip" /></x-list>'))
->toContain('role="list"')
->toContain('aria-label="Files"')
->toContain('data-list="plain"')
->toContain('divide-y divide-outline-variant')
->toContain('role="listitem"');
});
it('draws M3 Expressive\'s segmented list', function () {
expect((string) $this->blade('<x-list segmented><x-list-item title="a.zip" /></x-list>'))
->toContain('data-list="segmented"')
->toContain('gap-0.5');
});
it('grows an item from one to three lines', function () {
expect((string) $this->blade('<x-list-item title="a.zip" />'))->toContain('min-h-14')
->and((string) $this->blade('<x-list-item title="a.zip" description="248 MB" />'))->toContain('min-h-18')->toContain('248 MB')
->and((string) $this->blade('<x-list-item title="a.zip" overline="Protected" description="248 MB" />'))->toContain('min-h-22')->toContain('Protected');
});
it('leads with an icon, an avatar or an image, and trails with text or an icon', function () {
expect((string) $this->blade('<x-list-item title="a" icon="folder_zip" trailing="3 days" icon-right="chevron_right" />'))
->toContain('3 days')
->and(substr_count((string) $this->blade('<x-list-item title="a" icon="folder_zip" icon-right="chevron_right" />'), '<svg'))->toBe(2)
->and((string) $this->blade('<x-list-item title="Anna" avatar="AM" />'))->toContain('bg-primary-container')->toContain('>AM</span>')
->and((string) $this->blade('<x-list-item title="Anna" avatar="/anna.jpg" />'))->toContain('<img src="/anna.jpg"')
->and((string) $this->blade('<x-list-item title="Photo" image="/p.jpg" />'))->toContain('size-14');
});
it('makes a linked item a row that opens from anywhere', function () {
expect((string) $this->blade('<x-list-item title="Settings" link="/settings" />'))
->toContain('data-list-row')
->toContain('href="/settings"')
->toContain('data-list-open')
->toContain('wire:navigate');
});
it('marks a selected item and takes controls in its slots', function () {
expect((string) $this->blade('<x-list-item title="Anna" :selected="true"><x-slot:leading><input type="checkbox"></x-slot:leading><x-slot:end><button>⋮</button></x-slot:end></x-list-item>'))
->toContain('data-selected')
->toContain('<input type="checkbox">')
->toContain('<button>⋮</button>');
});
+73
View File
@@ -0,0 +1,73 @@
<?php
use Livewire\Component;
use Livewire\Livewire;
it('opens a native dialog entangled with a Livewire property, out of the morph\'s reach', function () {
$component = new class extends Component
{
public bool $confirming = false;
public function render(): string
{
return <<<'BLADE'
<div>
<x-modal wire:model="confirming" title="Delete this share?" subtitle="This cannot be undone." icon="delete">
<x-slot:actions><button>Delete</button></x-slot:actions>
</x-modal>
</div>
BLADE;
}
};
Livewire::test($component)
->assertSeeHtml('<dialog')
->assertSeeHtml('wire:ignore.self')
->assertSeeHtml('open: window.Livewire.find(')
->assertSeeHtml("entangle('confirming').live")
->assertSeeHtml('close() { this.open = typeof this.open === \'boolean\' ? false : null }')
->assertSeeHtml('rounded-corner-xl bg-surface-container-high')
->assertSeeHtml('type-headline-sm')
->assertSee('This cannot be undone.')
->assertSeeHtml('text-center')
->assertDontSeeHtml('wire:model="confirming"');
});
it('uses the surrounding Alpine scope without wire:model, and stays open when persistent', function () {
$html = (string) $this->blade('<x-modal title="Help" persistent fullscreen>Text</x-modal>');
expect($html)
->toContain('x-data="{ close() { this.open = false } }"')
->toContain('x-on:cancel.prevent=""')
->not->toContain('x-on:click.self')
->toContain('max-sm:h-dvh')
->toContain('aria-label="Close"');
});
it('slides a side sheet in from either edge, and is a pane from xl when asked', function () {
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('end-0 sm:rounded-s-corner-lg')
->toContain('bg-surface-container-low')
->toContain('role="dialog"')
->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('start-0 sm:rounded-e-corner-lg')
->and((string) $this->blade('<x-drawer pane pane-width="28rem">Body</x-drawer>'))
->toContain('data-pane')
->toContain('--pane-width: 28rem')
->toContain("matchMedia('(min-width: 80rem)')");
});
it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () {
expect((string) $this->blade('<x-bottom-sheet title="Share via">Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(false)')
->toContain('bg-scrim/32')
->toContain('x-trap.inert.noscroll="open"')
->toContain('rounded-t-corner-xl bg-surface-container-low')
->toContain('data-drag-handle')
->toContain('aria-modal="true"')
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(true)')
->not->toContain('bg-scrim/32')
->not->toContain('aria-modal');
});
+1
View File
@@ -20,6 +20,7 @@ it('finds what compiles to nothing', function () {
'views/page.blade.php:6 daisyUI class `btn-primary`', 'views/page.blade.php:6 daisyUI class `btn-primary`',
'views/page.blade.php:4 unknown Material Symbol `o-home`', 'views/page.blade.php:4 unknown Material Symbol `o-home`',
'views/page.blade.php:5 unknown Material Symbol `not_a_symbol`', 'views/page.blade.php:5 unknown Material Symbol `not_a_symbol`',
'views/page.blade.php:9 Blade directive `@class` inside a component tag, where it does not compile',
'views/page.blade.php:2 maryUI component `<x-mary-button`', 'views/page.blade.php:2 maryUI component `<x-mary-button`',
'views/page.blade.php:3 colour the theme does not declare `bg-base-200`', 'views/page.blade.php:3 colour the theme does not declare `bg-base-200`',
'views/page.blade.php:6 colour the theme does not declare `text-red-600`', 'views/page.blade.php:6 colour the theme does not declare `text-red-600`',
@@ -6,4 +6,5 @@
<span @class(['btn-primary' => $active, 'select-none'])>text-red-600</span> <span @class(['btn-primary' => $active, 'select-none'])>text-red-600</span>
<p class="text-tertiary table collapse link focus-ring">Tailwind's and ours</p> <p class="text-tertiary table collapse link focus-ring">Tailwind's and ours</p>
<x-icon :name="$dynamic" /> <x-icon :name="$dynamic" />
<x-icon name="home" @class(['size-4']) />
</div> </div>