From fef20a91788256d0002e25014b9d68424bf232c3 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 07:28:05 +0200 Subject: [PATCH] Add cards, lists, dialogs, sheets and the rest of M3's containment (filled, elevated, outlined), and (plain or M3 Expressive's segmented list), , on
, on the native , as a side sheet or list-detail pane, and 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) Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy --- docs/plans/livewire-material.md | 26 +++ .../livewire-material-development/SKILL.md | 60 ++++++- resources/css/components/list.css | 101 +++++++++++ resources/css/material.css | 1 + resources/js/bottom-sheet.js | 60 +++++++ resources/js/list-rows.js | 119 +++++++++++++ resources/js/material.js | 2 + .../views/components/bottom-sheet.blade.php | 75 +++++++++ resources/views/components/card.blade.php | 76 +++++++++ resources/views/components/collapse.blade.php | 42 +++++ resources/views/components/divider.blade.php | 24 +++ resources/views/components/drawer.blade.php | 137 +++++++++++++++ .../views/components/list-item.blade.php | 99 +++++++++++ resources/views/components/list.blade.php | 29 ++++ resources/views/components/modal.blade.php | 113 +++++++++++++ resources/views/showcase/index.blade.php | 1 + resources/views/showcase/layout.blade.php | 10 +- .../showcase/sections/containment.blade.php | 126 ++++++++++++++ src/Testing/DesignGuard.php | 28 ++++ tests/Browser/ActionsTest.php | 3 +- tests/Browser/CommunicationTest.php | 9 +- tests/Browser/ContainmentTest.php | 157 ++++++++++++++++++ tests/Browser/ProgressTest.php | 25 ++- tests/Feature/Components/CardTest.php | 40 +++++ tests/Feature/Components/CollapseTest.php | 14 ++ tests/Feature/Components/DividerTest.php | 8 + tests/Feature/Components/ListTest.php | 46 +++++ tests/Feature/Components/OverlayTest.php | 73 ++++++++ tests/Feature/DesignGuardTest.php | 1 + .../design-guard/views/page.blade.php | 1 + 30 files changed, 1490 insertions(+), 16 deletions(-) create mode 100644 resources/css/components/list.css create mode 100644 resources/js/bottom-sheet.js create mode 100644 resources/js/list-rows.js create mode 100644 resources/views/components/bottom-sheet.blade.php create mode 100644 resources/views/components/card.blade.php create mode 100644 resources/views/components/collapse.blade.php create mode 100644 resources/views/components/divider.blade.php create mode 100644 resources/views/components/drawer.blade.php create mode 100644 resources/views/components/list-item.blade.php create mode 100644 resources/views/components/list.blade.php create mode 100644 resources/views/components/modal.blade.php create mode 100644 resources/views/showcase/sections/containment.blade.php create mode 100644 tests/Browser/ContainmentTest.php create mode 100644 tests/Feature/Components/CardTest.php create mode 100644 tests/Feature/Components/CollapseTest.php create mode 100644 tests/Feature/Components/DividerTest.php create mode 100644 tests/Feature/Components/ListTest.php create mode 100644 tests/Feature/Components/OverlayTest.php diff --git a/docs/plans/livewire-material.md b/docs/plans/livewire-material.md index 30a667ec..49aebca2 100644 --- a/docs/plans/livewire-material.md +++ b/docs/plans/livewire-material.md @@ -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 `x-figure`), `empty-state`. +**Phase 4 is done (2026-09-13).** What changed from the steps above: + +- **`` 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. +- **`` 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`. +- **`` 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. ``, `` and + `` are built from M3's parts; `` 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 24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 86152d54..f5d3a8c2 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -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. +### `` + +`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 `` or use a stretched link. + +```blade + + {{ $share->name }} + + +``` + +### ``, `` + +``: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). ``: `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 + + @foreach ($files as $file) + + + + @endforeach + +``` + +### `` + +`` — outline-variant line; `vertical`, `inset` (16px start), `middle`, `decorative` (hidden from assistive tech). + +### `` + +A disclosure on native `
`: `` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph. + +### `` + +An M3 dialog on native ``. 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 + + + + + + +``` + +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. + +### `` + +An M3 side sheet, bound like ``; `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 `
`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`. + +### `` + +An M3 bottom sheet, bound like ``: 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 ```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 diff --git a/resources/css/components/list.css b/resources/css/components/list.css new file mode 100644 index 00000000..e4c7d7ae --- /dev/null +++ b/resources/css/components/list.css @@ -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 (``): 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 (``): 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); +} diff --git a/resources/css/material.css b/resources/css/material.css index b33d21fc..44bd9669 100644 --- a/resources/css/material.css +++ b/resources/css/material.css @@ -21,6 +21,7 @@ @import './tokens/theme.css'; @import './tokens/state.css'; @import './components/groups.css'; +@import './components/list.css'; @layer base { html { diff --git a/resources/js/bottom-sheet.js b/resources/js/bottom-sheet.js new file mode 100644 index 00000000..91ad955a --- /dev/null +++ b/resources/js/bottom-sheet.js @@ -0,0 +1,60 @@ +/** + * `materialBottomSheet(standard)`: the behaviour of ``, 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) + }, +}) diff --git a/resources/js/list-rows.js b/resources/js/list-rows.js new file mode 100644 index 00000000..98669913 --- /dev/null +++ b/resources/js/list-rows.js @@ -0,0 +1,119 @@ +/** + * A row opens from anywhere on it. + * + * A row is `data-list-row` — an ``, an ``, 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 , so in + * a table every overlay would cover the whole table; and not one +
+ +
+ @if (filled($title)) +

