Merge branch 'worktree-agent-a6769a5cd2c4b070e'
This commit is contained in:
@@ -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>`
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,10 +98,9 @@
|
||||
</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)
|
||||
@if ($header)
|
||||
{{-- 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])>
|
||||
<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
|
||||
@@ -91,7 +110,7 @@
|
||||
@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>
|
||||
<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)
|
||||
@@ -100,13 +119,25 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="type-body-md text-on-surface-variant">{{ $slot }}</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>
|
||||
|
||||
@@ -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><x-carousel></code> and <code><x-carousel-item></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()" />
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))');
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user