Merge branch 'worktree-agent-a3082a7e6ef1bee94'
This commit is contained in:
@@ -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>`
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 !!}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
]) }}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user