{{ $title }}

+ @endif + + {{ $slot }} +
+ + @isset($actions) +
{{ $actions }}
+ @endisset + + diff --git a/resources/views/components/card.blade.php b/resources/views/components/card.blade.php new file mode 100644 index 00000000..4c0bb75c --- /dev/null +++ b/resources/views/components/card.blade.php @@ -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 `` + 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 + +
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) +
{{ $figure }}
+ @endisset + +
+ @if ($title || $subtitle || isset($menu)) +
+
+
+ @if ($title) +

{{ $title }}

+ @endif + + @if ($subtitle) +

{{ $subtitle }}

+ @endif +
+ + @isset($menu) +
{{ $menu }}
+ @endisset +
+ + @if ($separator) + + @endif +
+ @endif + + @if ($slot->isNotEmpty()) +
{{ $slot }}
+ @endif + + @isset($actions) +
{{ $actions }}
+ @endisset +
+
diff --git a/resources/views/components/collapse.blade.php b/resources/views/components/collapse.blade.php new file mode 100644 index 00000000..5e1a5fbd --- /dev/null +++ b/resources/views/components/collapse.blade.php @@ -0,0 +1,42 @@ +{{-- A section that opens to show more: an FAQ answer, advanced settings. + + Not an M3 component; built on the native `
` 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', +]) + +
class([ + 'group/collapse [interpolate-size:allow-keywords]', + 'rounded-corner-lg bg-surface-container' => $variant === 'filled', + ]) }} +> + $variant === 'filled', + ])> + @if ($icon) + + @endif + + {{ $heading ?? $title }} + + + + +
$variant === 'filled', 'pt-1'])> + {{ $slot }} +
+
diff --git a/resources/views/components/divider.blade.php b/resources/views/components/divider.blade.php new file mode 100644 index 00000000..b7f8d413 --- /dev/null +++ b/resources/views/components/divider.blade.php @@ -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, +]) + + diff --git a/resources/views/components/drawer.blade.php b/resources/views/components/drawer.blade.php new file mode 100644 index 00000000..cfa17779 --- /dev/null +++ b/resources/views/components/drawer.blade.php @@ -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 + +
+
$pane]) + aria-hidden="true" + >
+ + +
diff --git a/resources/views/components/list-item.blade.php b/resources/views/components/list-item.blade.php new file mode 100644 index 00000000..0f59e29a --- /dev/null +++ b/resources/views/components/list-item.blade.php @@ -0,0 +1,99 @@ +{{-- One M3 list item: a headline with what leads it, what supports it and what trails it. + + + + + `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 + +
diff --git a/resources/views/components/list.blade.php b/resources/views/components/list.blade.php new file mode 100644 index 00000000..527e0db4 --- /dev/null +++ b/resources/views/components/list.blade.php @@ -0,0 +1,29 @@ +{{-- An M3 list: ``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, +]) + +
class([ + 'flex flex-col', + 'gap-0.5' => $segmented, + 'divide-y divide-outline-variant' => $dividers && ! $segmented, + ]) }} +> + {{ $slot }} +
diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php new file mode 100644 index 00000000..453bb494 --- /dev/null +++ b/resources/views/components/modal.blade.php @@ -0,0 +1,113 @@ +{{-- An M3 dialog, on the native `` 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: + +
+ + +
+ + `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 + +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'), + ]) +> +
$fullscreen, + $boxClass, + ])> + @if ($fullscreen) +
+ + + @if (filled($title)) + {{ $title }} + @endif +
+ @endif + +
$fullscreen])> + @if (filled($title) || $icon) +
$fullscreen && ! $icon, 'text-center' => $icon])> + @if ($icon) + + @endif + + @if (filled($title)) +

{{ $title }}

+ @endif + + @if (filled($subtitle)) +

{{ $subtitle }}

+ @endif + + @if ($separator) + + @endif +
+ @endif + +
{{ $slot }}
+
+ + @isset($actions) +
$fullscreen, + ])> + {{ $actions }} +
+ @endisset +
+
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index 272ed194..408c7080 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -16,5 +16,6 @@ @include('livewire-material::showcase.sections.menus') @include('livewire-material::showcase.sections.communication') @include('livewire-material::showcase.sections.progress') + @include('livewire-material::showcase.sections.containment') @endsection diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index 6c1469a7..85f36002 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -14,16 +14,16 @@
-
- Livewire Material +
+ Livewire Material -