Merge branch 'worktree-agent-a6769a5cd2c4b070e'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:19:47 +02:00
26 changed files with 747 additions and 214 deletions
@@ -423,7 +423,7 @@ The `illustration` slot draws the application's own artwork in place of the shap
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link.
A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move.
```blade
<x-card variant="outlined" data-list-row wire:key="share-{{ $share->id }}">
@@ -432,9 +432,13 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
</x-card>
```
That is M3's *non-actionable card with actionable elements*: Tab walks the controls inside. For M3's *directly actionable card*, where Tab lands on the card and then moves to the next card, add `actionable` (and `role="link"` where it goes somewhere) and put `tabindex="-1"` on the opener — the card is then the tab stop, named by its `title`, and Enter or Space on it reaches the opener while the card's other actions follow it.
### `<x-list>`, `<x-list-item>`
`<x-list>`: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). `<x-list-item>`: `title` (or slot), `overline`, `description`, leading `icon` / `avatar` (image URL or initials) / `image` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content.
`<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.
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`.
```blade
<x-list segmented label="Files">
@@ -476,15 +480,15 @@ An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; cl
</x-modal>
```
Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent` (no Escape or scrim), `fullscreen` (whole screen on a compact window, below `medium`, for forms — M3 allows a full-screen dialog only there), `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead.
Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (a divider under the headline and above the actions), `persistent` (no Escape or scrim), `fullscreen` (whole screen on a compact window, below `medium`, for forms — M3 allows a full-screen dialog only there), `alert` (`role="alertdialog"` for a dialog that interrupts to say something important — not for forms), `box-class`. The headline and the action row are pinned and only the body between them scrolls, as M3 requires, so do not put `overflow` on `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead.
### `<x-drawer>`
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`, `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.
### `<x-bottom-sheet>`
An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`90dvh`), `actions` slot.
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.
### `<x-carousel>`, `<x-carousel-item>`
@@ -498,7 +502,7 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
</x-carousel>
```
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `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, 0), `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 focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. 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`, `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.
### `<x-chip>`
+30
View File
@@ -0,0 +1,30 @@
/*
* `<x-collapse>`'s height, eased open.
*
* The native `<details>` opens and closes by itself; what it cannot do on its own is animate, so
* the component sets `interpolate-size: allow-keywords` (which makes `0` → `auto` an animatable
* pair) and this transitions `block-size` on `::details-content`, the pseudo-element that holds
* everything after the `<summary>`. `content-visibility` goes with it, `allow-discrete`, so the
* content stays rendered while it closes rather than vanishing on the first frame.
*
* The fast spatial spring, as the chevron beside it: M3's spatial track is for anything that
* changes size or position. Under reduced motion the duration token is zero
* (resources/css/tokens/motion.css), so the section snaps open with nothing else to do.
*
* Not an M3 component — M3 has expandable list items and menu expansion, no standalone disclosure
* — so the geometry is the library's and only the motion tokens are M3's.
*/
@layer components {
[data-collapse]::details-content {
block-size: 0;
overflow: hidden;
transition:
block-size var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
content-visibility var(--md-sys-motion-spatial-fast-duration) allow-discrete;
}
[data-collapse][open]::details-content {
block-size: auto;
}
}
+52 -12
View File
@@ -9,9 +9,36 @@
*
* Unlayered where a component paints its fill as a utility (`<x-card>`): anything in a @layer loses
* to a utility whatever its specificity.
*
* Every selector that paints a row is `:where()`, so they all weigh nothing and the later rule
* wins: the segmented list's own fill is declared first and a row's state layer, further down,
* paints over it. Written any other way — outside the layer, or with its real specificity — the
* fill would beat the state layer and a segmented row would answer nothing.
*/
@layer components {
/*
* M3 Expressive's segmented list gives each item its own container. ListTokens.kt:201 —
* `ItemSegmentedContainerColor get() = ColorSchemeKeyTokens.Surface`.
*/
:where([data-list='segmented'] > [data-list-item]) {
background-color: var(--md-sys-color-surface);
}
/*
* ListTokens.kt:30,36 — `DividerLeadingSpace` and `DividerTrailingSpace` are 16dp, so a
* list's dividers are inset from both ends rather than full-bleed (`<x-list dividers>`).
*/
:where([data-list][data-dividers] > [data-list-item]:not(:last-child))::after {
content: '';
position: absolute;
inset-inline: 16px;
bottom: 0;
height: 1px;
background-color: var(--md-sys-color-outline-variant);
pointer-events: none;
}
:where([data-list-row]) {
cursor: pointer;
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast),
@@ -39,26 +66,32 @@
outline: none;
}
[data-list='segmented'] > [data-list-item] {
background-color: var(--md-sys-color-surface-container);
}
:is([data-list-row], [data-list-item])[data-selected]:not([data-card]) {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
/* A card that opens answers with its container one tone up and its corner opening a step. */
/*
* A card that opens answers with M3's state layer and one step of elevation, both written as one
* box-shadow because the elevated card's rest level is a `shadow-elevation-1` utility that only
* an unlayered rule can replace. Per-state elevation from the token files (the specs page's
* Hovered / Focused / Pressed rows): elevated 1 → 2 hovered → 1 focused and pressed; filled and
* outlined 0 → 1 hovered → 0. Its corner does not move: M3 gives a card one shape and specifies
* shape morph for buttons, FABs and list items, never for cards.
*/
[data-card][data-list-row] {
transition-property: border-radius, background-color, box-shadow;
transition-property: background-color, box-shadow;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-default);
}
@media (hover: hover) {
[data-card][data-list-row]:hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) {
border-radius: var(--md-sys-shape-corner-lg);
box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
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)) {
box-shadow: var(--md-sys-elevation-2), inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
@@ -66,15 +99,22 @@
box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
[data-card][data-list-row]:has([data-list-open]:focus-visible) {
[data-card='elevated'][data-list-row]: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);
}
/* Focus is on the card itself when the card is the tab stop (`<x-card actionable>`). */
[data-card][data-list-row]:is(:focus-visible, :has([data-list-open]:focus-visible)) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
/*
* M3 Expressive's segmented list (`<x-list segmented>`): each item its own surface, 2px apart,
* extra-small corners that open to large at the ends, and to large while hovered, pressed or
* selected (ListTokens, androidx Compose Material 3, Apache-2.0).
* extra-small corners that open to medium while hovered and to large at the ends and while
* focused, pressed or selected — the specs page's interaction-state expressive shapes, and
* ListTokens' `ItemPressedContainerExpressiveShape` / `ItemDraggedContainerExpressiveShape`
* (androidx Compose Material 3, Apache-2.0).
*/
[data-list='segmented'] > [data-list-item] {
border-radius: var(--md-sys-shape-corner-xs);
@@ -96,6 +136,6 @@
}
}
[data-list='segmented'] > [data-list-item]:is([data-selected], [data-list-row]:active) {
[data-list='segmented'] > [data-list-item]:is([data-selected], [data-list-row]:active, [data-list-row]:has([data-list-open]:focus-visible)) {
border-radius: var(--md-sys-shape-corner-lg);
}
+1
View File
@@ -23,6 +23,7 @@
@import './components/actions.css';
@import './components/groups.css';
@import './components/list.css';
@import './components/collapse.css';
@import './components/field.css';
@import './components/menu.css';
@import './components/selection.css';
+124 -54
View File
@@ -23,9 +23,10 @@
* records. A ResizeObserver does the same for a new width. RTL is read when measuring:
* scroll offsets are negative there and shifts mirror, as Compose's `translationX` does.
*
* Under reduced motion the buttons and keys scroll instantly, and the content stays pinned to
* the mask's leading edge instead of sliding inside it: the frame still opens and closes with
* the scroll the user makes, but nothing moves on its own.
* Under reduced motion the buttons and keys scroll instantly and nothing is masked at all: M3
* says the parallax goes and items "should no longer expand as they come into view — all items
* are the same size", so every item stays at the strategy's large size and the keylines only
* decide where the row snaps.
*
* ---------------------------------------------------------------------------------------
* Keyline maths ported from androidx (https://github.com/androidx/androidx), commit
@@ -39,8 +40,9 @@
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
* (carouselItem, calculateMaxScrollOffset, CarouselDefaults)
*
* The full-screen arrangement follows material-components-android's
* FullScreenCarouselStrategy (one large item the size of the container).
* The full-screen layout uses none of it: M3 gives it one edge-to-edge item at a time, scrolled
* 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.
*
* Copyright 2023-2024 The Android Open Source Project
*
@@ -546,11 +548,6 @@ const heroKeylineList = (space, maxItemSize, itemSpacing, itemCount, isCentered)
: leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, arrangement)
}
const fullScreenKeylineList = (space, itemSpacing) =>
space === 0
? EMPTY
: leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, { priority: 0, smallSize: 0, smallCount: 0, mediumSize: 0, mediumCount: 0, largeSize: space, largeCount: 1 })
// Strategy.kt ------------------------------------------------------------------------------
const shiftedKeylineListForContentPadding = (from, space, itemSpacing, contentPadding, pivot, pivotIndex) => {
@@ -805,6 +802,7 @@ document.addEventListener('alpine:init', () => {
snaps: [],
maxScroll: 0,
rtl: false,
vertical: false,
frame: null,
target: null,
targetAt: 0,
@@ -860,32 +858,50 @@ document.addEventListener('alpine:init', () => {
refresh() {
const root = this.$root
const scroller = this.$refs.scroller
const space = scroller.clientWidth
const itemSpacing = parseFloat(getComputedStyle(scroller).columnGap) || 0
const padding = Number(root.dataset.padding) || 0
const probe = this.$refs.probe
const preferred = probe ? probe.getBoundingClientRect().width : null
const style = getComputedStyle(scroller)
state.rtl = getComputedStyle(scroller).direction === 'rtl'
state.rtl = style.direction === 'rtl'
state.vertical = root.dataset.materialCarousel === 'full-screen'
state.items = [...scroller.children]
.filter((element) => element.matches(ITEM))
.map((element) => ({
element,
surface: element.querySelector('[data-material-carousel-surface]'),
content: element.querySelector('[data-material-carousel-content]'),
label: element.querySelector('[data-material-carousel-label]'),
labelWidth: 0,
written: '',
}))
// M3's full-screen layout is one edge-to-edge item at a time, scrolled
// vertically: no keylines, no mask, no end padding — the browser's scroll snap
// does the whole of it, and this only works out where each item comes to rest.
if (state.vertical) {
const space = scroller.clientHeight
const spacing = parseFloat(style.rowGap) || 0
state.strategy = null
state.maxScroll = Math.max(0, (space + spacing) * state.items.length - spacing - space)
state.snaps = state.items.map((_, index) => clamp(index * (space + spacing), 0, state.maxScroll))
this.render()
return
}
const space = scroller.clientWidth
const itemSpacing = parseFloat(style.columnGap) || 0
const padding = Number(root.dataset.padding) || 0
const paddingEnd = Number(root.dataset.paddingEnd) || 0
const probe = this.$refs.probe
const preferred = probe ? probe.getBoundingClientRect().width : null
const count = state.items.length
const keylines = {
hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.centered !== undefined),
uncontained: () => uncontainedKeylineList(space, preferred ?? 0, itemSpacing),
'full-screen': () => fullScreenKeylineList(space, itemSpacing),
}[root.dataset.materialCarousel] ?? (() => multiBrowseKeylineList(space, preferred ?? 0, itemSpacing, count))
const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, padding)
const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, paddingEnd)
state.strategy = strategy
@@ -941,10 +957,10 @@ document.addEventListener('alpine:init', () => {
state.target = null
const left = state.snaps[target]
const offset = state.snaps[target]
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
if (offset !== undefined && Math.abs(this.scrollOffset() - offset) > 1) {
this.scrollTo(offset, 'instant')
}
}, SETTLE_MS)
},
@@ -954,6 +970,14 @@ document.addEventListener('alpine:init', () => {
cancelAnimationFrame(state.frame)
state.frame = null
// Nothing is masked in the vertical full-screen layout; only the buttons change.
if (state.vertical) {
this.buttons()
state.mutations?.takeRecords()
return
}
const strategy = state.strategy
if (!strategy?.valid) {
@@ -963,7 +987,13 @@ document.addEventListener('alpine:init', () => {
const scroll = this.scrollOffset()
const keylines = keylinesForScrollOffset(strategy, scroll, state.maxScroll)
const size = strategy.itemSize
const pinned = state.reducedMotion.matches
// M3: "When reduced motion settings are turned on, the parallax effect should be
// removed and carousel items should no longer expand as they come into view. All
// items are the same size" (docs/reference/m3/styles.md § Motion → Accessibility
// requirements). The keylines still decide where the row snaps; nothing is masked,
// moved or faded, so every item stays at strategy.itemSize.
const still = state.reducedMotion.matches
state.items.forEach((item, index) => {
const center = index * (size + strategy.itemSpacing) + size / 2 - scroll
@@ -978,11 +1008,10 @@ document.addEventListener('alpine:init', () => {
translation += (center - keyline.unadjustedOffset) / keyline.size
}
const inset = clamp((size - keyline.size) / 2, 0, size / 2)
const shift = state.rtl ? -translation : translation
const pin = pinned ? (state.rtl ? -inset : inset) : 0
const opacity = item.labelWidth > 0 ? clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1) : 1
const written = `${inset.toFixed(2)}|${shift.toFixed(2)}|${pin.toFixed(2)}|${opacity.toFixed(3)}`
const inset = still ? 0 : clamp((size - keyline.size) / 2, 0, size / 2)
const shift = still ? 0 : state.rtl ? -translation : translation
const opacity = still || item.labelWidth === 0 ? 1 : clamp((item.labelWidth - size + keyline.size) / item.labelWidth, 0, 1)
const written = `${inset.toFixed(2)}|${shift.toFixed(2)}|${opacity.toFixed(3)}`
if (written === item.written) {
return
@@ -993,11 +1022,19 @@ document.addEventListener('alpine:init', () => {
item.written = written
item.surface.style.setProperty('--material-carousel-inset', `${inset.toFixed(2)}px`)
item.surface.style.setProperty('--material-carousel-shift', `${shift.toFixed(2)}px`)
item.surface.style.setProperty('--material-carousel-pin', `${pin.toFixed(2)}px`)
item.surface.style.setProperty('--material-carousel-label-shift', `${(state.rtl ? -inset : inset).toFixed(2)}px`)
item.surface.style.setProperty('--material-carousel-label', opacity.toFixed(3))
})
this.buttons()
state.mutations?.takeRecords()
},
/** A control that would scroll past an end is off. */
buttons() {
const scroll = this.scrollOffset()
if (this.$refs.previous) {
this.$refs.previous.disabled = scroll <= 1
}
@@ -1005,13 +1042,21 @@ document.addEventListener('alpine:init', () => {
if (this.$refs.next) {
this.$refs.next.disabled = scroll >= state.maxScroll - 1
}
state.mutations?.takeRecords()
},
/** The scroll offset from the start edge, positive in both directions. */
/** The scroll offset from the start edge, positive in every direction. */
scrollOffset() {
return clamp(Math.abs(this.$refs.scroller.scrollLeft), 0, state.maxScroll)
const scroller = this.$refs.scroller
return clamp(state.vertical ? scroller.scrollTop : Math.abs(scroller.scrollLeft), 0, state.maxScroll)
},
/** Scrolls to an offset on whichever axis this carousel runs along. */
scrollTo(offset, behavior) {
this.$refs.scroller.scrollTo({
...(state.vertical ? { top: offset } : { left: state.rtl ? -offset : offset }),
behavior,
})
},
/** The item nearest the current scroll position. */
@@ -1047,46 +1092,68 @@ document.addEventListener('alpine:init', () => {
},
scrollToItem(index) {
if (!state.strategy?.valid || state.snaps[index] === undefined) {
if (state.snaps[index] === undefined || (!state.vertical && !state.strategy?.valid)) {
return
}
state.target = index
state.targetAt = performance.now()
const left = state.snaps[index]
this.$refs.scroller.scrollTo({
left: state.rtl ? -left : left,
behavior: state.reducedMotion.matches ? 'instant' : 'smooth',
})
this.scrollTo(state.snaps[index], state.reducedMotion.matches ? 'instant' : 'smooth')
},
/** The row's own keys, while the row itself has focus: a control inside an item keeps its keys. */
/**
* The items' keys, while an item itself has focus — M3: "Tab or Arrows moves to the
* previous or next carousel item; Space or Enter activates the focused carousel item".
* A control inside an item keeps its own keys, because focus is then on the control
* and not on the item.
*/
navigate(event) {
if (event.target !== this.$refs.scroller || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return
}
const forward = state.rtl ? 'ArrowLeft' : 'ArrowRight'
const backward = state.rtl ? 'ArrowRight' : 'ArrowLeft'
const index = state.items.findIndex((item) => item.element === event.target)
const action = {
[forward]: () => this.next(),
[backward]: () => this.previous(),
Home: () => this.scrollToItem(0),
End: () => this.scrollToItem(state.items.length - 1),
if (index < 0) {
return
}
if (event.key === 'Enter' || event.key === ' ') {
if (this.isMasked(index)) {
event.preventDefault()
this.scrollToItem(index)
}
return
}
const forward = state.vertical ? 'ArrowDown' : state.rtl ? 'ArrowLeft' : 'ArrowRight'
const backward = state.vertical ? 'ArrowUp' : state.rtl ? 'ArrowRight' : 'ArrowLeft'
const to = {
[forward]: index + 1,
[backward]: index - 1,
Home: 0,
End: state.items.length - 1,
}[event.key]
if (action) {
event.preventDefault()
action()
const target = to === undefined ? undefined : state.items[to]
if (!target) {
return
}
// The browser would jump the row to the newly focused item; the smooth scroll to
// its snap position is this script's.
event.preventDefault()
target.element.focus({ preventScroll: true })
this.scrollToItem(to)
},
/** Focus inside an item brings that item into focus, as Compose's bring-into-view does. */
reveal(event) {
const element = event.target === this.$refs.scroller ? null : event.target.closest(ITEM)
const element = event.target.closest?.(ITEM)
const index = state.items.findIndex((item) => item.element === element)
if (index >= 0 && this.isMasked(index)) {
@@ -1106,8 +1173,11 @@ document.addEventListener('alpine:init', () => {
}
},
/** Whether item `index` is not fully in focus, so a press or focus should bring it there. */
isMasked(index) {
return parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
return state.vertical
? Math.abs(state.snaps[index] - this.scrollOffset()) > 1
: parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
},
destroy() {
+26
View File
@@ -11,6 +11,11 @@
* Not a stretched link (`::after { inset: 0 }`): Safari makes no containing block of a <tr>, so in
* a table every overlay would cover the whole table; and not one <button> around the row, which
* could hold no other buttons. The listeners sit on `document` and are added once.
*
* A row that is also `data-list-actionable` is M3's **directly actionable card** (`<x-card
* actionable>`): the card is the one tab stop, so Enter and Space on the card reach the opener,
* and the card's other actions follow it in the tab order with their own keys. The opener itself
* carries `tabindex="-1"`, which the card's caller writes.
*/
/** Anything that answers a click itself — the row's own controls, and the opener. */
@@ -99,6 +104,27 @@ document.addEventListener('click', (event) => {
opener.click()
})
// A directly actionable card is a control, so it answers Enter and Space the way a
// button does — by reaching the opener, which owns the href or the wire:click.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return
}
if (event.defaultPrevented || !(event.target instanceof Element) || !event.target.matches('[data-list-actionable]')) {
return
}
const opener = event.target.querySelector('[data-list-open]')
if (!opener) {
return
}
event.preventDefault()
opener.click()
})
// The middle button is not a `click`, and on a row that goes somewhere it means
// what it means on a link.
document.addEventListener('auxclick', (event) => {
@@ -9,12 +9,20 @@
SheetBottomTokens (androidx Compose Material 3, Apache-2.0): surface-container-low, extra-large
top corners, elevation 1, a 32×4px drag handle in on-surface-variant; 640px wide at most, centred
on a wide screen; it rises on emphasized decelerate. `title` and `actions` as on a dialog.
`height` caps it (default: 90% of the screen); content scrolls inside. --}}
`height` is where it opens, and M3 caps a modal sheet's initial position at half the screen —
"if content exceeds that, it can be pulled to full screen and scrolled internally" — so the
default is `50dvh`, under a ceiling of the screen less M3's 72dp top margin. Content scrolls
inside.
The handle is drawn 32×4px and pressed 48×48: `touch-target` on the button and 22px above and
below it, which is M3's "drag handle has an accessible 48dp hit target" and SheetDefaults.kt's
`DragHandleVerticalPadding = 22.dp` (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets Specs). --}}
@props([
'title' => null,
'standard' => false,
'height' => '90dvh',
'height' => '50dvh',
])
@php
@@ -50,14 +58,14 @@
role="dialog"
@unless ($standard) aria-modal="true" @endunless
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
style="--sheet-max-height: {{ $height }}"
style="--sheet-max-height: min({{ $height }}, calc(100dvh - 72px))"
{{ $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',
$attributes->get('class'),
]) }}
>
<div class="flex shrink-0 cursor-grab justify-center py-4 active:cursor-grabbing" data-drag-handle>
<button type="button" class="h-1 w-8 rounded-corner-full bg-on-surface-variant/40 outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary" aria-label="{{ __('Close') }}" x-on:click="close()"></button>
<div 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>
</div>
<div x-ref="body" class="min-h-0 flex-1 overflow-y-auto px-6 pb-6">
+29 -6
View File
@@ -7,11 +7,24 @@
`separator` (a divider under the header), and the `menu` (top-end, beside the title),
`figure` (full-bleed media on top) and `actions` (end-aligned, under the content) slots.
A card that opens something is a row: give it `data-list-row` and one `data-list-open`
control inside (the title link or a button), and a press anywhere on it reaches that control
while its other buttons keep their own (resources/js/list-rows.js). It answers with a state
layer and its corner opening a step. Never a stretched link, and never a whole-card `<a>`
around buttons.
M3 draws two kinds of card a person can press, and they differ in the keyboard: on a
**non-actionable card with actionable elements** Tab moves through each control inside before
the next card; on a **directly actionable card** Tab moves to the card, then to the next card
(docs/reference/m3/components-actions-communication-containment.md § Cards Accessibility).
Both are a row: give the card `data-list-row` and one `data-list-open` control inside (the
title link or a button), and a press anywhere on it reaches that control while its other
buttons keep their own (resources/js/list-rows.js). That is the first kind the opener is
the tab stop, the accessible name and the owner of the `href` or the `wire:click`.
`actionable` makes it the second: the card takes `tabindex="0"` and `role="button"` (pass
`role="link"` where it goes somewhere), is named by its `title`, and Enter or Space on it
reaches the opener. Put `tabindex="-1"` on that opener so the card is the one stop, and leave
the card's other actions as they are — they follow it in the tab order.
A row answers with M3's state layer and one step of elevation (elevated 1 2 hovered,
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.
Do not pass a `bg-*` class to change its fill it races the card's own in Tailwind's emit
order; use `variant`, or colour a wrapper inside. --}}
@@ -21,14 +34,24 @@
'subtitle' => null,
'variant' => 'filled',
'separator' => false,
'actionable' => false,
])
@php
$variant = in_array($variant, ['filled', 'elevated', 'outlined'], true) ? $variant : 'filled';
$role = $actionable ? ($attributes->get('role') ?: 'button') : null;
$attributes = $actionable ? $attributes->except('role') : $attributes;
@endphp
<div
data-card
data-card="{{ $variant }}"
@if ($actionable)
data-list-row
data-list-actionable
tabindex="0"
role="{{ $role }}"
@if (filled($title)) aria-label="{{ $title }}" @endif
@endif
{{ $attributes->class([
'relative flex flex-col overflow-hidden rounded-corner-md text-on-surface',
'bg-surface-container-highest' => $variant === 'filled',
@@ -7,34 +7,56 @@
`label` lays a short line of text over the bottom of the art, on a scrim, pinned to the
item's visible edge and fading out as the item narrows — Compose's carousel sample, which
fades its label chip in once the mask is wide enough to hold it.
fades its label chip in once the mask is wide enough to hold it. The scrim is what M3 asks
for under text on an image; the text itself is `inverse-on-surface`, a role a scheme and a
contrast profile follow, rather than a literal white.
A `group` with `aria-roledescription="slide"`, named "n of m" by the carousel around it
(WAI-ARIA's carousel pattern). The item is laid out at the carousel's large size and masked:
A focusable `group` with `aria-roledescription="slide"`, named "n of m" by the carousel
around it. M3 puts the tab stop on the item, not on the row: Tab reaches the first item, the
arrow keys move between them and Space or Enter opens the focused one
(docs/reference/m3/components-actions-communication-containment.md § Carousel
Accessibility). The item is laid out at the carousel's large size and masked:
the surface inside is clipped by `--material-carousel-inset` from both sides with M3's
extra-large corner (28px, CarouselDefaults' item shape) and moved by
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
item shows unmasked, at its `item-width`. Only inside `<x-carousel>`. --}}
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>`. --}}
@props([
'label' => null,
])
{{-- The layout of the `<x-carousel>` around it: the full-screen one is a vertical row of
edge-to-edge items, which M3 gives no corner and no mask. --}}
@aware([
'layout' => 'multi-browse',
])
@php
$vertical = $layout === 'full-screen';
@endphp
<div {{ $attributes
->class([
'relative h-full w-(--material-carousel-slot) max-w-full shrink-0 snap-start snap-always',
'focus-ring relative h-full shrink-0 snap-start snap-always focus-visible:-outline-offset-3',
'w-full' => $vertical,
'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical,
])
->merge([
'role' => 'group',
'aria-roledescription' => __('slide'),
'aria-label' => '[material-carousel-position]',
'data-material-carousel-item' => true,
'tabindex' => '0',
]) }}>
<div
data-material-carousel-surface
class="relative size-full overflow-hidden rounded-corner-xl bg-surface-container-highest text-on-surface translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]"
@class([
'relative size-full overflow-hidden bg-surface-container-highest text-on-surface',
'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical,
])
>
<div data-material-carousel-content class="size-full translate-x-(--material-carousel-pin) [&>img]:size-full [&>img]:object-cover">
<div data-material-carousel-content class="size-full [&>img]:size-full [&>img]:object-cover">
{{ $slot }}
</div>
@@ -43,7 +65,7 @@
data-material-carousel-label
class="pointer-events-none absolute inset-x-0 bottom-0 flex bg-linear-to-t from-scrim/60 to-transparent px-4 pt-10 pb-4 opacity-(--material-carousel-label)"
>
<span class="truncate type-title-md text-white translate-x-(--material-carousel-label-shift)">{{ $label }}</span>
<span class="truncate type-title-md text-inverse-on-surface translate-x-(--material-carousel-label-shift)">{{ $label }}</span>
</div>
@endif
</div>
+43 -22
View File
@@ -19,12 +19,19 @@
does.
- `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves
HorizontalUncontainedCarousel. No snapping, as Compose's uncontained fling.
- `full-screen`: one item the width of the carousel at a time
(FullScreenCarouselStrategy).
- `full-screen`: one edge-to-edge item at a time, scrolled **vertically** — M3: "this layout
works best with content that is taller than it is wide, and scrolls vertically. It only
works in portrait orientation in compact and medium breakpoints. Don't use this layout in
landscape orientation." Items have no corner and no mask, 16px apart, no end padding, 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
portrait image wants.
`item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's
sample). `padding` is Compose's `contentPadding` in pixels (0): the first and last items
rest that far in from the edges while items in between scroll to them. Items are 8px apart
with M3's extra-large corner.
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
every layout 16dp of it — `uncontained` at the leading edge only, `full-screen` none — so
that is the default, with the 8dp above and below the row that goes with it. Items are 8px
apart with M3's extra-large corner.
The row is a native scroll container with CSS scroll snap, one item per swipe, as Compose's
single-advance fling; touch, trackpad and Shift with the wheel scroll it. resources/js/
@@ -34,14 +41,18 @@
frame, content at full size, so items change size between the keylines. Without script
the row still scrolls and snaps, unmasked.
WAI-ARIA's carousel pattern: the row is a focusable `region` with
`aria-roledescription="carousel"`, named by `label` ("Carousel" by default); each item is a
`group` with `aria-roledescription="slide"` named "n of m". With the row focused the arrow
keys move one item, Home and End to the ends; focus moving into an item, or a press on one
The row is a `region` with `aria-roledescription="carousel"`, named by `label` ("Carousel"
by default); each item is a focusable `group` with `aria-roledescription="slide"` named
"n of m". M3: "Use Tab to place initial focus on the first carousel item" and "avoid
focusing on the carousel container", so the items are the tab stops and the row is not one.
From a focused item the arrow keys move one item, Home and End go to the ends, and Space or
Enter opens an item that is not fully open; focus moving into an item, or a press on one
that is not fully open, brings it into focus. `controls` adds previous and next icon
buttons under the row — by default only where the pointer is fine (a mouse or trackpad);
`true` always, `false` never. Under reduced motion they scroll instantly and content no
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
`true` always, `false` never. Under reduced motion they scroll instantly and nothing is
masked: every item stays at its large size, which is M3's rule for a carousel under reduced
motion (docs/reference/m3/styles.md § Motion → Accessibility requirements). RTL mirrors the
keylines, keys and buttons.
Re-measures itself when resized, when a Livewire morph resets its styles and when items
come and go. The row's id, which the buttons control, is new with every render; the row
@@ -52,7 +63,7 @@
'layout' => 'multi-browse',
'itemWidth' => null,
'height' => null,
'padding' => 0,
'padding' => 16,
'centered' => false,
'label' => null,
'controls' => null,
@@ -60,6 +71,7 @@
@php
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse';
$vertical = $layout === 'full-screen';
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
$label ??= __('Carousel');
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
@@ -76,8 +88,9 @@
'hero' => $cssLength($itemWidth),
'full-screen' => null,
};
// The full-screen item is `h-full w-full`: it takes the row, not a slot.
$slotWidth = match (true) {
$layout === 'full-screen' => '100%',
$vertical => null,
$preferredWidth === null => 'calc(100% - 64px)',
default => $preferredWidth,
};
@@ -97,15 +110,22 @@
$slides,
);
// The specs table's leading and trailing padding: 16dp for multi-browse and hero, leading
// only for uncontained, none for full-screen, which is edge to edge.
$padding = max(0, (float) $padding);
$paddingStart = $vertical ? 0.0 : $padding;
$paddingEnd = $vertical || $layout === 'uncontained' ? 0.0 : $padding;
$attributes = $attributes
->class('relative')
->merge(array_filter([
'data-material-carousel' => $layout,
'data-centered' => $layout === 'hero' && $centered ? true : null,
'data-padding' => (string) max(0, (float) $padding),
'data-padding' => (string) $paddingStart,
'data-padding-end' => (string) $paddingEnd,
'style' => implode('; ', array_filter([
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
"--material-carousel-slot: {$slotWidth}",
$slotWidth ? "--material-carousel-slot: {$slotWidth}" : null,
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
])),
], fn ($value): bool => $value !== null));
@@ -123,11 +143,12 @@
role="region"
aria-roledescription="{{ __('carousel') }}"
aria-label="{{ $label }}"
tabindex="0"
@class([
'focus-ring flex h-(--material-carousel-height) gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain',
'flex',
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'snap-x snap-mandatory' => $layout !== 'uncontained',
'mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain' => $vertical,
'h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2' => ! $vertical,
'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained',
])
>
{!! $slides !!}
@@ -140,22 +161,22 @@
'flex' => $controls === true,
])>
<x-livewire-material::button
icon="chevron_left"
:icon="$vertical ? 'keyboard_arrow_up' : 'chevron_left'"
variant="tonal"
:tooltip="__('Previous')"
aria-controls="{{ $scrollerId }}"
x-ref="previous"
x-on:click="previous()"
class="rtl:-scale-x-100"
:class="$vertical ? '' : 'rtl:-scale-x-100'"
/>
<x-livewire-material::button
icon="chevron_right"
:icon="$vertical ? 'keyboard_arrow_down' : 'chevron_right'"
variant="tonal"
:tooltip="__('Next')"
aria-controls="{{ $scrollerId }}"
x-ref="next"
x-on:click="next()"
class="rtl:-scale-x-100"
:class="$vertical ? '' : 'rtl:-scale-x-100'"
/>
</div>
@endif
@@ -5,7 +5,8 @@
and is announced as a disclosure. Drawn in M3's terms: a title-medium `title` (or a
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
and where the browser supports animating `details` content (`interpolate-size`) a height
that eases open. `open` starts it open. `variant`: `plain` (the default, on the surface around
that eases open on the same spring (`data-collapse`, resources/css/components/collapse.css).
`open` starts it open. `variant`: `plain` (the default, on the surface around
it) or `filled` (a surface-container tile with a large corner).
Its open state can be bound, both ways:
@@ -38,6 +39,7 @@
<details
wire:ignore.self
data-collapse
@if ($bound)
x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }"
@if ($model === null) x-modelable="collapseOpen" @endif
+18 -4
View File
@@ -25,6 +25,13 @@
a size container, so its contents lay out by the room the sheet or pane actually has (`@md:`),
never by the viewport.
M3 **requires** a close affordance on a side sheet without one nobody can predict the
sheet's open/close flow or tell whether it is transient or permanent
(docs/reference/m3/components-actions-communication-containment.md § Side sheets →
Accessibility) — so `with-close-button` is on by default, and `:with-close-button="false"` is
ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane, which has
neither.
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}}
@@ -34,7 +41,7 @@
'separator' => false,
'side' => 'end',
'right' => true,
'withCloseButton' => false,
'withCloseButton' => true,
'closeOnEscape' => true,
'withoutBackdropClose' => false,
'width' => '25rem',
@@ -47,6 +54,10 @@
$model = $attributes->wire('model')->value() ?: null;
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
$start = $side === 'start';
// M3 requires a close affordance; the prop can only ever add one, never take away the last
// way out of the sheet.
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || ($pane && ! $paneCloseOnEscape);
@endphp
<div
@@ -105,7 +116,7 @@
$attributes->get('class'),
]) }}
>
@if (filled($title) || $withCloseButton)
@if (filled($title) || $closeButton)
<div class="mb-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
@@ -118,7 +129,7 @@
@endif
</div>
@if ($withCloseButton)
@if ($closeButton)
<span class="-me-3 -mt-2 inline-flex shrink-0">
<x-livewire-material::button icon="close" :tooltip-left="__('Close')" x-on:click="close()" />
</span>
@@ -136,7 +147,10 @@
</div>
@isset($actions)
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6">
{{-- The side-sheet specs table: bottom actions 72dp tall, 16dp above them, 24dp below
(the sheet's own padding), aligned **left** — a dialog's are trailing-aligned, and
M3 specifies the two differently. --}}
<div class="flex min-h-18 shrink-0 flex-wrap items-center justify-start gap-2 pt-4">
{{ $actions }}
</div>
@endisset
+41 -9
View File
@@ -8,12 +8,23 @@
`avatar` (an image URL, or initials in primary-container) or `image` (a 56px thumbnail), or a
`leading` slot (a checkbox, a switch). The trailing element is `trailing` text (label-small),
`icon-right`, or an `end` slot for controls (a menu, a switch). One-, two- and three-line
heights (56, 72, 88px) follow from what is given (ListTokens, androidx Compose Material 3, Apache-2.0).
heights (56, 72, 88px) follow from what is given, 16px between the item and what leads or
trails it, and a three-line item top-aligns rather than centring M3 aligns an item middle
"by default, top-aligned if the item is 88dp+ or has 3+ lines of text". Its icons are 24px,
or 20px in a `segmented` list, which is M3 Expressive's (ListTokens, androidx Compose
Material 3, Apache-2.0).
`link` makes the whole item the link. Otherwise, to make it open something while its trailing
controls keep their own presses, give it `data-list-row` and put `data-list-open` on the one
control that opens — see resources/js/list-rows.js. `selected` (true) is M3's selected item,
in secondary-container; `disabled` greys it. --}}
in secondary-container; `disabled` greys it to 38%, drops its link and announces it
`aria-disabled` M3's states model treats disabled as not interactive, so a disabled item
answers neither the pointer nor the keyboard.
It takes its role from the `<x-list>` around it: a `listitem` in a plain list, where
`selected` is `aria-current`, and an `option` announcing `aria-selected` in a `selectable`
one. M3 asks for two cues on a selected item, never colour alone, so a selected option also
draws a trailing check unless `icon-right` says otherwise. --}}
@props([
'title' => null,
@@ -31,22 +42,41 @@
'disabled' => false,
])
{{-- The `<x-list>` around it: whether it is one a person chooses from and how many it takes, and
whether it is the expressive (segmented) one, whose icons are a size smaller. --}}
@aware([
'selectable' => false,
'selection' => null,
'segmented' => false,
])
@php
$isLink = filled($link);
$isLink = filled($link) && ! $disabled;
$lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0);
$initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
$option = $selectable || $selection !== null;
$check = $option && $selected && blank($iconRight);
// ListTokens.kt:153,156,332,335 — ItemLeading/TrailingIconExpressiveSize = 20dp against the
// baseline 24dp. At 20px the symbol is drawn from the 20 optical cut, not the 24 scaled down.
$iconSize = $segmented ? 'size-5' : 'size-6';
$optical = $segmented ? '20' : '24';
@endphp
<div
role="listitem"
role="{{ $option ? 'option' : 'listitem' }}"
@if ($option) aria-selected="{{ $selected ? 'true' : 'false' }}" @elseif ($selected) aria-current="true" @endif
data-list-item
@if ($isLink) data-list-row @endif
@if ($selected) data-selected @endif
@if ($disabled) aria-disabled="true" @endif
{{ $attributes->class([
'relative flex items-center gap-3 px-4 text-on-surface',
'relative flex gap-4 px-4 text-on-surface',
'items-center' => $lines !== 2,
'items-start' => $lines === 2,
'min-h-14 py-2' => $lines === 0,
'min-h-18 py-2.5' => $lines === 1,
'min-h-22 py-2.5' => $lines === 2,
'min-h-18 py-2' => $lines === 1,
'min-h-22 py-3' => $lines === 2,
'pointer-events-none text-on-surface/38' => $disabled,
]) }}
>
@@ -61,7 +91,7 @@
@elseif ($image)
<img src="{{ $image }}" alt="" class="size-14 shrink-0 rounded-corner-sm object-cover" />
@elseif ($icon)
<x-livewire-material::icon :name="$icon" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
<x-livewire-material::icon :name="$icon" :optical="$optical" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'text-on-surface-variant' => ! $selected && ! $disabled])" />
@endif
<div class="min-w-0 flex-1">
@@ -94,6 +124,8 @@
@endif
@if ($iconRight)
<x-livewire-material::icon :name="$iconRight" :class="\Illuminate\Support\Arr::toCssClasses(['size-6', 'text-on-surface-variant' => ! $selected && ! $disabled])" />
<x-livewire-material::icon :name="$iconRight" :optical="$optical" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'text-on-surface-variant' => ! $selected && ! $disabled])" />
@elseif ($check)
<x-livewire-material::icon name="check" :optical="$optical" :class="$iconSize" />
@endif
</div>
+30 -8
View File
@@ -1,28 +1,50 @@
{{-- An M3 list: `<x-list-item>`s, one under another.
Plain by default the items sit on the surface around them, with `dividers` between them if
asked. `segmented` is M3 Expressive's list: each item its own surface-container tile, 2px
apart, with small corners that open to large at the ends and while hovered, pressed or
selected (ListTokens, androidx Compose Material 3, Apache-2.0).
asked, inset 16px from both ends as ListTokens' `DividerLeadingSpace`/`DividerTrailingSpace`
say (resources/css/components/list.css draws them). `segmented` is M3 Expressive's list: each
item its own surface tile, 2px apart, with small corners that open to large at the ends and
while hovered, pressed, focused or selected (ListTokens, androidx Compose Material 3,
Apache-2.0).
It is a `role="list"`; for keyboard walking between rows, `j`/`k` or arrows, the application
can use `data-list` (resources/js/list-rows.js marks rows; a list keyboard arrives with the
list-detail pane). `label` names the list. --}}
A plain list is a `role="list"` of `listitem`s. `selectable` (or `selection="single"` /
`selection="multi"`) makes it the list a person chooses from: M3 maps single- and
multi-select lists on the web to a **list box** of **options** with a selected state
(docs/reference/m3/components-actions-communication-containment.md § Lists Accessibility),
so the container becomes `role="listbox"` (`aria-multiselectable` when multi) and each item
an `option` that announces `aria-selected`. A selected option also draws a trailing check, so
selection is never colour alone; give the items a leading checkbox or radio instead and pass
`icon-right` to keep the check off.
For keyboard walking between rows, `j`/`k` or arrows, the application can use `data-list`
(resources/js/list-rows.js marks rows; a list keyboard arrives with the list-detail pane).
`label` names the list. --}}
@props([
'segmented' => false,
'dividers' => false,
'label' => null,
'selectable' => false,
'selection' => null,
])
@php
$selection = match (true) {
in_array($selection, ['single', 'multi'], true) => $selection,
$selectable || $selection !== null => 'single',
default => null,
};
@endphp
<div
role="list"
role="{{ $selection === null ? 'list' : 'listbox' }}"
@if ($selection === 'multi') aria-multiselectable="true" @endif
data-list="{{ $segmented ? 'segmented' : 'plain' }}"
@if ($dividers && ! $segmented) data-dividers @endif
@if ($label) aria-label="{{ $label }}" @endif
{{ $attributes->class([
'flex flex-col',
'gap-0.5' => $segmented,
'divide-y divide-outline-variant' => $dividers && ! $segmented,
]) }}
>
{{ $slot }}
+59 -28
View File
@@ -20,9 +20,21 @@
on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon
above a centred title, as M3 draws a dialog with a hero icon. `fullscreen` makes a dialog that
holds a form take the whole screen on a compact window (below `medium`, 600px M3 uses
full-screen dialogs "only in compact breakpoints"), with a close button and the title in a top bar
clear of the notch. `persistent` ignores Escape and the scrim, for a dialog that must be
answered. It opens on the fast spatial spring and closes at once, as M3's do. --}}
full-screen dialogs "only in compact breakpoints"), with a 56px close-and-title bar clear of
the notch. `persistent` ignores Escape and the scrim, for a dialog that must be
answered. It opens on the fast spatial spring and closes at once, as M3's do.
"Dialog content generally shouldn't scroll; if it must, the title stays pinned at the top and
the buttons at the bottom" (docs/reference/m3/components-actions-communication-containment.md
§ Dialogs → Behaviour): the header and the action row are their own rows of the flex column
and only the body between them scrolls, each with the 24dp padding the box used to carry.
`separator` draws M3's divider under the pinned header and above the pinned actions.
`alert` is M3's "on web, basic dialogs should have the alert dialog role": it sets
`role="alertdialog"` and points `aria-describedby` at the body, for the dialog that
interrupts to say something important. It is opt-in, because ARIA-APG keeps `alertdialog`
for exactly that and a form dialog would over-announce with it. A `subtitle` is always the
dialog's description. --}}
@props([
'title' => null,
@@ -31,12 +43,18 @@
'separator' => false,
'persistent' => false,
'fullscreen' => false,
'alert' => false,
'boxClass' => null,
])
@php
$model = $attributes->wire('model')->value() ?: null;
$id = $attributes->get('id') ?? 'material-dialog-'.substr(md5($model.'|'.$title), 0, 10);
$header = filled($title) || filled($subtitle) || $icon;
$describedBy = implode(' ', array_filter([
filled($subtitle) ? $id.'-subtitle' : null,
$alert && $slot->isNotEmpty() ? $id.'-body' : null,
]));
@endphp
<dialog
@@ -53,7 +71,9 @@
x-on:cancel.prevent="{{ $persistent ? '' : 'close()' }}"
x-on:close="if (open) close()"
@if (! $persistent) x-on:click.self="close()" @endif
@if ($alert) role="alertdialog" @endif
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->merge(['id' => $id]) }}
@class([
'm-auto max-h-[calc(100dvh-3rem)] w-[calc(100vw-3rem)] max-w-[35rem] min-w-70 overflow-visible bg-transparent p-0 text-on-surface',
@@ -64,12 +84,12 @@
])
>
<div @class([
'flex max-h-[inherit] flex-col overflow-y-auto rounded-corner-xl bg-surface-container-high p-6 shadow-elevation-3',
'max-medium:h-full max-medium:rounded-corner-none max-medium:p-0 max-medium:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
'flex max-h-[inherit] flex-col overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3',
'max-medium:h-full max-medium:rounded-corner-none max-medium:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen,
$boxClass,
])>
@if ($fullscreen)
<div class="flex h-16 shrink-0 items-center gap-1 px-1 medium:hidden">
<div class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden">
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title))
@@ -78,35 +98,46 @@
</div>
@endif
<div @class(['min-h-0 flex-1', 'max-medium:overflow-y-auto max-medium:px-6 max-medium:pb-6' => $fullscreen])>
@if (filled($title) || filled($subtitle) || $icon)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['mb-4', 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
@if ($icon)
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif
@if ($header)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['shrink-0 px-6 pt-6', 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
@if ($icon)
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-medium:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-medium:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif
@if (filled($subtitle))
<p @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif
@if (filled($subtitle))
<p id="{{ $id }}-subtitle" @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif
@if ($separator)
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
@if ($separator)
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
<div class="type-body-md text-on-surface-variant">{{ $slot }}</div>
</div>
@if ($slot->isNotEmpty())
<div id="{{ $id }}-body" @class([
'min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant',
'pt-4' => $header,
'pt-6' => ! $header,
'pb-6' => ! isset($actions),
])>
{{ $slot }}
</div>
@endif
@isset($actions)
@if ($separator)
<x-livewire-material::divider class="shrink-0" />
@endif
<div @class([
'flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6',
'max-medium:border-t max-medium:border-outline-variant max-medium:px-6 max-medium:py-4' => $fullscreen,
'flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6',
'max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2' => $fullscreen,
])>
{{ $actions }}
</div>
+4 -4
View File
@@ -203,7 +203,7 @@ img {
width: 100%;
}
/* Header: the app name in title-lg, or the configured logo */
/* Header: the app name in title-lg (22/28 at weight 400), or the configured logo */
.header {
padding: 32px 0 24px;
@@ -213,7 +213,7 @@ img {
.header a {
color: {{ $role['on-surface'] }};
font-size: 22px;
font-weight: 500;
font-weight: 400;
letter-spacing: 0;
line-height: 28px;
text-decoration: none;
@@ -226,7 +226,7 @@ img {
margin: 0 auto;
}
/* Body: the card, separated from the page by tone alone */
/* Body: the card, separated from the page by tone alone, on M3's extra-large corner */
.body {
-premailer-cellpadding: 0;
@@ -245,7 +245,7 @@ img {
-premailer-width: 570px;
background-color: {{ $card }};
border: 0;
border-radius: 24px;
border-radius: 28px;
margin: 0 auto;
padding: 0;
width: 570px;
@@ -55,8 +55,8 @@
@endforeach
</x-carousel>
BLADE,
'Full-screen' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="240" :controls="true">
'Full-screen: one item at a time, scrolled down' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="320" :controls="true">
@foreach ([
['Morning', 'very-sunny', 'bg-linear-to-br from-primary-container to-tertiary-container text-on-primary-container'],
['Noon', 'clam-shell', 'bg-linear-to-br from-secondary-container to-primary-container text-on-secondary-container'],
@@ -78,7 +78,7 @@
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-carousel&gt;</code> and <code>&lt;x-carousel-item&gt;</code>: items that change size between M3's keylines as they scroll.
Swipe, scroll with Shift and the wheel, or focus a row and use the arrow keys.
Swipe, scroll with Shift and the wheel, or tab to an item and use the arrow keys.
</p>
@foreach ($examples as $title => $code)
@@ -26,6 +26,15 @@
</x-slot:actions>
</x-card>
BLADE,
'A directly actionable card: the card is the tab stop' => <<<'BLADE'
<x-card variant="elevated" actionable role="link" title="contract.pdf" class="w-full max-w-sm">
<a href="#containment" data-list-open tabindex="-1" class="type-title-md">contract.pdf</a>
<p class="mt-1 text-on-surface-variant">1.2 MB · downloaded twice</p>
<x-slot:actions>
<x-button label="Copy link" icon="content_copy" x-on:click="materialToast('Link copied', { type: 'success' })" />
</x-slot:actions>
</x-card>
BLADE,
'Lists' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<x-list dividers label="Files">
@@ -40,9 +49,17 @@
<x-list-item title="Chiara Rossi" avatar="CR">
<x-slot:end><x-button icon="more_vert" tooltip="More" /></x-slot:end>
</x-list-item>
<x-list-item title="Dan Fischer" description="Invitation withdrawn" avatar="DF" link="#containment" disabled />
</x-list>
</div>
BLADE,
'A list to choose from' => <<<'BLADE'
<x-list selectable label="Expiry" class="w-full max-w-sm">
<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>
BLADE,
'Dividers and collapse' => <<<'BLADE'
<div class="w-full space-y-4">
<x-collapse title="How long do links last?" icon="schedule" open>
@@ -70,7 +87,7 @@
'Dialogs' => <<<'BLADE'
<div x-data="{ open: false }">
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
<x-modal title="Delete this share?" subtitle="Recipients lose access at once. This cannot be undone.">
<x-modal title="Delete this share?" subtitle="Recipients lose access at once. This cannot be undone." alert>
<x-slot:actions>
<x-button label="Cancel" x-on:click="close()" />
<x-button label="Delete" danger x-on:click="close()" />
+14 -13
View File
@@ -60,7 +60,7 @@ function onCarousel(int $index, string $body, string $scope = '#carousel'): stri
JS;
}
const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
const FIRST_ITEM = '#carousel [data-material-carousel-item] >> nth=0';
function carouselShowcase(array $options = [])
{
@@ -110,19 +110,21 @@ it('moves one item with the next and previous buttons', function () {
it('moves one item with the arrow keys, and to the ends with Home and End', function () {
$page = carouselShowcase();
$page->keys(FIRST_SCROLLER, 'ArrowRight')
->assertScript(onCarousel(0, 'return at(1)'));
// M3 puts the tab stop on the item, so the keys are the focused item's and each one moves
// focus to the item it scrolls to.
$page->keys(FIRST_ITEM, 'ArrowRight')
->assertScript(onCarousel(0, 'return at(1) && document.activeElement === items[1]'));
$page->keys(FIRST_SCROLLER, 'ArrowRight')
$page->keys(':focus', 'ArrowRight')
->assertScript(onCarousel(0, 'return at(2)'));
$page->keys(FIRST_SCROLLER, 'ArrowLeft')
$page->keys(':focus', 'ArrowLeft')
->assertScript(onCarousel(0, 'return at(1)'));
$page->keys(FIRST_SCROLLER, 'End')
$page->keys(':focus', 'End')
->assertScript(onCarousel(0, 'return Math.abs(scroller.scrollLeft - (scroller.scrollWidth - scroller.clientWidth)) < 1.5 && inset(items.length - 1) < 0.5 && root.querySelector(\'[aria-label="Next"]\').disabled'));
$page->keys(FIRST_SCROLLER, 'Home')
$page->keys(':focus', 'Home')
->assertScript(onCarousel(0, 'return at(0)'));
});
@@ -146,15 +148,14 @@ it('brings a partly hidden item into focus when it is pressed', function () {
$page->assertScript(onCarousel(0, 'return inset(4) < 0.5 && scroller.scrollLeft > 0'));
});
it('scrolls instantly and keeps content pinned to its mask under reduced motion', function () {
it('scrolls instantly and leaves every item unmasked under reduced motion', function () {
carouselShowcase(['reducedMotion' => 'reduce'])
->assertScript(onCarousel(0, <<<'JS'
root.querySelector('[aria-label="Next"]').click()
const arrived = at(1)
await pause(50)
const last = items.length - 1
return arrived && inset(last) > 0.5
&& surface(last).style.getPropertyValue('--material-carousel-pin') === surface(last).style.getPropertyValue('--material-carousel-inset')
return arrived && items.every((_, i) => inset(i) === 0)
&& items.every((item) => Math.abs(item.getBoundingClientRect().width - size) < 1.5)
JS));
});
@@ -184,7 +185,7 @@ it('mirrors in a right-to-left page', function () {
->assertNoJavaScriptErrors()
->assertScript(onCarousel(0, 'return size > 0 && at(0) && inset(0) < 0.5 && inset(items.length - 1) > 0.5', 'body'));
$page->keys('[role="region"]', 'ArrowLeft')
$page->keys('[data-material-carousel-item] >> nth=0', 'ArrowLeft')
->assertScript(onCarousel(0, <<<'JS'
return scroller.scrollLeft < -1 && at(1) && inset(0) > 0.5 && inset(1) < 0.5
&& parseFloat(surface(0).style.getPropertyValue('--material-carousel-shift')) < 0
@@ -193,7 +194,7 @@ it('mirrors in a right-to-left page', function () {
$page->click('button[aria-label="Next"]')
->assertScript(onCarousel(0, 'return at(2)', 'body'));
$page->keys('[role="region"]', 'ArrowRight')
$page->keys('[data-material-carousel-item] >> nth=2', 'ArrowRight')
->assertScript(onCarousel(0, 'return at(1)', 'body'));
});
+19 -3
View File
@@ -214,12 +214,28 @@ it('dismisses a bottom sheet dragged down past a quarter of its height', functio
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment();
$page->script("window.__opened = 0; document.querySelector('#containment [data-card][data-list-row] [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$row = '#containment [data-card][data-list-row]:not([data-list-actionable])';
$page->click('#containment [data-card][data-list-row] p')
$page->script("window.__opened = 0; document.querySelector('{$row} [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->click("{$row} p")
->assertScript('window.__opened === 1');
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
$page->click("{$row} button:has-text(\"Copy link\")")
->assertScript('window.__opened === 1');
});
it('makes a directly actionable card the one tab stop, answering Enter', function () {
$card = '#containment [data-card][data-list-actionable]';
$page = containment();
$page->script("window.__opened = 0; document.querySelector('{$card} [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->script("document.querySelector('{$card}').focus()")
->assertScript("document.activeElement.matches('{$card}')");
$page->keys($card, 'Enter')
->assertScript('window.__opened === 1');
});
+25 -3
View File
@@ -1,7 +1,10 @@
<?php
it('draws M3\'s three cards', function (string $variant, string $classes) {
expect((string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>"))->toContain($classes)->toContain('rounded-corner-md')->toContain('data-card');
it('draws M3\'s three cards, each naming its variant for its per-state elevation', function (string $variant, string $classes) {
expect((string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>"))
->toContain($classes)
->toContain('rounded-corner-md')
->toContain("data-card=\"{$variant}\"");
})->with([
'filled' => ['filled', 'bg-surface-container-highest'],
'elevated' => ['elevated', 'bg-surface-container-low shadow-elevation-1'],
@@ -9,7 +12,9 @@ it('draws M3\'s three cards', function (string $variant, string $classes) {
]);
it('is filled unless it knows the variant', function () {
expect((string) $this->blade('<x-card variant="glass">Body</x-card>'))->toContain('bg-surface-container-highest');
expect((string) $this->blade('<x-card variant="glass">Body</x-card>'))
->toContain('bg-surface-container-highest')
->toContain('data-card="filled"');
});
it('lays out a header, menu, media, body and actions', function () {
@@ -38,3 +43,20 @@ it('passes row attributes through', function () {
->toContain('data-list-row')
->toContain('data-list-open');
});
it('makes a directly actionable card the one tab stop, named by its title', function () {
$html = (string) $this->blade('<x-card actionable title="holiday-photos.zip"><a href="/s/1" data-list-open tabindex="-1">Open</a></x-card>');
expect($html)
->toContain('data-list-row')
->toContain('data-list-actionable')
->toContain('tabindex="0"')
->toContain('role="button"')
->toContain('aria-label="holiday-photos.zip"')
->and((string) $this->blade('<x-card actionable role="link" title="Share">Body</x-card>'))
->toContain('role="link"')
->and(substr_count((string) $this->blade('<x-card actionable role="link" title="Share">Body</x-card>'), 'role="link"'))->toBe(1)
->and((string) $this->blade('<x-card title="Share">Body</x-card>'))
->not->toContain('data-list-actionable')
->not->toContain('tabindex');
});
+25 -10
View File
@@ -1,6 +1,6 @@
<?php
it('is a focusable carousel region of slides named n of m', function () {
it('is a carousel region of focusable slides named n of m', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-carousel label="Recent uploads">
<x-carousel-item><img src="/a.jpg" alt="A lake" /></x-carousel-item>
@@ -17,14 +17,16 @@ it('is a focusable carousel region of slides named n of m', function () {
->toContain('role="region"')
->toContain('aria-roledescription="carousel"')
->toContain('aria-label="Recent uploads"')
->toContain('tabindex="0"')
->toContain('aria-roledescription="slide"')
->toContain('aria-label="1 of 3"')
->toContain('aria-label="2 of 3"')
->toContain('aria-label="3 of 3"')
->toContain('<img src="/a.jpg" alt="A lake" />')
->not->toContain('[material-carousel-position]')
->and(substr_count($html, 'role="group"'))->toBe(3);
->and(substr_count($html, 'role="group"'))->toBe(3)
// M3 puts the tab stop on each item and tells you not to focus the container.
->and(substr_count($html, 'tabindex="0"'))->toBe(3)
->and($html)->toMatch('/<div\s+x-ref="scroller"(?:(?!tabindex)[^>])*>/');
});
it('counts the slides of a carousel inside a slide on their own', function () {
@@ -56,7 +58,9 @@ it('browses many by default: 186px items that snap one at a time, 205px high', f
->toContain('aria-label="Carousel"')
->toContain('--material-carousel-item-width: 186px; --material-carousel-slot: 186px; --material-carousel-height: 205px')
->toContain('x-ref="probe"')
->toContain('data-padding="0"')
->toContain('data-padding="16"')
->toContain('data-padding-end="16"')
->toContain('h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2')
->toContain('snap-x snap-mandatory')
->toContain('snap-start snap-always')
->toContain('rounded-corner-xl')
@@ -65,9 +69,12 @@ it('browses many by default: 186px items that snap one at a time, 205px high', f
});
it('takes the item width in pixels or as a CSS length, and a height and padding', function () {
expect((string) $this->blade('<x-carousel item-width="220" height="18rem" padding="16"><x-carousel-item>A</x-carousel-item></x-carousel>'))
expect((string) $this->blade('<x-carousel item-width="220" height="18rem" padding="24"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('--material-carousel-item-width: 220px; --material-carousel-slot: 220px; --material-carousel-height: 18rem')
->toContain('data-padding="24"')
->and((string) $this->blade('<x-carousel layout="uncontained"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-padding="16"')
->toContain('data-padding-end="0"')
->and((string) $this->blade('<x-carousel item-width="40%"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('--material-carousel-item-width: 40%;');
});
@@ -88,15 +95,23 @@ it('lays out a hero that fills the width unless capped, centred on request', fun
->not->toContain('data-centered');
});
it('leaves an uncontained carousel unsnapped, and a full-screen one a page wide', function () {
it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down the page', function () {
expect((string) $this->blade('<x-carousel layout="uncontained" item-width="180"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-material-carousel="uncontained"')
->toContain('--material-carousel-item-width: 180px')
->not->toContain('snap-mandatory')
->and((string) $this->blade('<x-carousel layout="full-screen" centered><x-carousel-item>A</x-carousel-item></x-carousel>'))
->and((string) $this->blade('<x-carousel layout="full-screen" centered controls><x-carousel-item label="Morning">A</x-carousel-item></x-carousel>'))
->toContain('data-material-carousel="full-screen"')
->toContain('--material-carousel-slot: 100%')
->toContain('snap-mandatory')
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no end padding,
// no corner, no mask, and never wider than the medium window it is meant for.
->toContain('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')
->toContain('data-padding="0" data-padding-end="0"')
->toContain('aria-label="Previous"')
->not->toContain('--material-carousel-slot')
->not->toContain('snap-x')
->not->toContain('rounded-corner-xl')
->not->toContain('clip-path')
->not->toContain('rtl:-scale-x-100')
->not->toContain('x-ref="probe"')
->not->toContain('data-centered')
->and((string) $this->blade('<x-carousel layout="sideways"><x-carousel-item>A</x-carousel-item></x-carousel>'))
@@ -136,7 +151,7 @@ it('lays a label over an item on a scrim, and passes attributes to the item', fu
->toContain('data-material-carousel-label')
->toContain('Lake Constance')
->toContain('from-scrim/60')
->toContain('type-title-md text-white')
->toContain('type-title-md text-inverse-on-surface')
->toContain('[&>img]:size-full [&>img]:object-cover')
->and((string) $this->blade('<x-carousel-item>A</x-carousel-item>'))
->not->toContain('data-material-carousel-label');
+4 -2
View File
@@ -11,6 +11,8 @@ it('discloses on the native details element, kept open through a morph', functio
->toContain('wire:ignore.self')
->toContain(' open ')
->toContain('rounded-corner-lg bg-surface-container')
->toContain('data-collapse')
->toContain('[interpolate-size:allow-keywords]')
->toContain('How long do links last?')
->toContain('Until the expiry.')
->toContain('group-open/collapse:rotate-180');
@@ -20,12 +22,12 @@ it('has no Alpine on it without a binding', function () {
$html = (string) $this->blade('<x-collapse title="Advanced" open>Body</x-collapse>');
expect($html)
->toMatch('/<details\s+wire:ignore.self\s+open\s+class="group\/collapse/')
->toMatch('/<details\s+wire:ignore.self\s+data-collapse\s+open\s+class="group\/collapse/')
->not->toContain('x-data')
->not->toContain('x-modelable')
->not->toContain('x-on:toggle')
->and((string) $this->blade('<x-collapse title="Advanced">Body</x-collapse>'))
->toMatch('/<details\s+wire:ignore.self\s+class="group\/collapse/');
->toMatch('/<details\s+wire:ignore.self\s+data-collapse\s+class="group\/collapse/');
});
it('binds its open state through x-model, drawing the open prop until Alpine starts', function () {
+55 -6
View File
@@ -5,8 +5,11 @@ it('draws a plain list, with dividers on request', function () {
->toContain('role="list"')
->toContain('aria-label="Files"')
->toContain('data-list="plain"')
->toContain('divide-y divide-outline-variant')
->toContain('role="listitem"');
->toContain('data-dividers')
->not->toContain('divide-y')
->toContain('role="listitem"')
->and((string) $this->blade('<x-list><x-list-item title="a.zip" /></x-list>'))->not->toContain('data-dividers')
->and((string) $this->blade('<x-list segmented dividers><x-list-item title="a.zip" /></x-list>'))->not->toContain('data-dividers');
});
it('draws M3 Expressive\'s segmented list', function () {
@@ -15,10 +18,25 @@ it('draws M3 Expressive\'s segmented list', function () {
->toContain('gap-0.5');
});
it('grows an item from one to three lines', function () {
expect((string) $this->blade('<x-list-item title="a.zip" />'))->toContain('min-h-14')
->and((string) $this->blade('<x-list-item title="a.zip" description="248 MB" />'))->toContain('min-h-18')->toContain('248 MB')
->and((string) $this->blade('<x-list-item title="a.zip" overline="Protected" description="248 MB" />'))->toContain('min-h-22')->toContain('Protected');
it('grows an item from one to three lines, top-aligning the tallest', function () {
expect((string) $this->blade('<x-list-item title="a.zip" />'))->toContain('items-center')->toContain('min-h-14 py-2')
->and((string) $this->blade('<x-list-item title="a.zip" description="248 MB" />'))->toContain('items-center')->toContain('min-h-18 py-2')->toContain('248 MB')
->and((string) $this->blade('<x-list-item title="a.zip" overline="Protected" description="248 MB" />'))
->toContain('items-start')
->toContain('min-h-22 py-3')
->toContain('Protected')
->not->toContain('items-center');
});
it('leaves 16px between the item and what leads or trails it', function () {
expect((string) $this->blade('<x-list-item title="a.zip" icon="folder_zip" />'))->toContain('flex gap-4 px-4');
});
it('draws the expressive icon size in a segmented list', function () {
expect((string) $this->blade('<x-list segmented><x-list-item title="a" icon="settings" icon-right="chevron_right" /></x-list>'))
->toContain('size-5')
->not->toContain('size-6')
->and((string) $this->blade('<x-list><x-list-item title="a" icon="settings" /></x-list>'))->toContain('size-6');
});
it('leads with an icon, an avatar or an image, and trails with text or an icon', function () {
@@ -38,6 +56,37 @@ it('makes a linked item a row that opens from anywhere', function () {
->toContain('wire:navigate');
});
it('is a list box of options when it is one a person chooses from', function () {
expect((string) $this->blade('<x-list selectable label="Recipients"><x-list-item title="Anna" :selected="true" /><x-list-item title="Ben" /></x-list>'))
->toContain('role="listbox"')
->toMatch('/role="option"\s+aria-selected="true"/')
->toMatch('/role="option"\s+aria-selected="false"/')
->not->toContain('aria-multiselectable')
->and((string) $this->blade('<x-list selection="multi"><x-list-item title="Anna" /></x-list>'))
->toContain('role="listbox"')
->toContain('aria-multiselectable="true"')
->and((string) $this->blade('<x-list><x-list-item title="Anna" :selected="true" /></x-list>'))
->toContain('role="list"')
->toMatch('/role="listitem"\s+aria-current="true"/')
->not->toContain('aria-selected');
});
it('draws a second cue on a selected option, which icon-right replaces', function () {
expect(substr_count((string) $this->blade('<x-list selectable><x-list-item title="Anna" :selected="true" /></x-list>'), '<svg'))->toBe(1)
->and(substr_count((string) $this->blade('<x-list selectable><x-list-item title="Anna" :selected="true" icon-right="chevron_right" /></x-list>'), '<svg'))->toBe(1)
->and(substr_count((string) $this->blade('<x-list selectable><x-list-item title="Anna" /></x-list>'), '<svg'))->toBe(0)
->and(substr_count((string) $this->blade('<x-list><x-list-item title="Anna" :selected="true" /></x-list>'), '<svg'))->toBe(0);
});
it('leaves a disabled item out of the keyboard and announces it disabled', function () {
expect((string) $this->blade('<x-list-item title="Settings" link="/settings" icon="settings" disabled />'))
->toContain('aria-disabled="true"')
->toContain('pointer-events-none text-on-surface/38')
->not->toContain('href="/settings"')
->not->toContain('data-list-open')
->not->toContain('data-list-row');
});
it('marks a selected item and takes controls in its slots', function () {
expect((string) $this->blade('<x-list-item title="Anna" :selected="true"><x-slot:leading><input type="checkbox"></x-slot:leading><x-slot:end><button>⋮</button></x-slot:end></x-list-item>'))
->toContain('data-selected')
+57 -5
View File
@@ -49,10 +49,43 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
expect($html)
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/')
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code</p>')
->not->toContain('<div class="mb-4 max-medium:hidden">')
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-medium:hidden">')
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>');
->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code<\/p>/')
->not->toContain('<div class="shrink-0 px-6 pt-6 max-medium:hidden">')
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="shrink-0 px-6 pt-6 max-medium:hidden">')
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant">Only a subtitle<\/p>/');
});
it('pins a dialog\'s headline and actions and scrolls only the body between them', function () {
$html = (string) $this->blade('<x-modal title="Terms" subtitle="Please read" separator>Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>');
expect($html)
->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3')
->not->toContain('overflow-y-auto rounded-corner-xl')
->toContain('<div class="shrink-0 px-6 pt-6">')
->toMatch('/<div id="[^"]+-body" class="min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant pt-4">/')
->toContain('flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6')
->and(substr_count($html, 'role="separator"'))->toBe(2);
});
it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () {
$alert = (string) $this->blade('<x-modal title="Delete this share?" subtitle="Recipients lose access." alert>This cannot be undone.</x-modal>');
preg_match('/id="(material-dialog-[a-z0-9]+)"/', $alert, $id);
expect($alert)
->toContain('role="alertdialog"')
->toContain("aria-describedby=\"{$id[1]}-subtitle {$id[1]}-body\"")
->and((string) $this->blade('<x-modal title="Share settings" subtitle="Who can see it">Body</x-modal>'))
->not->toContain('role="alertdialog"')
->toMatch('/aria-describedby="material-dialog-[a-z0-9]+-subtitle"/')
->and((string) $this->blade('<x-modal title="Share settings">Body</x-modal>'))
->not->toContain('aria-describedby');
});
it('gives a full-screen dialog M3\'s 56px header and action bar', function () {
expect((string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>'))
->toContain('flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden')
->toContain('max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2');
});
it('leaves a pane open on Escape unless it is asked to close then too', function () {
@@ -64,6 +97,20 @@ it('leaves a pane open on Escape unless it is asked to close then too', function
->not->toContain('keydown.window.escape');
});
it('always offers a way out of a side sheet, as M3 requires', function () {
expect((string) $this->blade('<x-drawer title="Details">Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false">Body</x-drawer>'))->not->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" :close-on-escape="false">Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" without-backdrop-close>Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer pane :with-close-button="false">Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :with-close-button="false">Body</x-drawer>'))->not->toContain('aria-label="Close"');
});
it('puts a side sheet\'s actions on the left, in M3\'s 72dp row', function () {
expect((string) $this->blade('<x-drawer title="Details">Body<x-slot:actions><button>Delete</button></x-slot:actions></x-drawer>'))
->toContain('flex min-h-18 shrink-0 flex-wrap items-center justify-start gap-2 pt-4');
});
it('slides a side sheet in from either edge, and is a pane from expanded when asked', function () {
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
->toContain('x-trap.inert.noscroll="open && ! wide"')
@@ -86,8 +133,13 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s
->toContain('rounded-t-corner-xl bg-surface-container-low')
->toContain('data-drag-handle')
->toContain('aria-modal="true"')
->toContain('py-5.5')
->toContain('touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant ')
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(true)')
->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>'))
->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))');
});
+14 -1
View File
@@ -118,7 +118,7 @@ it('inlines the application\'s scheme onto the mail', function () {
->toContain('border-top: 16px solid #123456')
->toContain('color: #fefefe')
->toContain('border-radius: 9999px')
->and(inlineStyle($html, 'inner-body'))->toContain('background-color: #fdfdfd')
->and(inlineStyle($html, 'inner-body'))->toContain('background-color: #fdfdfd')->toContain('border-radius: 28px')
->and(inlineStyle($html, 'wrapper'))->toContain('background-color: #eeeeee')
->and(inlineStyle($html, 'panel-content'))->toContain('background-color: #eeeeee')
->and($html)
@@ -190,6 +190,19 @@ it('styles every button colour Laravel and M3 name', function () {
expect($css)->not->toContain('@media');
});
it('keeps every corner and weight on M3\'s scales', function () {
$css = view('livewire-material::mail.theme')->render();
preg_match_all('/border-radius: (\d+)px/', $css, $corners);
// M3's shape scale, plus the `full` corner a button draws as a large pixel value.
expect(array_unique($corners[1]))->each->toBeIn(['0', '4', '8', '12', '16', '20', '28', '32', '48', '9999'])
->and($css)->toContain(<<<'CSS'
.header a {
CSS)
->toMatch('/\.header a \{[^}]*font-weight: 400/');
});
it('leaves the application\'s mail components alone unless asked', function () {
expect(config('mail.markdown.paths'))->not->toContain(LivewireMaterialServiceProvider::mailComponentPath());