Merge branch 'worktree-agent-a3082a7e6ef1bee94'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 10:38:57 +02:00
18 changed files with 721 additions and 63 deletions
@@ -461,11 +461,15 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
</x-card>
```
`data-dragged` on the card draws M3's dragged state — elevation 8dp elevated / 6dp filled and outlined, under the 16% dragged state layer. The application sets it when its drag starts and removes it on drop; pair any drag with a single-pointer alternative (a menu with the same actions), as M3 requires.
That is M3's *non-actionable card with actionable elements*: Tab walks the controls inside. For M3's *directly actionable card*, where Tab lands on the card and then moves to the next card, add `actionable` (and `role="link"` where it goes somewhere) and put `tabindex="-1"` on the opener — the card is then the tab stop, named by its `title`, and Enter or Space on it reaches the opener while the card's other actions follow it.
### `<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, and a three-line item top-aligns as M3 asks. Its icons are 24px, 20px in a `segmented` list.
`<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` / `video` / `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, and a three-line item top-aligns as M3 asks. Its icons are 24px, 20px in a `segmented` list.
`video` is M3's leading media in landscape: a poster URL, or `<x-slot:video>` for a `<video>` or a thumbnail with a play badge. It is drawn 100×56px in a two-line item and 114×64px in a three-line one (ListTokens' small and large leading video), and always lifts the item to at least the 72px two-line height, since the tallest element sets an item's height.
A list a person chooses from is `<x-list selectable>` (or `selection="single"` / `selection="multi"`): M3 maps those to a **list box** of **options**, so the container becomes `role="listbox"` (`aria-multiselectable` when multi) and each item an `option` announcing `aria-selected`. A selected option also draws a trailing check — M3 never allows colour as the only cue — which `icon-right` or a leading checkbox replaces. A plain list stays `role="list"`, where `selected` is `aria-current`. `disabled` renders no link and announces `aria-disabled`.
@@ -483,6 +487,8 @@ A list a person chooses from is `<x-list selectable>` (or `selection="single"` /
`<x-divider />` — outline-variant line; `vertical`, `inset` (16px start), `middle`, `decorative` (hidden from assistive tech).
`<x-divider text="Earlier this week" />` is M3's divider with a subheader, to head a group in a list or a menu: the label (title-small, on-surface-variant) at the start, the rule 4px after it and 8px short of the end, 8px under the row. The words stay text; only the rule is the separator (`decorative` hides just the rule). Horizontal only.
### `<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.
@@ -515,9 +521,13 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (a divider u
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, and on a pane), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). `pane` (with `pane-width`, `22.5rem` — M3's 360dp fixed pane) turns it into a second pane from `expanded`, where M3 shows two panes: render it after the list inside `<div class="expanded:flex expanded:items-start expanded:gap-6">`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` (a *container* query), never a window class.
`standard` is M3's other side-sheet variant, and it is not the same thing as `pane`. A **pane** is the list-detail companion: it shows what the list beside it has selected, 360dp wide on `surface-container` with a large corner. A **standard** sheet is supplementary content beside the primary content — filters, details, a list of actions: co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, the window's full height, an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it in the same `expanded:flex` row as a pane.
### `<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` (`50dvh` — M3 caps a modal sheet's initial position at half the screen; whatever you pass is held under a ceiling of the screen less M3's 72dp top margin), `actions` slot.
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` (`50dvh` — M3 caps a modal sheet's initial position at half the screen; whatever you pass is held under a ceiling of the screen less M3's 72dp top margin), `heights`/`snap`, `actions` slot.
`heights` gives it M3's **preset heights**`heights="25dvh,50dvh,90dvh"`, `:heights="[25, 50, 90]"` (a bare number is `dvh`) or a JSON list; `snap` is the shorthand for those three. The sheet then takes its stop's height and opens at the stop that equals `height`, or at the first. The drag handle is the height control M3 requires beside the drag: activating it (press, Enter, Space) moves to the next stop and announces it, and from the last stop it closes the sheet; dragging settles on the nearest stop, or closes below the smallest. Fewer than two stops is no stops — use `height` for a single height.
### `<x-carousel>`, `<x-carousel-item>`
@@ -531,7 +541,9 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
</x-carousel>
```
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen` — one edge-to-edge item at a time scrolled **vertically**, which M3 gives to compact and medium windows in portrait only, and never to landscape), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `multi-aspect`, `full-screen` — one edge-to-edge item at a time scrolled **vertically**, which M3 gives to compact and medium windows in portrait only, and never to landscape), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text; `aspect` is its ratio in a `multi-aspect` carousel. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
`layout="multi-aspect"` is M3's uncontained multi-aspect-ratio layout (November 2025): each `<x-carousel-item aspect="16/9">` keeps its own ratio at the row's `height`, held inside M3's 9:16-to-16:9 range, so the widths come from the art. Only use it when the items really do have various widths. It is a plain flex row with uncontained scrolling — no keylines and no masks, since an arrangement of one item size cannot describe it — while the buttons, the arrow keys, Home/End and bring-into-view still work, from resting positions measured off the DOM.
### `<x-chip>`
+26 -4
View File
@@ -86,23 +86,45 @@
}
@media (hover: hover) {
[data-card][data-list-row]:hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) {
[data-card][data-list-row]:not([data-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) {
box-shadow: var(--md-sys-elevation-1), inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
[data-card='elevated'][data-list-row]:hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) {
[data-card='elevated'][data-list-row]:not([data-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) {
box-shadow: var(--md-sys-elevation-2), 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)) {
[data-card][data-list-row]:not([data-dragged]):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='elevated'][data-list-row]:active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active)) {
[data-card='elevated'][data-list-row]:not([data-dragged]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active)) {
box-shadow: var(--md-sys-elevation-1), inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
/*
* M3's dragged card (`<x-card data-dragged>`): the highest elevation a card ever takes, under the
* 16% dragged state layer. The token files' per-state elevation — elevated Level4 (8dp), filled
* and outlined Level3 (6dp) — and md.sys.state.dragged-state-layer-opacity from tokens/state.css
* (docs/reference/m3/components-actions-communication-containment.md § Cards → Specs). Written as
* one box-shadow with the row's other states, and named in the hover and press rules above so the
* card a pointer is holding stays at its dragged level rather than falling back to a press.
*
* The application owns the attribute: it sets `data-dragged` when its drag starts and takes it off
* on drop, since nothing in the browser tells a card it is being carried. M3 asks for a
* single-pointer alternative beside any drag, so give the card a menu with the same actions.
*/
[data-card][data-dragged] {
box-shadow: var(--md-sys-elevation-3),
inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-dragged-state-layer-opacity) * 100%), transparent);
}
[data-card='elevated'][data-dragged] {
box-shadow: var(--md-sys-elevation-4),
inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-dragged-state-layer-opacity) * 100%), transparent);
}
/* Focus is on the card itself when the card is the tab stop (`<x-card actionable>`). */
[data-card][data-list-row]:is(:focus-visible, :has([data-list-open]:focus-visible)) {
outline: 3px solid var(--md-sys-color-secondary);
+129 -8
View File
@@ -1,23 +1,114 @@
/**
* `materialBottomSheet(standard)`: the behaviour of `<x-bottom-sheet>`, spread into its x-data
* alongside `open` (entangled with Livewire, or the surrounding Alpine scope's).
* `materialBottomSheet(standard, presets)`: 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.
* Without preset heights the sheet is as tall as its content allows and a downward drag follows the
* pointer: released past a quarter of the sheet's height or flicked down, it closes; otherwise it
* springs back. Activating the handle closes it.
*
* With preset heights (`presets.stops`, CSS lengths from `heights`/`snap`) the sheet is the height
* of its current stop. A drag then runs that height with the pointer and settles on the nearest
* stop when it is let go below the smallest stop, or on a downward flick, it closes instead. M3
* requires a single-pointer alternative to any drag, and names the handle as it: activating it
* moves to the next stop and announces it, and from the last stop it closes the sheet, which is
* M3's "selecting the drag handle toggles preset heights or closes the sheet".
*
* 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. The sheet's inline `--sheet-stop` is what the CSS reads
* for the current stop; only while a drag is running does this write a pixel height, with the
* transition off, so the sheet tracks the pointer rather than easing after it.
*/
const DISMISS_FRACTION = 0.25
const FLICK_PX_PER_MS = 0.5
window.materialBottomSheet = (standard = false) => ({
/** M3's top margin for a bottom sheet, which its stops are held under. */
const TOP_MARGIN = 72
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
window.materialBottomSheet = (standard = false, presets = {}) => ({
standard,
stops: presets.stops ?? [],
labels: presets.labels ?? {},
start: presets.start ?? 0,
stop: presets.start ?? 0,
dragged: 0,
dragging: false,
height: 0,
announcement: '',
/** Whether this sheet has more than one height to move between. */
get preset() {
return this.stops.length > 1
},
/** What the handle does next: the last stop closes the sheet, as a handle with no stops does. */
get handleLabel() {
return this.preset && this.stop < this.stops.length - 1 ? this.labels.change : this.labels.close
},
/** Only a running drag writes inline styles; at rest the classes and `--sheet-stop` own the box. */
get sheetStyle() {
if (!this.dragging) {
return {}
}
return this.preset ? { height: `${this.height}px`, transition: 'none' } : { translate: `0 ${this.dragged}px`, transition: 'none' }
},
close() {
this.dragged = 0
this.dragging = false
this.move(this.start, false)
this.open = typeof this.open === 'boolean' ? false : null
},
/** The handle's press, Enter and Space: the next stop, or the way out. */
activate() {
if (this.preset && this.stop < this.stops.length - 1) {
this.move(this.stop + 1)
return
}
this.close()
},
/** Settles on a stop: the CSS variable the box is sized from, and a word for the screen reader. */
move(index, announce = true) {
if (!this.preset) {
return
}
this.stop = index
this.$refs.sheet?.style.setProperty('--sheet-stop', this.stops[index])
if (announce) {
this.announcement = this.labels.announce?.[index] ?? ''
}
},
/** The tallest a sheet may be: the screen less M3's 72dp top margin. */
ceiling() {
return Math.max(0, window.innerHeight - TOP_MARGIN)
},
/** Each stop in pixels — only the browser can say what `25dvh` is. */
sizes() {
const probe = this.$refs.probe
const ceiling = this.ceiling()
return this.stops.map((stop) => {
probe.style.height = stop
const size = probe.getBoundingClientRect().height
probe.style.removeProperty('height')
return Math.min(size, ceiling)
})
},
dragStart(event) {
const sheet = this.$refs.sheet
const onHandle = event.target.closest('[data-drag-handle]')
@@ -27,18 +118,28 @@ window.materialBottomSheet = (standard = false) => ({
return
}
const preset = this.preset
const startY = event.clientY
const startHeight = sheet.offsetHeight
let lastY = startY
let lastAt = performance.now()
let velocity = 0
this.dragging = true
this.height = startHeight
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)
if (preset) {
this.height = clamp(startHeight - (moveEvent.clientY - startY), 0, this.ceiling())
} else {
this.dragged = Math.max(0, moveEvent.clientY - startY)
}
}
const end = () => {
@@ -46,7 +147,11 @@ window.materialBottomSheet = (standard = false) => ({
window.removeEventListener('pointerup', end)
window.removeEventListener('pointercancel', end)
if (this.dragged > sheet.offsetHeight * DISMISS_FRACTION || (this.dragged > 0 && velocity > FLICK_PX_PER_MS)) {
this.dragging = false
if (preset) {
this.release(velocity)
} else if (this.dragged > sheet.offsetHeight * DISMISS_FRACTION || (this.dragged > 0 && velocity > FLICK_PX_PER_MS)) {
this.close()
} else {
this.dragged = 0
@@ -57,4 +162,20 @@ window.materialBottomSheet = (standard = false) => ({
window.addEventListener('pointerup', end)
window.addEventListener('pointercancel', end)
},
/** A preset-height drag let go: the nearest stop, or out past the smallest one. */
release(velocity) {
const sizes = this.sizes()
const smallest = Math.min(...sizes)
if (velocity > FLICK_PX_PER_MS || this.height < smallest * (1 - DISMISS_FRACTION)) {
this.close()
return
}
const nearest = sizes.reduce((best, size, index) => (Math.abs(size - this.height) < Math.abs(sizes[best] - this.height) ? index : best), 0)
this.move(nearest)
},
})
+48 -6
View File
@@ -44,6 +44,13 @@
* vertically, so the browser's own scroll snap is the whole of it and this script only works out
* where each item comes to rest, for the buttons and the keys.
*
* The uncontained multi-aspect-ratio layout uses none of it either, and for a reason that is in
* the maths: an Arrangement counts large, medium and small items of one size each, and every
* keyline, snap position and mask follows from that one size, so items that each keep their own
* aspect ratio have no arrangement to fit. That layout is a plain flex row the browser scrolls,
* every item laid out at its own ratio and none of them masked; the resting positions the buttons,
* the keys and bring-into-view need are measured off the DOM instead.
*
* Copyright 2023-2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -803,6 +810,7 @@ document.addEventListener('alpine:init', () => {
maxScroll: 0,
rtl: false,
vertical: false,
measured: false,
frame: null,
target: null,
targetAt: 0,
@@ -862,6 +870,7 @@ document.addEventListener('alpine:init', () => {
state.rtl = style.direction === 'rtl'
state.vertical = root.dataset.materialCarousel === 'full-screen'
state.measured = root.dataset.materialCarousel === 'multi-aspect'
state.items = [...scroller.children]
.filter((element) => element.matches(ITEM))
.map((element) => ({
@@ -888,6 +897,28 @@ document.addEventListener('alpine:init', () => {
return
}
// The uncontained multi-aspect-ratio layout: no strategy, no mask, and each item
// resting where its own width puts it. `scrollLeft` is negative in RTL, so an
// offset is taken from whichever edge the row starts at.
if (state.measured) {
const width = scroller.clientWidth
const leading = parseFloat(style.paddingInlineStart) || 0
const box = scroller.getBoundingClientRect()
const scroll = Math.abs(scroller.scrollLeft)
state.strategy = null
state.maxScroll = Math.max(0, scroller.scrollWidth - width)
state.snaps = state.items.map(({ element }) => {
const item = element.getBoundingClientRect()
return clamp(scroll + (state.rtl ? box.right - item.right : item.left - box.left) - leading, 0, state.maxScroll)
})
this.render()
return
}
const space = scroller.clientWidth
const itemSpacing = parseFloat(style.columnGap) || 0
const padding = Number(root.dataset.padding) || 0
@@ -970,8 +1001,9 @@ document.addEventListener('alpine:init', () => {
cancelAnimationFrame(state.frame)
state.frame = null
// Nothing is masked in the vertical full-screen layout; only the buttons change.
if (state.vertical) {
// Nothing is masked in the full-screen or multi-aspect layouts; only the buttons
// change.
if (state.vertical || state.measured) {
this.buttons()
state.mutations?.takeRecords()
@@ -1092,7 +1124,7 @@ document.addEventListener('alpine:init', () => {
},
scrollToItem(index) {
if (state.snaps[index] === undefined || (!state.vertical && !state.strategy?.valid)) {
if (state.snaps[index] === undefined || (!state.vertical && !state.measured && !state.strategy?.valid)) {
return
}
@@ -1175,9 +1207,19 @@ document.addEventListener('alpine:init', () => {
/** Whether item `index` is not fully in focus, so a press or focus should bring it there. */
isMasked(index) {
return state.vertical
? Math.abs(state.snaps[index] - this.scrollOffset()) > 1
: parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
if (state.vertical) {
return Math.abs(state.snaps[index] - this.scrollOffset()) > 1
}
// Nothing masks a multi-aspect item, so "not fully open" is "cut off by the row".
if (state.measured) {
const row = this.$refs.scroller.getBoundingClientRect()
const item = state.items[index].element.getBoundingClientRect()
return item.left < row.left - 1 || item.right > row.right + 1
}
return parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
},
destroy() {
@@ -15,6 +15,24 @@
default is `50dvh`, under a ceiling of the screen less M3's 72dp top margin. Content scrolls
inside.
`heights` gives the sheet M3's **preset heights**: a list of stops `heights="25dvh,50dvh,90dvh"`,
`:heights="[25, 50, 90]"` (a bare number is read as `dvh`) or a JSON list and the sheet then
takes the height of its current stop rather than sizing itself to its content. `snap` is the
shorthand for the library's three, `25dvh`, `50dvh` and `90dvh`. It opens at the stop that equals
`height`, or at the first one; fewer than two stops is no stops at all, since a single height is
what `height` already says.
With stops the drag handle is M3's height control, which is the accessibility rule behind them:
"the drag handle can be dragged **or selected** to cycle through preset heights", "any drag-only
action needs a single-pointer alternative", "Tab focuses the drag handle; Space/Enter toggles
between available heights", and "selecting the drag handle toggles preset heights **or closes the
sheet**". So activating the handle — a click, Enter or Space, since it is a button — moves to the
next stop and announces it in a live region, and from the last stop it closes the sheet, which is
also what a handle with no stops does. A drag runs the sheet's height with the pointer and
settles on the nearest stop on release, or closes it below the smallest one or on a downward
flick (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets
Behaviour, Accessibility).
The handle is drawn 32×4px and pressed 48×48: `touch-target` on the button and 22px above and
below it, which is M3's "drag handle has an accessible 48dp hit target" and SheetDefaults.kt's
`DragHandleVerticalPadding = 22.dp` (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets Specs). --}}
@@ -23,16 +41,51 @@
'title' => null,
'standard' => false,
'height' => '50dvh',
'heights' => null,
'snap' => false,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$id = $attributes->get('id') ?? 'material-bottom-sheet-'.substr(md5($model.'|'.$title), 0, 10);
// A list, a JSON list or a comma-separated one; a bare number is a percentage of the screen,
// which is how M3 talks about a sheet's position ("capped at 50% of screen height").
$stops = match (true) {
is_array($heights) => $heights,
is_string($heights) && str_starts_with(trim($heights), '[') => json_decode($heights, true) ?: [],
filled($heights) => explode(',', (string) $heights),
(bool) $snap => ['25dvh', '50dvh', '90dvh'],
default => [],
};
$stops = array_values(array_filter(array_map(
fn ($stop): string => is_numeric($stop) ? ((float) $stop).'dvh' : trim((string) $stop),
$stops,
), 'filled'));
// M3 asks for a non-drag way to change height "if multiple preset heights exist"; one stop is
// not multiple, and `height` already says where a single-height sheet opens.
$stops = count($stops) > 1 ? $stops : [];
$start = (int) (array_search($height, $stops, true) ?: 0);
$presets = \Illuminate\Support\Js::from([
'stops' => $stops,
'start' => $start,
'labels' => [
'change' => __('Change the sheet height'),
'close' => __('Close'),
'announce' => array_map(
fn (int $index): string => __('Height :position of :count', ['position' => $index + 1, 'count' => count($stops)]),
array_keys($stops),
),
],
]);
@endphp
<div
x-data="{
...materialBottomSheet({{ $standard ? 'true' : 'false' }}),
...materialBottomSheet({{ $standard ? 'true' : 'false' }}, {{ $presets }}),
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
}"
x-on:keydown.window.escape="if (open && ! standard) close()"
@@ -41,6 +94,12 @@
<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
@if ($stops !== [])
{{-- A stop is a CSS length, and only the browser can say what `25dvh` is in pixels; this
measures one when a drag has to find the nearest. --}}
<div x-ref="probe" aria-hidden="true" class="pointer-events-none invisible fixed start-0 top-0 w-0"></div>
@endif
<section
x-cloak
x-show="open"
@@ -52,22 +111,39 @@
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-bind:style="sheetStyle"
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: min({{ $height }}, calc(100dvh - 72px))"
@if ($stops === [])
style="--sheet-max-height: min({{ $height }}, calc(100dvh - 72px))"
@else
style="--sheet-stop: {{ $stops[$start] }}; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))"
@endif
{{ $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-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
// With stops the sheet is the height of its stop, not of its content, and it moves
// between them on the spatial track — anything that changes size does.
'h-(--sheet-max-height) transition-[height] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default' => $stops !== [],
$attributes->get('class'),
]) }}
>
<div class="flex shrink-0 cursor-grab justify-center py-5.5 active:cursor-grabbing" data-drag-handle>
<button type="button" class="touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary" aria-label="{{ __('Close') }}" x-on:click="close()"></button>
<button
type="button"
class="touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary"
aria-label="{{ $stops === [] ? __('Close') : __('Change the sheet height') }}"
@if ($stops !== []) x-bind:aria-label="handleLabel" @endif
x-on:click="activate()"
></button>
</div>
@if ($stops !== [])
<span class="sr-only" aria-live="polite" x-text="announcement"></span>
@endif
<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>
@@ -26,6 +26,15 @@
filled and outlined 0 1); its corner does not move, because M3 gives a card one shape.
Never a stretched link, and never a whole-card `<a>` around buttons.
`data-dragged` is M3's dragged card: the top of a card's elevation scale 8dp elevated, 6dp
filled and outlined under the 16% dragged state layer, and it holds while the pointer is
down rather than falling back to the press state. Nothing in the browser tells a card it is
being carried, so the application sets the attribute when its drag starts and takes it off on
drop. M3 requires a single-pointer alternative beside any drag, so keep the same reorder or
delete actions in a menu on the card
(docs/reference/m3/components-actions-communication-containment.md § Cards Specs,
Accessibility; resources/css/components/list.css draws it).
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. --}}
@@ -21,40 +21,67 @@
extra-large corner (28px, CarouselDefaults' item shape) and moved by
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
item shows unmasked, at its `item-width`. In a `full-screen` carousel it is none of that: the
item fills the row, edge to edge, with no corner and no mask. Only inside `<x-carousel>`. --}}
item fills the row, edge to edge, with no corner and no mask.
In a `multi-aspect` carousel the item is as wide as its own `aspect` makes it at the row's
height, and nothing masks it: M3's uncontained multi-aspect-ratio layout is for items whose
widths "vary… ranging from a 9:16 minimum to a 16:9 maximum aspect ratio", so `aspect` is
held inside that range (`16/9`, `16:9` or a plain number; a square by default, and ignored in
every other layout, where the keylines size the items)
(docs/reference/m3/components-actions-communication-containment.md § Carousel Variants,
Specs). Only inside `<x-carousel>`. --}}
@props([
'label' => null,
'aspect' => null,
])
{{-- The layout of the `<x-carousel>` around it: the full-screen one is a vertical row of
edge-to-edge items, which M3 gives no corner and no mask. --}}
edge-to-edge items, which M3 gives no corner and no mask, and the multi-aspect one sizes each
item by its own aspect ratio and masks none of them. --}}
@aware([
'layout' => 'multi-browse',
])
@php
$vertical = $layout === 'full-screen';
$multiAspect = $layout === 'multi-aspect';
// M3's 9:16 minimum and 16:9 maximum. `16/9`, `16:9` and `1.78` all say the same thing.
$ratio = null;
if ($multiAspect) {
$value = $aspect ?? 1;
if (is_string($value) && preg_match('/^\s*([\d.]+)\s*[\/:]\s*([\d.]+)\s*$/', $value, $parts) && (float) $parts[2] > 0) {
$value = (float) $parts[1] / (float) $parts[2];
}
$ratio = round(min(max((float) $value, 9 / 16), 16 / 9), 4);
}
@endphp
<div {{ $attributes
->class([
'focus-ring relative h-full shrink-0 snap-start snap-always focus-visible:-outline-offset-3',
'w-full' => $vertical,
'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical,
'w-auto rounded-corner-xl' => $multiAspect,
'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical && ! $multiAspect,
])
->merge([
->merge(array_filter([
'role' => 'group',
'aria-roledescription' => __('slide'),
'aria-label' => '[material-carousel-position]',
'data-material-carousel-item' => true,
'tabindex' => '0',
]) }}>
'style' => $ratio === null ? null : "aspect-ratio: {$ratio}",
], fn ($value): bool => $value !== null)) }}>
<div
data-material-carousel-surface
@class([
'relative size-full overflow-hidden bg-surface-container-highest text-on-surface',
'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical,
'rounded-corner-xl' => $multiAspect,
'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical && ! $multiAspect,
])
>
<div data-material-carousel-content class="size-full [&>img]:size-full [&>img]:object-cover">
+29 -6
View File
@@ -19,6 +19,12 @@
does.
- `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves
HorizontalUncontainedCarousel. No snapping, as Compose's uncontained fling.
- `multi-aspect`: M3's **uncontained multi-aspect-ratio** layout, added November 2025
"same as Uncontained but items vary in width, ranging from a 9:16 minimum to a 16:9 maximum
aspect ratio", and "only use this layout if the items have various widths". Each
`<x-carousel-item aspect="16/9">` keeps its own ratio at the row's fixed `height`, so the
widths follow from the art rather than from a keyline. Uncontained scrolling, 16px of
leading padding, 8px between items, the extra-large corner.
- `full-screen`: one edge-to-edge item at a time, scrolled **vertically** M3: "this layout
works best with content that is taller than it is wide, and scrolls vertically. It only
works in portrait orientation in compact and medium breakpoints. Don't use this layout in
@@ -26,6 +32,15 @@
the row is never wider than the 840px medium window it is meant for. `item-width` and
`padding` do not apply; `height` is the height of each item, so give it the room a
portrait image wants.
The keyline machinery fits every layout but `multi-aspect`: an Arrangement is a count of
large, medium and small items of **one** size each, and the snap positions and masks it
produces all follow from that one size, so a row of items of different widths has no
arrangement to fit. That layout is therefore a plain flex row the browser scrolls it and
each item is laid out at its own aspect ratio, unmasked and only the parts of
resources/js/carousel.js that need no Strategy work on it: the previous and next buttons, the
arrow keys, Home and End, and bringing a clipped item into view, all from resting positions
measured off the DOM rather than computed from keylines.
`item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's
sample). `padding` is Compose's `contentPadding` in pixels: the first and last items rest
that far in from the edges while items in between scroll to them. M3's specs table gives
@@ -70,8 +85,9 @@
])
@php
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse';
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'multi-aspect', 'full-screen'], true) ? $layout : 'multi-browse';
$vertical = $layout === 'full-screen';
$multiAspect = $layout === 'multi-aspect';
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
$label ??= __('Carousel');
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
@@ -86,11 +102,12 @@
$preferredWidth = match ($layout) {
'multi-browse', 'uncontained' => $cssLength($itemWidth) ?? '186px',
'hero' => $cssLength($itemWidth),
'full-screen' => null,
'multi-aspect', 'full-screen' => null,
};
// The full-screen item is `h-full w-full`: it takes the row, not a slot.
// The full-screen item is `h-full w-full` and a multi-aspect one is as wide as its own ratio
// makes it: neither takes a slot.
$slotWidth = match (true) {
$vertical => null,
$vertical, $multiAspect => null,
$preferredWidth === null => 'calc(100% - 64px)',
default => $preferredWidth,
};
@@ -114,7 +131,7 @@
// only for uncontained, none for full-screen, which is edge to edge.
$padding = max(0, (float) $padding);
$paddingStart = $vertical ? 0.0 : $padding;
$paddingEnd = $vertical || $layout === 'uncontained' ? 0.0 : $padding;
$paddingEnd = $vertical || $layout === 'uncontained' || $multiAspect ? 0.0 : $padding;
$attributes = $attributes
->class('relative')
@@ -126,6 +143,9 @@
'style' => implode('; ', array_filter([
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
$slotWidth ? "--material-carousel-slot: {$slotWidth}" : null,
// Without keylines there is nothing to anchor the first item in from the edge, so
// the multi-aspect row carries the specs table's leading padding itself.
$multiAspect ? "--material-carousel-pad: {$paddingStart}px" : null,
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
])),
], fn ($value): bool => $value !== null));
@@ -148,7 +168,10 @@
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain' => $vertical,
'h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2' => ! $vertical,
'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained',
'ps-(--material-carousel-pad)' => $multiAspect,
// M3's two scrolling modes: snap-scrolling everywhere but the two uncontained
// layouts, which it gives default scrolling.
'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained' && ! $multiAspect,
])
>
{!! $slides !!}
+38 -12
View File
@@ -2,23 +2,49 @@
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. --}}
ends. It is `role="separator"`; with `decorative` it is hidden from assistive tech.
`text` is the divider with a subheader, as M3 draws it to head a group in a list or a menu:
the label at the start and the rule running on from it. The divider specs table gives the
geometry "space between divider & supporting text 4dp", "divider right margin 8dp",
"divider bottom margin 8dp" so the rule starts 4px after the words, stops 8px short of the
end and leaves 8px under the row. The label is M3's subhead: title-small in
on-surface-variant, the type the rich tooltip's specs give a subhead, since the divider's own
page names none (docs/reference/m3/components-actions-communication-containment.md
§ Divider Specs, § Tooltips Specs). The words stay readable text that names the group
after them; only the rule is the separator, and `decorative` hides the rule and leaves the
words. `text` is for a horizontal divider; a vertical one ignores it. --}}
@props([
'vertical' => false,
'inset' => false,
'middle' => false,
'decorative' => false,
'text' => null,
])
<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>
@if (filled($text) && ! $vertical)
<div {{ $attributes->class([
'flex shrink-0 items-center gap-1 pb-2',
'ms-4' => $inset,
'mx-4' => $middle,
]) }}>
<span class="shrink-0 type-title-sm text-on-surface-variant">{{ $text }}</span>
<div
@if ($decorative) aria-hidden="true" @else role="separator" aria-orientation="horizontal" @endif
class="me-2 h-px min-w-0 flex-1 bg-outline-variant"
></div>
</div>
@else
<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>
@endif
+53 -9
View File
@@ -15,6 +15,25 @@
elevation 1; `side` `end` (the default) or `start`; `width` from `medium` (a caller's `w-*`
would race the sheet's own a compact window gets the full-bleed sheet).
As a **standard** side sheet (`standard`, from `expanded`) it is M3's other variant: co-planar
with the content rather than over it no scrim, no focus trap, nothing inert, 0dp elevation
(material-components-android's `SideSheet.md`: "standard side sheet elevation = 0dp,
coplanar"), `surface` rather than surface-container-low, no corner, and an outline-variant
divider down its inner edge in place of the scrim. It sits in the page flow beside the
content, spans the window's height and scrolls on its own. M3 calls the standard sheet
"supplementary surfaces mainly for medium to expanded breakpoints" and the modal one
"preferred at compact breakpoints"; the library switches at `expanded` (840px) rather than at
medium, because M3 also caps a side sheet at 400dp and a 600px window has too little left
beside one below `expanded` a `standard` sheet is the modal sheet
(docs/reference/m3/components-actions-communication-containment.md § Side sheets).
`standard` and `pane` are not the same thing and neither replaces the other: a **pane** is the
second pane of a list-detail layout it shows what the list beside it has selected, is
`22.5rem` wide (M3's 360dp fixed pane), sticks under the top of the viewport and sits on
`surface-container` with a large corner. A **standard** sheet is supplementary content beside
the primary content filters, details, a list of actions full height, flat on `surface`,
divided from the content by a rule. Pass one or the other; `pane` wins if both are given.
As a pane (`pane`, from `expanded`) nothing is covered: the page renders the drawer after its
list in an `expanded:flex expanded:items-start expanded:gap-6` row, the drawer sticks under the
top of the viewport, the
@@ -29,8 +48,8 @@
sheet's open/close flow or tell whether it is transient or permanent
(docs/reference/m3/components-actions-communication-containment.md § Side sheets
Accessibility) so `with-close-button` is on by default, and `:with-close-button="false"` is
ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane, which has
neither.
ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane or a
standard sheet, which have neither from `expanded`.
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}}
@@ -48,6 +67,7 @@
'pane' => false,
'paneWidth' => '22.5rem',
'paneCloseOnEscape' => false,
'standard' => false,
])
@php
@@ -55,9 +75,21 @@
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
$start = $side === 'start';
// One or the other: a pane is the list-detail companion, a standard sheet is supplementary
// content beside the primary content.
$standard = $standard && ! $pane;
// Both stop being modal from `expanded`, so both watch the window for that width.
$wide = $pane || $standard;
// The side-sheet specs table caps the sheet at 400dp, which is where `width` already starts;
// a wider one is the modal sheet's to take, not the co-planar standard sheet's.
$sheetWidth = $standard ? "min({$width}, 25rem)" : $width;
// M3 requires a close affordance; the prop can only ever add one, never take away the last
// way out of the sheet.
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || ($pane && ! $paneCloseOnEscape);
// way out of the sheet. A standard sheet keeps no scrim and no trap from `expanded`, and
// Escape leaves it open there, so it always draws one.
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || $standard || ($pane && ! $paneCloseOnEscape);
@endphp
<div
@@ -65,7 +97,7 @@
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
wide: false,
close() { this.open = typeof this.open === 'boolean' ? false : null; },
@if ($pane)
@if ($wide)
init() {
const query = window.matchMedia('(width >= 52.5rem)');
this.wide = query.matches;
@@ -79,6 +111,12 @@
x-bind:class="! open && 'expanded:hidden'"
class="expanded:sticky expanded:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] expanded:shrink-0 expanded:self-start"
data-pane
@elseif ($standard)
{{-- A standard sheet spans the window's height beside the content and takes no room while
closed; sticky, so it stays put as the page scrolls past it. --}}
x-bind:class="! open && 'expanded:hidden'"
class="expanded:sticky expanded:top-0 expanded:h-dvh expanded:shrink-0 expanded:self-start"
data-standard
@endif
>
<div
@@ -86,7 +124,7 @@
x-show="open"
x-transition.opacity.duration.200ms
@if (! $withoutBackdropClose) x-on:click="close()" @endif
@class(['fixed inset-0 z-40 bg-scrim/32', 'expanded:hidden' => $pane])
@class(['fixed inset-0 z-40 bg-scrim/32', 'expanded:hidden' => $wide])
aria-hidden="true"
></div>
@@ -95,24 +133,30 @@
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-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
x-transition:enter-start="{{ $wide ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded: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-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
x-transition:leave-end="{{ $wide ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded: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 }}"
style="--sheet-width: {{ $sheetWidth }}; --pane-width: {{ $paneWidth }}"
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
'fixed top-[var(--material-safe-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 medium:rounded-s-corner-lg' => ! $start,
'start-0 medium:rounded-e-corner-lg' => $start,
'medium:w-(--sheet-width) medium:max-w-[calc(100vw-4rem)]',
'expanded:relative expanded:top-0 expanded:z-auto expanded:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] expanded:w-(--pane-width) expanded:max-w-none expanded:rounded-corner-lg expanded:bg-surface-container expanded:shadow-none' => $pane,
// M3's standard side sheet from `expanded`: co-planar on `surface`, square, 0dp
// elevation, the window's full height, with an outline-variant rule down the edge it
// meets the content on — the divider its anatomy lists, in place of the scrim.
'expanded:relative expanded:top-0 expanded:z-auto expanded:h-full expanded:rounded-corner-none expanded:bg-surface expanded:shadow-none' => $standard,
'expanded:border-s expanded:border-outline-variant' => $standard && ! $start,
'expanded:border-e expanded:border-outline-variant' => $standard && $start,
$attributes->get('class'),
]) }}
>
+37 -3
View File
@@ -5,15 +5,27 @@
`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
`avatar` (an image URL, or initials in primary-container), `image` (a 56px thumbnail) or
`video`, 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, 16px between the item and what leads or
trails it, and a three-line item top-aligns rather than centring M3 aligns an item middle
"by default, top-aligned if the item is 88dp+ or has 3+ lines of text". Its icons are 24px,
or 20px in a `segmented` list, which is M3 Expressive's (ListTokens, androidx Compose
Material 3, Apache-2.0).
`video` is M3's leading media in its landscape size a poster URL, or a `<x-slot:video>`
holding a `<video>` or a thumbnail with a play badge. ListTokens gives it two: 100×56px in a
two-line item and 114×64px in a three-line one (`LeadingVideoSmall` 56dp × 100dp,
`LeadingVideoLarge` 64dp × 114dp height first, both 16:9), so a video always lifts the item
to at least the 72px two-line height, where 56 + 2×8 comes to 72 exactly, and to 88 with the
large one, where 64 + 2×12 comes to 88. It takes the small corner the leading image takes
(`ItemLeadingImageShape` = CornerSmall; the site publishes no separate video shape) and sits
in the leading slot, 16px in from the edge like every other leading element. M3's slot model
asks that the leading slot stay narrower than the content slot, so give an item with a large
video the room its text needs
(docs/reference/m3/components-actions-communication-containment.md § Lists Anatomy, Specs).
`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,
@@ -33,6 +45,7 @@
'icon' => null,
'avatar' => null,
'image' => null,
'video' => null,
'trailing' => null,
'iconRight' => null,
'link' => null,
@@ -53,6 +66,15 @@
@php
$isLink = filled($link) && ! $disabled;
$lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0);
// `video` is either a poster URL or a `<x-slot:video>`; a slot is an object, which `filled()`
// would call full even when it holds nothing.
$hasVideo = $video instanceof \Illuminate\View\ComponentSlot ? $video->isNotEmpty() : filled($video);
// The tallest element sets the item's height: the small video needs the 72px two-line row,
// and the large one goes with the 88px three-line item M3 draws it in.
$videoLarge = $hasVideo && $lines === 2;
$lines = $hasVideo ? max($lines, 1) : $lines;
$initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
$option = $selectable || $selection !== null;
$check = $option && $selected && blank($iconRight);
@@ -82,6 +104,18 @@
>
@isset($leading)
<div class="flex shrink-0 items-center">{{ $leading }}</div>
@elseif ($hasVideo)
<div @class([
'shrink-0 overflow-hidden rounded-corner-sm bg-surface-container-highest *:size-full *:object-cover',
'h-16 w-28.5' => $videoLarge,
'h-14 w-25' => ! $videoLarge,
])>
@if ($video instanceof \Illuminate\View\ComponentSlot)
{{ $video }}
@else
<img src="{{ $video }}" alt="" />
@endif
</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>
@@ -55,6 +55,24 @@
@endforeach
</x-carousel>
BLADE,
'Uncontained, multi-aspect ratio: every item keeps its own shape' => <<<'BLADE'
<x-carousel layout="multi-aspect" label="Clips" height="200">
@foreach ([
['16/9', 'pill', 'bg-primary-container text-on-primary-container'],
['1/1', 'cookie-12', 'bg-tertiary-container text-on-tertiary-container'],
['9/16', 'arch', 'bg-secondary-container text-on-secondary-container'],
['4/3', 'slanted', 'bg-surface-container-highest text-primary'],
['3/4', 'bun', 'bg-primary text-on-primary'],
['16/9', 'very-sunny', 'bg-secondary text-on-secondary'],
] as [$aspect, $shape, $colours])
<x-carousel-item :aspect="$aspect" :label="$aspect">
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-20" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
'Full-screen: one item at a time, scrolled down' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="320" :controls="true">
@foreach ([
@@ -35,6 +35,14 @@
</x-slot:actions>
</x-card>
BLADE,
'A card being dragged' => <<<'BLADE'
<div x-data="{ dragged: false }" class="w-full max-w-sm space-y-4">
<x-button label="Pick it up or put it down" variant="tonal" x-on:click="dragged = ! dragged" />
<x-card variant="elevated" title="holiday-photos.zip" subtitle="248 MB" x-bind:data-dragged="dragged || null">
Elevation 4 and the 16% state layer, for as long as the application says the card is being carried.
</x-card>
</div>
BLADE,
'Lists' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<x-list dividers label="Files">
@@ -60,6 +68,24 @@
<x-list-item title="Thirty days" :selected="false" />
</x-list>
BLADE,
'A list with leading video' => <<<'BLADE'
<x-list dividers label="Clips" class="w-full max-w-md">
<x-list-item title="Sunrise over the lake" description="2:14 · 48 MB">
<x-slot:video>
<div class="grid place-items-center bg-linear-to-br from-primary-container to-tertiary-container text-on-primary-container">
<x-icon name="play_circle" class="size-6" />
</div>
</x-slot:video>
</x-list-item>
<x-list-item overline="Draft" title="Walking the old town" description="Uploaded yesterday · 7:48">
<x-slot:video>
<div class="grid place-items-center bg-linear-to-br from-secondary-container to-primary-container text-on-secondary-container">
<x-icon name="play_circle" class="size-6" />
</div>
</x-slot:video>
</x-list-item>
</x-list>
BLADE,
'Dividers and collapse' => <<<'BLADE'
<div class="w-full space-y-4">
<x-collapse title="How long do links last?" icon="schedule" open>
@@ -72,6 +98,19 @@
<div class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
</div>
BLADE,
'A divider with a subheader' => <<<'BLADE'
<div class="w-full max-w-md">
<x-divider text="Today" />
<x-list label="Today">
<x-list-item title="holiday-photos.zip" description="Shared with 3 people" icon="folder_zip" />
<x-list-item title="contract.pdf" description="Downloaded twice" icon="picture_as_pdf" />
</x-list>
<x-divider text="Earlier this week" class="mt-2" />
<x-list label="Earlier this week">
<x-list-item title="minutes.docx" description="Expired" icon="description" />
</x-list>
</div>
BLADE,
'Collapse bound to a property' => <<<'BLADE'
<div x-data="{ advanced: false }" class="w-full space-y-4">
<div class="flex flex-wrap items-center gap-4">
@@ -114,6 +153,27 @@
</x-modal>
</div>
BLADE,
'A standard side sheet: co-planar from expanded, modal below' => <<<'BLADE'
<div x-data="{ open: true }" class="w-full">
<div class="expanded:flex expanded:items-start expanded:gap-6">
<div class="min-w-0 flex-1 space-y-4">
<x-button label="Show or hide the filters" variant="tonal" x-on:click="open = ! open" />
<p class="type-body-md text-on-surface-variant">
From <code>expanded</code> (840px) the sheet beside this text is co-planar: no scrim, no focus trap, flat on the surface, divided from this column by a rule. Narrow the window and the same sheet becomes the modal one.
</p>
</div>
<x-drawer standard title="Filters" subtitle="Narrow the list down">
<x-list selectable label="Expiry">
<x-list-item title="One hour" :selected="false" />
<x-list-item title="Three days" :selected="true" />
<x-list-item title="Thirty days" :selected="false" />
</x-list>
<x-slot:actions><x-button label="Apply" variant="filled" /></x-slot:actions>
</x-drawer>
</div>
</div>
BLADE,
'Sheets' => <<<'BLADE'
<div x-data="{ open: false }">
<x-button label="Side sheet" variant="tonal" x-on:click="open = true" />
@@ -137,6 +197,18 @@
</x-list>
</x-bottom-sheet>
</div>
<div x-data="{ open: false }">
<x-button label="Bottom sheet with preset heights" variant="tonal" x-on:click="open = true" />
<x-bottom-sheet title="Nearby places" snap>
<p class="type-body-md text-on-surface-variant">Press the drag handle, or focus it and press Enter, to move to the next height; from the last it closes. Dragging it settles on the nearest.</p>
<x-list>
<x-list-item title="Hafen" description="240 m · open until 23:00" icon="location_on" />
<x-list-item title="Stadtbibliothek" description="600 m · closes at 18:00" icon="location_on" />
<x-list-item title="Seepromenade" description="1.1 km · always open" icon="location_on" />
</x-list>
</x-bottom-sheet>
</div>
BLADE,
];
@endphp
+15
View File
@@ -60,3 +60,18 @@ it('makes a directly actionable card the one tab stop, named by its title', func
->not->toContain('data-list-actionable')
->not->toContain('tabindex');
});
it('draws M3\'s dragged state while the application says the card is being carried', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/list.css');
expect((string) $this->blade('<x-card data-dragged variant="elevated">Body</x-card>'))
->toContain('data-dragged')
->and($css)
->toContain('[data-card][data-dragged] {')
->toContain('var(--md-sys-elevation-3),')
->toContain("[data-card='elevated'][data-dragged] {")
->toContain('var(--md-sys-elevation-4),')
->toContain('calc(var(--md-sys-state-dragged-state-layer-opacity) * 100%)')
->toContain(':not([data-dragged]):hover')
->toContain(':not([data-dragged]):active');
});
+32
View File
@@ -156,3 +156,35 @@ it('lays a label over an item on a scrim, and passes attributes to the item', fu
->and((string) $this->blade('<x-carousel-item>A</x-carousel-item>'))
->not->toContain('data-material-carousel-label');
});
it('lets a multi-aspect carousel size each item by its own ratio, between 9:16 and 16:9', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-carousel layout="multi-aspect" label="Clips" height="180">
<x-carousel-item aspect="16/9">A</x-carousel-item>
<x-carousel-item aspect="9:16">B</x-carousel-item>
<x-carousel-item>C</x-carousel-item>
<x-carousel-item aspect="4">D</x-carousel-item>
<x-carousel-item aspect="0.1">E</x-carousel-item>
</x-carousel>
BLADE);
expect($html)
->toContain('data-material-carousel="multi-aspect"')
->toContain('--material-carousel-pad: 16px')
->toContain('--material-carousel-height: 180px')
->toContain('ps-(--material-carousel-pad)')
->toContain('data-padding="16" data-padding-end="0"')
// M3's own range holds whatever the item asks for: 16/9 at the top, 9/16 at the bottom.
->toContain('aspect-ratio: 1.7778')
->toContain('aspect-ratio: 0.5625')
->toContain('aspect-ratio: 1')
->toContain('w-auto rounded-corner-xl')
// Uncontained scrolling, and nothing masks an item whose width is its own.
->not->toContain('snap-mandatory')
->not->toContain('--material-carousel-slot')
->not->toContain('clip-path')
->not->toContain('x-ref="probe"')
->and(substr_count($html, 'aspect-ratio: 1.7778'))->toBe(2)
->and((string) $this->blade('<x-carousel><x-carousel-item aspect="16/9">A</x-carousel-item></x-carousel>'))
->not->toContain('aspect-ratio');
});
+18
View File
@@ -6,3 +6,21 @@ it('separates horizontally or vertically, inset on request', function () {
->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"');
});
it('heads a group with a subheader, the rule running on from the words', function () {
$html = (string) $this->blade('<x-divider text="Recent" />');
expect($html)
->toContain('flex shrink-0 items-center gap-1 pb-2')
->toContain('<span class="shrink-0 type-title-sm text-on-surface-variant">Recent</span>')
->toMatch('/role="separator" aria-orientation="horizontal"\s+class="me-2 h-px min-w-0 flex-1 bg-outline-variant"/')
->and(strpos($html, 'Recent'))->toBeLessThan(strpos($html, 'role="separator"'))
->and((string) $this->blade('<x-divider text="Recent" inset />'))->toContain('flex shrink-0 items-center gap-1 pb-2 ms-4')
->and((string) $this->blade('<x-divider text="Recent" decorative />'))
->toContain('>Recent</span>')
->toContain('aria-hidden="true"')
->not->toContain('role="separator"')
->and((string) $this->blade('<x-divider text="Recent" vertical />'))
->not->toContain('Recent')
->toContain('aria-orientation="vertical"');
});
+17
View File
@@ -93,3 +93,20 @@ it('marks a selected item and takes controls in its slots', function () {
->toContain('<input type="checkbox">')
->toContain('<button>⋮</button>');
});
it('leads with M3\'s video, in the size the item\'s height calls for', function () {
expect((string) $this->blade('<x-list-item title="Sunrise" video="/poster.jpg" />'))
->toContain('h-14 w-25')
->toContain('rounded-corner-sm')
->toContain('<img src="/poster.jpg" alt="" />')
->toContain('min-h-18 py-2')
->and((string) $this->blade('<x-list-item title="Sunrise" overline="Draft" description="7:48" video="/poster.jpg" />'))
->toContain('h-16 w-28.5')
->toContain('min-h-22 py-3')
->and((string) $this->blade('<x-list-item title="Sunrise"><x-slot:video><video src="/clip.mp4"></video></x-slot:video></x-list-item>'))
->toContain('<video src="/clip.mp4"></video>')
->toContain('h-14 w-25')
->and((string) $this->blade('<x-list-item title="Sunrise" />'))
->not->toContain('h-14 w-25')
->toContain('min-h-14 py-2');
});
+52 -2
View File
@@ -127,7 +127,7 @@ it('slides a side sheet in from either edge, and is a pane from expanded when as
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('...materialBottomSheet(false, JSON.parse(')
->toContain('bg-scrim/32')
->toContain('x-trap.inert.noscroll="open"')
->toContain('rounded-t-corner-xl bg-surface-container-low')
@@ -137,9 +137,59 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s
->toContain('touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant ')
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(true)')
->toContain('...materialBottomSheet(true, JSON.parse(')
->not->toContain('bg-scrim/32')
->not->toContain('aria-modal')
->and((string) $this->blade('<x-bottom-sheet height="90dvh">Body</x-bottom-sheet>'))
->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))');
});
it('is M3\'s standard side sheet from expanded, and the modal one below', function () {
$html = (string) $this->blade('<x-drawer standard title="Filters">Body</x-drawer>');
expect($html)
->toContain('data-standard')
->toContain('expanded:sticky expanded:top-0 expanded:h-dvh expanded:shrink-0 expanded:self-start')
->toContain('expanded:relative expanded:top-0 expanded:z-auto expanded:h-full expanded:rounded-corner-none expanded:bg-surface expanded:shadow-none')
->toContain('expanded:border-s expanded:border-outline-variant')
->toContain('--sheet-width: min(25rem, 25rem)')
->toContain("matchMedia('(width >= 52.5rem)')")
->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('bg-scrim/32 expanded:hidden')
->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer standard side="start" width="30rem">Body</x-drawer>'))
->toContain('expanded:border-e expanded:border-outline-variant')
->toContain('--sheet-width: min(30rem, 25rem)')
->and((string) $this->blade('<x-drawer standard pane>Body</x-drawer>'))
->toContain('data-pane')
->not->toContain('data-standard')
->and((string) $this->blade('<x-drawer title="Filters">Body</x-drawer>'))
->not->toContain('data-standard')
->not->toContain('expanded:hidden');
});
it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', function () {
$html = (string) $this->blade('<x-bottom-sheet title="Share via" snap>Body</x-bottom-sheet>');
expect($html)
->toContain('--sheet-stop: 50dvh; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))')
->toContain('h-(--sheet-max-height) transition-[height]')
->toContain('x-ref="probe"')
->toContain('aria-label="Change the sheet height"')
->toContain('x-bind:aria-label="handleLabel"')
->toContain('x-on:click="activate()"')
->toContain('<span class="sr-only" aria-live="polite" x-text="announcement"></span>')
->toContain('25dvh')
->toContain('90dvh')
->toContain('Height 2 of 3')
->and((string) $this->blade('<x-bottom-sheet heights="30dvh,60dvh" height="60dvh">Body</x-bottom-sheet>'))
->toContain('--sheet-stop: 60dvh;')
->toContain('30dvh')
->and((string) $this->blade('<x-bottom-sheet :heights="[25, 50, 90]">Body</x-bottom-sheet>'))
->toContain('--sheet-stop: 50dvh;')
->and((string) $this->blade('<x-bottom-sheet heights="50dvh">Body</x-bottom-sheet>'))
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
->not->toContain('--sheet-stop')
->not->toContain('sr-only')
->toContain('aria-label="Close"');
});