diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 02d87a4f..e1dd8164 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -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 `` 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 `` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move. ```blade @@ -432,9 +432,13 @@ A card or list item that opens something is a **row**: `data-list-row` on it and ``` +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. + ### ``, `` -``: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). ``: `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. +``: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). ``: `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 `` (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 @@ -476,15 +480,15 @@ An M3 dialog on native ``. Bind with `wire:model` to a flag or an id; cl ``` -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. ### `` -An M3 side sheet, bound like ``; `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 `
`. 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 ``; `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 `
`. 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 bottom sheet, bound like ``: 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 ``: 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. ### ``, `` @@ -498,7 +502,7 @@ An M3 bottom sheet, bound like ``: modal by default (scrim, inert page, ``` -A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). ``: `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). ``: slot is an `` (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). ``: `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). ``: slot is an `` (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. ### `` diff --git a/resources/css/components/collapse.css b/resources/css/components/collapse.css new file mode 100644 index 00000000..0ba2d2cf --- /dev/null +++ b/resources/css/components/collapse.css @@ -0,0 +1,30 @@ +/* + * ``'s height, eased open. + * + * The native `
` 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 ``. `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; + } +} diff --git a/resources/css/components/list.css b/resources/css/components/list.css index e4c7d7ae..a0454748 100644 --- a/resources/css/components/list.css +++ b/resources/css/components/list.css @@ -9,9 +9,36 @@ * * Unlayered where a component paints its fill as a utility (``): 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 (``). + */ + :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 (``). */ +[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 (``): 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); } diff --git a/resources/css/material.css b/resources/css/material.css index f997c77a..d6565b0e 100644 --- a/resources/css/material.css +++ b/resources/css/material.css @@ -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'; diff --git a/resources/js/carousel.js b/resources/js/carousel.js index f02e0fb2..eb0eb46b 100644 --- a/resources/js/carousel.js +++ b/resources/js/carousel.js @@ -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() { diff --git a/resources/js/list-rows.js b/resources/js/list-rows.js index 98669913..a9f624eb 100644 --- a/resources/js/list-rows.js +++ b/resources/js/list-rows.js @@ -11,6 +11,11 @@ * Not a stretched link (`::after { inset: 0 }`): Safari makes no containing block of a , so in * a table every overlay would cover the whole table; and not one +
+
diff --git a/resources/views/components/card.blade.php b/resources/views/components/card.blade.php index 88261d44..9bf2ff5e 100644 --- a/resources/views/components/card.blade.php +++ b/resources/views/components/card.blade.php @@ -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 `` - 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 `` 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
class([ 'relative flex flex-col overflow-hidden rounded-corner-md text-on-surface', 'bg-surface-container-highest' => $variant === 'filled', diff --git a/resources/views/components/carousel-item.blade.php b/resources/views/components/carousel-item.blade.php index 81b2f05d..1dc80f68 100644 --- a/resources/views/components/carousel-item.blade.php +++ b/resources/views/components/carousel-item.blade.php @@ -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 ``. --}} + 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 ``. --}} @props([ 'label' => null, ]) +{{-- The layout of the `` 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 +
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', ]) }}> diff --git a/resources/views/components/carousel.blade.php b/resources/views/components/carousel.blade.php index ef2af072..bf180ed9 100644 --- a/resources/views/components/carousel.blade.php +++ b/resources/views/components/carousel.blade.php @@ -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, ])>
@endif diff --git a/resources/views/components/collapse.blade.php b/resources/views/components/collapse.blade.php index e1ed8913..ec7ef45e 100644 --- a/resources/views/components/collapse.blade.php +++ b/resources/views/components/collapse.blade.php @@ -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 @@
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
get('class'), ]) }} > - @if (filled($title) || $withCloseButton) + @if (filled($title) || $closeButton)
@@ -118,7 +129,7 @@ @endif
- @if ($withCloseButton) + @if ($closeButton) @@ -136,7 +147,10 @@
@isset($actions) -
+ {{-- 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. --}} +
{{ $actions }}
@endisset diff --git a/resources/views/components/list-item.blade.php b/resources/views/components/list-item.blade.php index f9791dbe..f6ee5596 100644 --- a/resources/views/components/list-item.blade.php +++ b/resources/views/components/list-item.blade.php @@ -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 `` 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 `` 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
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) @elseif ($icon) - + @endif
@@ -94,6 +124,8 @@ @endif @if ($iconRight) - + + @elseif ($check) + @endif
diff --git a/resources/views/components/list.blade.php b/resources/views/components/list.blade.php index 527e0db4..e5ba9aaa 100644 --- a/resources/views/components/list.blade.php +++ b/resources/views/components/list.blade.php @@ -1,28 +1,50 @@ {{-- An M3 list: ``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 +
class([ 'flex flex-col', 'gap-0.5' => $segmented, - 'divide-y divide-outline-variant' => $dividers && ! $segmented, ]) }} > {{ $slot }} diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php index 190a539e..6be807d1 100644 --- a/resources/views/components/modal.blade.php +++ b/resources/views/components/modal.blade.php @@ -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 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 @@ ]) >
$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) -
+
@if (filled($title)) @@ -78,35 +98,46 @@
@endif -
$fullscreen])> - @if (filled($title) || filled($subtitle) || $icon) - {{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}} -
$fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])> - @if ($icon) - - @endif + @if ($header) + {{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}} +
$fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])> + @if ($icon) + + @endif - @if (filled($title)) -

