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>
|
</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.
|
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>`, `<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`.
|
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 />` — 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>`
|
### `<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.
|
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.
|
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>`
|
### `<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>`
|
### `<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>
|
</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>`
|
### `<x-chip>`
|
||||||
|
|
||||||
|
|||||||
@@ -86,23 +86,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
@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);
|
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);
|
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);
|
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);
|
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>`). */
|
/* 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)) {
|
[data-card][data-list-row]:is(:focus-visible, :has([data-list-open]:focus-visible)) {
|
||||||
outline: 3px solid var(--md-sys-color-secondary);
|
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
|
* `materialBottomSheet(standard, presets)`: the behaviour of `<x-bottom-sheet>`, spread into its
|
||||||
* alongside `open` (entangled with Livewire, or the surrounding Alpine scope's).
|
* 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
|
* Without preset heights the sheet is as tall as its content allows and a downward drag follows the
|
||||||
* flicked down, closes it; otherwise it springs back. The drag starts on the handle, or anywhere
|
* pointer: released past a quarter of the sheet's height or flicked down, it closes; otherwise it
|
||||||
* on the sheet while its content is scrolled to the top, so scrolling the content still scrolls.
|
* 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 DISMISS_FRACTION = 0.25
|
||||||
const FLICK_PX_PER_MS = 0.5
|
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,
|
standard,
|
||||||
|
stops: presets.stops ?? [],
|
||||||
|
labels: presets.labels ?? {},
|
||||||
|
start: presets.start ?? 0,
|
||||||
|
stop: presets.start ?? 0,
|
||||||
dragged: 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() {
|
close() {
|
||||||
this.dragged = 0
|
this.dragged = 0
|
||||||
|
this.dragging = false
|
||||||
|
this.move(this.start, false)
|
||||||
this.open = typeof this.open === 'boolean' ? false : null
|
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) {
|
dragStart(event) {
|
||||||
const sheet = this.$refs.sheet
|
const sheet = this.$refs.sheet
|
||||||
const onHandle = event.target.closest('[data-drag-handle]')
|
const onHandle = event.target.closest('[data-drag-handle]')
|
||||||
@@ -27,18 +118,28 @@ window.materialBottomSheet = (standard = false) => ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preset = this.preset
|
||||||
const startY = event.clientY
|
const startY = event.clientY
|
||||||
|
const startHeight = sheet.offsetHeight
|
||||||
let lastY = startY
|
let lastY = startY
|
||||||
let lastAt = performance.now()
|
let lastAt = performance.now()
|
||||||
let velocity = 0
|
let velocity = 0
|
||||||
|
|
||||||
|
this.dragging = true
|
||||||
|
this.height = startHeight
|
||||||
|
|
||||||
const move = (moveEvent) => {
|
const move = (moveEvent) => {
|
||||||
const now = performance.now()
|
const now = performance.now()
|
||||||
|
|
||||||
velocity = (moveEvent.clientY - lastY) / Math.max(now - lastAt, 1)
|
velocity = (moveEvent.clientY - lastY) / Math.max(now - lastAt, 1)
|
||||||
lastY = moveEvent.clientY
|
lastY = moveEvent.clientY
|
||||||
lastAt = now
|
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 = () => {
|
const end = () => {
|
||||||
@@ -46,7 +147,11 @@ window.materialBottomSheet = (standard = false) => ({
|
|||||||
window.removeEventListener('pointerup', end)
|
window.removeEventListener('pointerup', end)
|
||||||
window.removeEventListener('pointercancel', 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()
|
this.close()
|
||||||
} else {
|
} else {
|
||||||
this.dragged = 0
|
this.dragged = 0
|
||||||
@@ -57,4 +162,20 @@ window.materialBottomSheet = (standard = false) => ({
|
|||||||
window.addEventListener('pointerup', end)
|
window.addEventListener('pointerup', end)
|
||||||
window.addEventListener('pointercancel', 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
|
* 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.
|
* 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
|
* Copyright 2023-2024 The Android Open Source Project
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
@@ -803,6 +810,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
maxScroll: 0,
|
maxScroll: 0,
|
||||||
rtl: false,
|
rtl: false,
|
||||||
vertical: false,
|
vertical: false,
|
||||||
|
measured: false,
|
||||||
frame: null,
|
frame: null,
|
||||||
target: null,
|
target: null,
|
||||||
targetAt: 0,
|
targetAt: 0,
|
||||||
@@ -862,6 +870,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
state.rtl = style.direction === 'rtl'
|
state.rtl = style.direction === 'rtl'
|
||||||
state.vertical = root.dataset.materialCarousel === 'full-screen'
|
state.vertical = root.dataset.materialCarousel === 'full-screen'
|
||||||
|
state.measured = root.dataset.materialCarousel === 'multi-aspect'
|
||||||
state.items = [...scroller.children]
|
state.items = [...scroller.children]
|
||||||
.filter((element) => element.matches(ITEM))
|
.filter((element) => element.matches(ITEM))
|
||||||
.map((element) => ({
|
.map((element) => ({
|
||||||
@@ -888,6 +897,28 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return
|
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 space = scroller.clientWidth
|
||||||
const itemSpacing = parseFloat(style.columnGap) || 0
|
const itemSpacing = parseFloat(style.columnGap) || 0
|
||||||
const padding = Number(root.dataset.padding) || 0
|
const padding = Number(root.dataset.padding) || 0
|
||||||
@@ -970,8 +1001,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
cancelAnimationFrame(state.frame)
|
cancelAnimationFrame(state.frame)
|
||||||
state.frame = null
|
state.frame = null
|
||||||
|
|
||||||
// Nothing is masked in the vertical full-screen layout; only the buttons change.
|
// Nothing is masked in the full-screen or multi-aspect layouts; only the buttons
|
||||||
if (state.vertical) {
|
// change.
|
||||||
|
if (state.vertical || state.measured) {
|
||||||
this.buttons()
|
this.buttons()
|
||||||
state.mutations?.takeRecords()
|
state.mutations?.takeRecords()
|
||||||
|
|
||||||
@@ -1092,7 +1124,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
scrollToItem(index) {
|
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
|
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. */
|
/** Whether item `index` is not fully in focus, so a press or focus should bring it there. */
|
||||||
isMasked(index) {
|
isMasked(index) {
|
||||||
return state.vertical
|
if (state.vertical) {
|
||||||
? Math.abs(state.snaps[index] - this.scrollOffset()) > 1
|
return Math.abs(state.snaps[index] - this.scrollOffset()) > 1
|
||||||
: parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
|
}
|
||||||
|
|
||||||
|
// 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() {
|
destroy() {
|
||||||
|
|||||||
@@ -15,6 +15,24 @@
|
|||||||
default is `50dvh`, under a ceiling of the screen less M3's 72dp top margin. Content scrolls
|
default is `50dvh`, under a ceiling of the screen less M3's 72dp top margin. Content scrolls
|
||||||
inside.
|
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
|
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
|
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). --}}
|
`DragHandleVerticalPadding = 22.dp` (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets → Specs). --}}
|
||||||
@@ -23,16 +41,51 @@
|
|||||||
'title' => null,
|
'title' => null,
|
||||||
'standard' => false,
|
'standard' => false,
|
||||||
'height' => '50dvh',
|
'height' => '50dvh',
|
||||||
|
'heights' => null,
|
||||||
|
'snap' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$model = $attributes->wire('model')->value() ?: null;
|
$model = $attributes->wire('model')->value() ?: null;
|
||||||
$id = $attributes->get('id') ?? 'material-bottom-sheet-'.substr(md5($model.'|'.$title), 0, 10);
|
$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
|
@endphp
|
||||||
|
|
||||||
<div
|
<div
|
||||||
x-data="{
|
x-data="{
|
||||||
...materialBottomSheet({{ $standard ? 'true' : 'false' }}),
|
...materialBottomSheet({{ $standard ? 'true' : 'false' }}, {{ $presets }}),
|
||||||
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
||||||
}"
|
}"
|
||||||
x-on:keydown.window.escape="if (open && ! standard) close()"
|
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>
|
<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
|
@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
|
<section
|
||||||
x-cloak
|
x-cloak
|
||||||
x-show="open"
|
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="transition-[translate] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate"
|
||||||
x-transition:leave-start="translate-y-0"
|
x-transition:leave-start="translate-y-0"
|
||||||
x-transition:leave-end="translate-y-full"
|
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)"
|
x-on:pointerdown="dragStart($event)"
|
||||||
id="{{ $id }}"
|
id="{{ $id }}"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@unless ($standard) aria-modal="true" @endunless
|
@unless ($standard) aria-modal="true" @endunless
|
||||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
@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([
|
{{ $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',
|
'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'),
|
$attributes->get('class'),
|
||||||
]) }}
|
]) }}
|
||||||
>
|
>
|
||||||
<div class="flex shrink-0 cursor-grab justify-center py-5.5 active:cursor-grabbing" data-drag-handle>
|
<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>
|
</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">
|
<div x-ref="body" class="min-h-0 flex-1 overflow-y-auto px-6 pb-6">
|
||||||
@if (filled($title))
|
@if (filled($title))
|
||||||
<h2 id="{{ $id }}-title" class="mb-4 type-title-lg">{{ $title }}</h2>
|
<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.
|
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.
|
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
|
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. --}}
|
order; use `variant`, or colour a wrapper inside. --}}
|
||||||
|
|
||||||
|
|||||||
@@ -21,40 +21,67 @@
|
|||||||
extra-large corner (28px, CarouselDefaults' item shape) and moved by
|
extra-large corner (28px, CarouselDefaults' item shape) and moved by
|
||||||
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
|
`--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 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([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
|
'aspect' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
{{-- The layout of the `<x-carousel>` around it: the full-screen one is a vertical row of
|
{{-- 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([
|
@aware([
|
||||||
'layout' => 'multi-browse',
|
'layout' => 'multi-browse',
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$vertical = $layout === 'full-screen';
|
$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
|
@endphp
|
||||||
|
|
||||||
<div {{ $attributes
|
<div {{ $attributes
|
||||||
->class([
|
->class([
|
||||||
'focus-ring relative h-full shrink-0 snap-start snap-always focus-visible:-outline-offset-3',
|
'focus-ring relative h-full shrink-0 snap-start snap-always focus-visible:-outline-offset-3',
|
||||||
'w-full' => $vertical,
|
'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',
|
'role' => 'group',
|
||||||
'aria-roledescription' => __('slide'),
|
'aria-roledescription' => __('slide'),
|
||||||
'aria-label' => '[material-carousel-position]',
|
'aria-label' => '[material-carousel-position]',
|
||||||
'data-material-carousel-item' => true,
|
'data-material-carousel-item' => true,
|
||||||
'tabindex' => '0',
|
'tabindex' => '0',
|
||||||
]) }}>
|
'style' => $ratio === null ? null : "aspect-ratio: {$ratio}",
|
||||||
|
], fn ($value): bool => $value !== null)) }}>
|
||||||
<div
|
<div
|
||||||
data-material-carousel-surface
|
data-material-carousel-surface
|
||||||
@class([
|
@class([
|
||||||
'relative size-full overflow-hidden bg-surface-container-highest text-on-surface',
|
'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">
|
<div data-material-carousel-content class="size-full [&>img]:size-full [&>img]:object-cover">
|
||||||
|
|||||||
@@ -19,6 +19,12 @@
|
|||||||
does.
|
does.
|
||||||
- `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves —
|
- `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves —
|
||||||
HorizontalUncontainedCarousel. No snapping, as Compose's uncontained fling.
|
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
|
- `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 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
|
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
|
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
|
`padding` do not apply; `height` is the height of each item, so give it the room a
|
||||||
portrait image wants.
|
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
|
`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
|
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
|
that far in from the edges while items in between scroll to them. M3's specs table gives
|
||||||
@@ -70,8 +85,9 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@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';
|
$vertical = $layout === 'full-screen';
|
||||||
|
$multiAspect = $layout === 'multi-aspect';
|
||||||
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
|
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
|
||||||
$label ??= __('Carousel');
|
$label ??= __('Carousel');
|
||||||
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||||
@@ -86,11 +102,12 @@
|
|||||||
$preferredWidth = match ($layout) {
|
$preferredWidth = match ($layout) {
|
||||||
'multi-browse', 'uncontained' => $cssLength($itemWidth) ?? '186px',
|
'multi-browse', 'uncontained' => $cssLength($itemWidth) ?? '186px',
|
||||||
'hero' => $cssLength($itemWidth),
|
'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) {
|
$slotWidth = match (true) {
|
||||||
$vertical => null,
|
$vertical, $multiAspect => null,
|
||||||
$preferredWidth === null => 'calc(100% - 64px)',
|
$preferredWidth === null => 'calc(100% - 64px)',
|
||||||
default => $preferredWidth,
|
default => $preferredWidth,
|
||||||
};
|
};
|
||||||
@@ -114,7 +131,7 @@
|
|||||||
// only for uncontained, none for full-screen, which is edge to edge.
|
// only for uncontained, none for full-screen, which is edge to edge.
|
||||||
$padding = max(0, (float) $padding);
|
$padding = max(0, (float) $padding);
|
||||||
$paddingStart = $vertical ? 0.0 : $padding;
|
$paddingStart = $vertical ? 0.0 : $padding;
|
||||||
$paddingEnd = $vertical || $layout === 'uncontained' ? 0.0 : $padding;
|
$paddingEnd = $vertical || $layout === 'uncontained' || $multiAspect ? 0.0 : $padding;
|
||||||
|
|
||||||
$attributes = $attributes
|
$attributes = $attributes
|
||||||
->class('relative')
|
->class('relative')
|
||||||
@@ -126,6 +143,9 @@
|
|||||||
'style' => implode('; ', array_filter([
|
'style' => implode('; ', array_filter([
|
||||||
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
|
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
|
||||||
$slotWidth ? "--material-carousel-slot: {$slotWidth}" : 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'),
|
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
|
||||||
])),
|
])),
|
||||||
], fn ($value): bool => $value !== null));
|
], fn ($value): bool => $value !== null));
|
||||||
@@ -148,7 +168,10 @@
|
|||||||
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
'[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,
|
'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,
|
'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 !!}
|
{!! $slides !!}
|
||||||
|
|||||||
@@ -2,23 +2,49 @@
|
|||||||
|
|
||||||
Horizontal by default; `vertical` stands it between items in a row (give the row a height).
|
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
|
`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([
|
@props([
|
||||||
'vertical' => false,
|
'vertical' => false,
|
||||||
'inset' => false,
|
'inset' => false,
|
||||||
'middle' => false,
|
'middle' => false,
|
||||||
'decorative' => false,
|
'decorative' => false,
|
||||||
|
'text' => null,
|
||||||
])
|
])
|
||||||
|
|
||||||
<div
|
@if (filled($text) && ! $vertical)
|
||||||
@if ($decorative) aria-hidden="true" @else role="separator" aria-orientation="{{ $vertical ? 'vertical' : 'horizontal' }}" @endif
|
<div {{ $attributes->class([
|
||||||
{{ $attributes->class([
|
'flex shrink-0 items-center gap-1 pb-2',
|
||||||
'shrink-0 bg-outline-variant',
|
'ms-4' => $inset,
|
||||||
'h-px w-auto' => ! $vertical,
|
'mx-4' => $middle,
|
||||||
'w-px self-stretch' => $vertical,
|
]) }}>
|
||||||
'ms-4' => $inset && ! $vertical,
|
<span class="shrink-0 type-title-sm text-on-surface-variant">{{ $text }}</span>
|
||||||
'mx-4' => $middle && ! $vertical,
|
<div
|
||||||
'my-2' => $middle && $vertical,
|
@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>
|
||||||
|
</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-*`
|
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).
|
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
|
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
|
list in an `expanded:flex expanded:items-start expanded:gap-6` row, the drawer sticks under the
|
||||||
top of the viewport, the
|
top of the viewport, the
|
||||||
@@ -29,8 +48,8 @@
|
|||||||
sheet's open/close flow or tell whether it is transient or permanent
|
sheet's open/close flow or tell whether it is transient or permanent
|
||||||
(docs/reference/m3/components-actions-communication-containment.md § Side sheets →
|
(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
|
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
|
ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane or a
|
||||||
neither.
|
standard sheet, which have neither from `expanded`.
|
||||||
|
|
||||||
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
|
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
|
||||||
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}}
|
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}}
|
||||||
@@ -48,6 +67,7 @@
|
|||||||
'pane' => false,
|
'pane' => false,
|
||||||
'paneWidth' => '22.5rem',
|
'paneWidth' => '22.5rem',
|
||||||
'paneCloseOnEscape' => false,
|
'paneCloseOnEscape' => false,
|
||||||
|
'standard' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
@php
|
@php
|
||||||
@@ -55,9 +75,21 @@
|
|||||||
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
|
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
|
||||||
$start = $side === 'start';
|
$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
|
// M3 requires a close affordance; the prop can only ever add one, never take away the last
|
||||||
// way out of the sheet.
|
// way out of the sheet. A standard sheet keeps no scrim and no trap from `expanded`, and
|
||||||
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || ($pane && ! $paneCloseOnEscape);
|
// Escape leaves it open there, so it always draws one.
|
||||||
|
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || $standard || ($pane && ! $paneCloseOnEscape);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -65,7 +97,7 @@
|
|||||||
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
||||||
wide: false,
|
wide: false,
|
||||||
close() { this.open = typeof this.open === 'boolean' ? false : null; },
|
close() { this.open = typeof this.open === 'boolean' ? false : null; },
|
||||||
@if ($pane)
|
@if ($wide)
|
||||||
init() {
|
init() {
|
||||||
const query = window.matchMedia('(width >= 52.5rem)');
|
const query = window.matchMedia('(width >= 52.5rem)');
|
||||||
this.wide = query.matches;
|
this.wide = query.matches;
|
||||||
@@ -79,6 +111,12 @@
|
|||||||
x-bind:class="! open && 'expanded:hidden'"
|
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"
|
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
|
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
|
@endif
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -86,7 +124,7 @@
|
|||||||
x-show="open"
|
x-show="open"
|
||||||
x-transition.opacity.duration.200ms
|
x-transition.opacity.duration.200ms
|
||||||
@if (! $withoutBackdropClose) x-on:click="close()" @endif
|
@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"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
@@ -95,24 +133,30 @@
|
|||||||
x-show="open"
|
x-show="open"
|
||||||
x-trap.inert.noscroll="open && ! wide"
|
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="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: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="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-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 }}"
|
id="{{ $id }}"
|
||||||
x-bind:role="wide ? 'region' : 'dialog'"
|
x-bind:role="wide ? 'region' : 'dialog'"
|
||||||
x-bind:aria-modal="wide ? null : 'true'"
|
x-bind:aria-modal="wide ? null : 'true'"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
@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([
|
{{ $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',
|
'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,
|
'end-0 medium:rounded-s-corner-lg' => ! $start,
|
||||||
'start-0 medium:rounded-e-corner-lg' => $start,
|
'start-0 medium:rounded-e-corner-lg' => $start,
|
||||||
'medium:w-(--sheet-width) medium:max-w-[calc(100vw-4rem)]',
|
'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,
|
'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'),
|
$attributes->get('class'),
|
||||||
]) }}
|
]) }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,15 +5,27 @@
|
|||||||
|
|
||||||
`title` (or the slot) is the body-large headline; `overline` sits above it (label-small),
|
`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`,
|
`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
|
`avatar` (an image URL, or initials in primary-container), `image` (a 56px thumbnail) or
|
||||||
`leading` slot (a checkbox, a switch). The trailing element is `trailing` text (label-small),
|
`video`, or a `leading` slot (a checkbox, a switch). The trailing element is `trailing` text
|
||||||
`icon-right`, or an `end` slot for controls (a menu, a switch). One-, two- and three-line
|
(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
|
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
|
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,
|
"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
|
or 20px in a `segmented` list, which is M3 Expressive's (ListTokens, androidx Compose
|
||||||
Material 3, Apache-2.0).
|
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
|
`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
|
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,
|
control that opens — see resources/js/list-rows.js. `selected` (true) is M3's selected item,
|
||||||
@@ -33,6 +45,7 @@
|
|||||||
'icon' => null,
|
'icon' => null,
|
||||||
'avatar' => null,
|
'avatar' => null,
|
||||||
'image' => null,
|
'image' => null,
|
||||||
|
'video' => null,
|
||||||
'trailing' => null,
|
'trailing' => null,
|
||||||
'iconRight' => null,
|
'iconRight' => null,
|
||||||
'link' => null,
|
'link' => null,
|
||||||
@@ -53,6 +66,15 @@
|
|||||||
@php
|
@php
|
||||||
$isLink = filled($link) && ! $disabled;
|
$isLink = filled($link) && ! $disabled;
|
||||||
$lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0);
|
$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, '.');
|
$initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
|
||||||
$option = $selectable || $selection !== null;
|
$option = $selectable || $selection !== null;
|
||||||
$check = $option && $selected && blank($iconRight);
|
$check = $option && $selected && blank($iconRight);
|
||||||
@@ -82,6 +104,18 @@
|
|||||||
>
|
>
|
||||||
@isset($leading)
|
@isset($leading)
|
||||||
<div class="flex shrink-0 items-center">{{ $leading }}</div>
|
<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)
|
@elseif ($avatar)
|
||||||
@if ($initials)
|
@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>
|
<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
|
@endforeach
|
||||||
</x-carousel>
|
</x-carousel>
|
||||||
BLADE,
|
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'
|
'Full-screen: one item at a time, scrolled down' => <<<'BLADE'
|
||||||
<x-carousel layout="full-screen" label="Wallpapers" height="320" :controls="true">
|
<x-carousel layout="full-screen" label="Wallpapers" height="320" :controls="true">
|
||||||
@foreach ([
|
@foreach ([
|
||||||
|
|||||||
@@ -35,6 +35,14 @@
|
|||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-card>
|
</x-card>
|
||||||
BLADE,
|
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'
|
'Lists' => <<<'BLADE'
|
||||||
<div class="grid w-full gap-6 medium:grid-cols-2">
|
<div class="grid w-full gap-6 medium:grid-cols-2">
|
||||||
<x-list dividers label="Files">
|
<x-list dividers label="Files">
|
||||||
@@ -60,6 +68,24 @@
|
|||||||
<x-list-item title="Thirty days" :selected="false" />
|
<x-list-item title="Thirty days" :selected="false" />
|
||||||
</x-list>
|
</x-list>
|
||||||
BLADE,
|
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'
|
'Dividers and collapse' => <<<'BLADE'
|
||||||
<div class="w-full space-y-4">
|
<div class="w-full space-y-4">
|
||||||
<x-collapse title="How long do links last?" icon="schedule" open>
|
<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 class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
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'
|
'Collapse bound to a property' => <<<'BLADE'
|
||||||
<div x-data="{ advanced: false }" class="w-full space-y-4">
|
<div x-data="{ advanced: false }" class="w-full space-y-4">
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
@@ -114,6 +153,27 @@
|
|||||||
</x-modal>
|
</x-modal>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
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'
|
'Sheets' => <<<'BLADE'
|
||||||
<div x-data="{ open: false }">
|
<div x-data="{ open: false }">
|
||||||
<x-button label="Side sheet" variant="tonal" x-on:click="open = true" />
|
<x-button label="Side sheet" variant="tonal" x-on:click="open = true" />
|
||||||
@@ -137,6 +197,18 @@
|
|||||||
</x-list>
|
</x-list>
|
||||||
</x-bottom-sheet>
|
</x-bottom-sheet>
|
||||||
</div>
|
</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,
|
BLADE,
|
||||||
];
|
];
|
||||||
@endphp
|
@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('data-list-actionable')
|
||||||
->not->toContain('tabindex');
|
->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>'))
|
->and((string) $this->blade('<x-carousel-item>A</x-carousel-item>'))
|
||||||
->not->toContain('data-material-carousel-label');
|
->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 inset />'))->toContain('ms-4')
|
||||||
->and((string) $this->blade('<x-divider decorative />'))->toContain('aria-hidden="true"')->not->toContain('role="separator"');
|
->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('<input type="checkbox">')
|
||||||
->toContain('<button>⋮</button>');
|
->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 () {
|
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>'))
|
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('bg-scrim/32')
|
||||||
->toContain('x-trap.inert.noscroll="open"')
|
->toContain('x-trap.inert.noscroll="open"')
|
||||||
->toContain('rounded-t-corner-xl bg-surface-container-low')
|
->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('touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant ')
|
||||||
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
|
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
|
||||||
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
|
->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('bg-scrim/32')
|
||||||
->not->toContain('aria-modal')
|
->not->toContain('aria-modal')
|
||||||
->and((string) $this->blade('<x-bottom-sheet height="90dvh">Body</x-bottom-sheet>'))
|
->and((string) $this->blade('<x-bottom-sheet height="90dvh">Body</x-bottom-sheet>'))
|
||||||
->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))');
|
->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