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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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/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 {
|
||||||
|
|||||||
@@ -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 './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>
|
||||||
@@ -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.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
|
||||||
|
|||||||
@@ -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><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>
|
||||||
@@ -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(), '/').'/';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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"');
|
||||||
|
});
|
||||||
@@ -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>');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user