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