$fullscreen && ! $icon])>{{ $title }}

- @endif + @if (filled($title)) +

$fullscreen && ! $icon])>{{ $title }}

+ @endif - @if (filled($subtitle)) -

filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}

- @endif + @if (filled($subtitle)) +

filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}

+ @endif - @if ($separator) - - @endif -
- @endif + @if ($separator) + + @endif +
+ @endif -
{{ $slot }}
-
+ @if ($slot->isNotEmpty()) +
$header, + 'pt-6' => ! $header, + 'pb-6' => ! isset($actions), + ])> + {{ $slot }} +
+ @endif @isset($actions) + @if ($separator) + + @endif +
$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 }}
diff --git a/resources/views/mail/theme.blade.php b/resources/views/mail/theme.blade.php index 910d6527..3dff94a4 100644 --- a/resources/views/mail/theme.blade.php +++ b/resources/views/mail/theme.blade.php @@ -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; diff --git a/resources/views/showcase/sections/carousel.blade.php b/resources/views/showcase/sections/carousel.blade.php index fb8e6d6e..11312911 100644 --- a/resources/views/showcase/sections/carousel.blade.php +++ b/resources/views/showcase/sections/carousel.blade.php @@ -55,8 +55,8 @@ @endforeach BLADE, - 'Full-screen' => <<<'BLADE' - + 'Full-screen: one item at a time, scrolled down' => <<<'BLADE' + @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 @@

<x-carousel> and <x-carousel-item>: 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.

@foreach ($examples as $title => $code) diff --git a/resources/views/showcase/sections/containment.blade.php b/resources/views/showcase/sections/containment.blade.php index 169fe1bb..4683f294 100644 --- a/resources/views/showcase/sections/containment.blade.php +++ b/resources/views/showcase/sections/containment.blade.php @@ -26,6 +26,15 @@ BLADE, + 'A directly actionable card: the card is the tab stop' => <<<'BLADE' + + contract.pdf +

1.2 MB · downloaded twice

+ + + +
+ BLADE, 'Lists' => <<<'BLADE'
@@ -40,9 +49,17 @@ +
BLADE, + 'A list to choose from' => <<<'BLADE' + + + + + + BLADE, 'Dividers and collapse' => <<<'BLADE'
@@ -70,7 +87,7 @@ 'Dialogs' => <<<'BLADE'
- + diff --git a/tests/Browser/CarouselTest.php b/tests/Browser/CarouselTest.php index 06d0c3d7..ddf8022d 100644 --- a/tests/Browser/CarouselTest.php +++ b/tests/Browser/CarouselTest.php @@ -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')); }); diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index 0c4c2562..4aaa6a31 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -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'); }); diff --git a/tests/Feature/Components/CardTest.php b/tests/Feature/Components/CardTest.php index 46b508fa..043069e0 100644 --- a/tests/Feature/Components/CardTest.php +++ b/tests/Feature/Components/CardTest.php @@ -1,7 +1,10 @@ blade("Body"))->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("Body")) + ->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('Body'))->toContain('bg-surface-container-highest'); + expect((string) $this->blade('Body')) + ->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('Open'); + + 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('Body')) + ->toContain('role="link"') + ->and(substr_count((string) $this->blade('Body'), 'role="link"'))->toBe(1) + ->and((string) $this->blade('Body')) + ->not->toContain('data-list-actionable') + ->not->toContain('tabindex'); +}); diff --git a/tests/Feature/Components/CarouselTest.php b/tests/Feature/Components/CarouselTest.php index 47f48567..b66d932a 100644 --- a/tests/Feature/Components/CarouselTest.php +++ b/tests/Feature/Components/CarouselTest.php @@ -1,6 +1,6 @@ blade(<<<'BLADE' A lake @@ -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('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('/])*>/'); }); 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('A')) + expect((string) $this->blade('A')) ->toContain('--material-carousel-item-width: 220px; --material-carousel-slot: 220px; --material-carousel-height: 18rem') + ->toContain('data-padding="24"') + ->and((string) $this->blade('A')) ->toContain('data-padding="16"') + ->toContain('data-padding-end="0"') ->and((string) $this->blade('A')) ->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('A')) ->toContain('data-material-carousel="uncontained"') ->toContain('--material-carousel-item-width: 180px') ->not->toContain('snap-mandatory') - ->and((string) $this->blade('A')) + ->and((string) $this->blade('A')) ->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('A')) @@ -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('A')) ->not->toContain('data-material-carousel-label'); diff --git a/tests/Feature/Components/CollapseTest.php b/tests/Feature/Components/CollapseTest.php index 11d9f24f..8c04fbd9 100644 --- a/tests/Feature/Components/CollapseTest.php +++ b/tests/Feature/Components/CollapseTest.php @@ -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('Body'); expect($html) - ->toMatch('/toMatch('/not->toContain('x-data') ->not->toContain('x-modelable') ->not->toContain('x-on:toggle') ->and((string) $this->blade('Body')) - ->toMatch('/toMatch('/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(''))->not->toContain('data-dividers') + ->and((string) $this->blade(''))->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(''))->toContain('min-h-14') - ->and((string) $this->blade(''))->toContain('min-h-18')->toContain('248 MB') - ->and((string) $this->blade(''))->toContain('min-h-22')->toContain('Protected'); +it('grows an item from one to three lines, top-aligning the tallest', function () { + expect((string) $this->blade(''))->toContain('items-center')->toContain('min-h-14 py-2') + ->and((string) $this->blade(''))->toContain('items-center')->toContain('min-h-18 py-2')->toContain('248 MB') + ->and((string) $this->blade('')) + ->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(''))->toContain('flex gap-4 px-4'); +}); + +it('draws the expressive icon size in a segmented list', function () { + expect((string) $this->blade('')) + ->toContain('size-5') + ->not->toContain('size-6') + ->and((string) $this->blade(''))->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('')) + ->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('')) + ->toContain('role="listbox"') + ->toContain('aria-multiselectable="true"') + ->and((string) $this->blade('')) + ->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(''), 'toBe(1) + ->and(substr_count((string) $this->blade(''), 'toBe(1) + ->and(substr_count((string) $this->blade(''), 'toBe(0) + ->and(substr_count((string) $this->blade(''), 'toBe(0); +}); + +it('leaves a disabled item out of the keyboard and announces it disabled', function () { + expect((string) $this->blade('')) + ->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('')) ->toContain('data-selected') diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 6e20420b..af8632db 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -49,10 +49,43 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the expect($html) ->toMatch('/

/') - ->toContain('

Scan the code

') - ->not->toContain('
') - ->and((string) $this->blade('Text'))->toContain('
') - ->and((string) $this->blade('Text'))->toContain('

Only a subtitle

'); + ->toMatch('/

Scan the code<\/p>/') + ->not->toContain('

') + ->and((string) $this->blade('Text'))->toContain('
') + ->and((string) $this->blade('Text'))->toMatch('/

Only a subtitle<\/p>/'); +}); + +it('pins a dialog\'s headline and actions and scrolls only the body between them', function () { + $html = (string) $this->blade('Body'); + + expect($html) + ->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3') + ->not->toContain('overflow-y-auto rounded-corner-xl') + ->toContain('

') + ->toMatch('/
/') + ->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('This cannot be undone.'); + + 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('Body')) + ->not->toContain('role="alertdialog"') + ->toMatch('/aria-describedby="material-dialog-[a-z0-9]+-subtitle"/') + ->and((string) $this->blade('Body')) + ->not->toContain('aria-describedby'); +}); + +it('gives a full-screen dialog M3\'s 56px header and action bar', function () { + expect((string) $this->blade('Body')) + ->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('Body'))->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->not->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->toContain('aria-label="Close"') + ->and((string) $this->blade('Body'))->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('Body')) + ->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('Body')) ->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('Body')) ->toContain('...materialBottomSheet(true)') ->not->toContain('bg-scrim/32') - ->not->toContain('aria-modal'); + ->not->toContain('aria-modal') + ->and((string) $this->blade('Body')) + ->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))'); }); diff --git a/tests/Feature/MailThemeTest.php b/tests/Feature/MailThemeTest.php index cc4dca63..ee8a1dfe 100644 --- a/tests/Feature/MailThemeTest.php +++ b/tests/Feature/MailThemeTest.php @@ -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());