Merge branch 'worktree-agent-a3082a7e6ef1bee94'
This commit is contained in:
@@ -15,6 +15,24 @@
|
||||
default is `50dvh`, under a ceiling of the screen less M3's 72dp top margin. Content scrolls
|
||||
inside.
|
||||
|
||||
`heights` gives the sheet M3's **preset heights**: a list of stops — `heights="25dvh,50dvh,90dvh"`,
|
||||
`:heights="[25, 50, 90]"` (a bare number is read as `dvh`) or a JSON list — and the sheet then
|
||||
takes the height of its current stop rather than sizing itself to its content. `snap` is the
|
||||
shorthand for the library's three, `25dvh`, `50dvh` and `90dvh`. It opens at the stop that equals
|
||||
`height`, or at the first one; fewer than two stops is no stops at all, since a single height is
|
||||
what `height` already says.
|
||||
|
||||
With stops the drag handle is M3's height control, which is the accessibility rule behind them:
|
||||
"the drag handle can be dragged **or selected** to cycle through preset heights", "any drag-only
|
||||
action needs a single-pointer alternative", "Tab focuses the drag handle; Space/Enter toggles
|
||||
between available heights", and "selecting the drag handle toggles preset heights **or closes the
|
||||
sheet**". So activating the handle — a click, Enter or Space, since it is a button — moves to the
|
||||
next stop and announces it in a live region, and from the last stop it closes the sheet, which is
|
||||
also what a handle with no stops does. A drag runs the sheet's height with the pointer and
|
||||
settles on the nearest stop on release, or closes it below the smallest one or on a downward
|
||||
flick (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets →
|
||||
Behaviour, Accessibility).
|
||||
|
||||
The handle is drawn 32×4px and pressed 48×48: `touch-target` on the button and 22px above and
|
||||
below it, which is M3's "drag handle has an accessible 48dp hit target" and SheetDefaults.kt's
|
||||
`DragHandleVerticalPadding = 22.dp` (docs/reference/m3/components-actions-communication-containment.md § Bottom sheets → Specs). --}}
|
||||
@@ -23,16 +41,51 @@
|
||||
'title' => null,
|
||||
'standard' => false,
|
||||
'height' => '50dvh',
|
||||
'heights' => null,
|
||||
'snap' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$model = $attributes->wire('model')->value() ?: null;
|
||||
$id = $attributes->get('id') ?? 'material-bottom-sheet-'.substr(md5($model.'|'.$title), 0, 10);
|
||||
|
||||
// A list, a JSON list or a comma-separated one; a bare number is a percentage of the screen,
|
||||
// which is how M3 talks about a sheet's position ("capped at 50% of screen height").
|
||||
$stops = match (true) {
|
||||
is_array($heights) => $heights,
|
||||
is_string($heights) && str_starts_with(trim($heights), '[') => json_decode($heights, true) ?: [],
|
||||
filled($heights) => explode(',', (string) $heights),
|
||||
(bool) $snap => ['25dvh', '50dvh', '90dvh'],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$stops = array_values(array_filter(array_map(
|
||||
fn ($stop): string => is_numeric($stop) ? ((float) $stop).'dvh' : trim((string) $stop),
|
||||
$stops,
|
||||
), 'filled'));
|
||||
|
||||
// M3 asks for a non-drag way to change height "if multiple preset heights exist"; one stop is
|
||||
// not multiple, and `height` already says where a single-height sheet opens.
|
||||
$stops = count($stops) > 1 ? $stops : [];
|
||||
$start = (int) (array_search($height, $stops, true) ?: 0);
|
||||
|
||||
$presets = \Illuminate\Support\Js::from([
|
||||
'stops' => $stops,
|
||||
'start' => $start,
|
||||
'labels' => [
|
||||
'change' => __('Change the sheet height'),
|
||||
'close' => __('Close'),
|
||||
'announce' => array_map(
|
||||
fn (int $index): string => __('Height :position of :count', ['position' => $index + 1, 'count' => count($stops)]),
|
||||
array_keys($stops),
|
||||
),
|
||||
],
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
...materialBottomSheet({{ $standard ? 'true' : 'false' }}),
|
||||
...materialBottomSheet({{ $standard ? 'true' : 'false' }}, {{ $presets }}),
|
||||
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
||||
}"
|
||||
x-on:keydown.window.escape="if (open && ! standard) close()"
|
||||
@@ -41,6 +94,12 @@
|
||||
<div x-cloak x-show="open" x-transition.opacity.duration.200ms x-on:click="close()" class="fixed inset-0 z-40 bg-scrim/32" aria-hidden="true"></div>
|
||||
@endunless
|
||||
|
||||
@if ($stops !== [])
|
||||
{{-- A stop is a CSS length, and only the browser can say what `25dvh` is in pixels; this
|
||||
measures one when a drag has to find the nearest. --}}
|
||||
<div x-ref="probe" aria-hidden="true" class="pointer-events-none invisible fixed start-0 top-0 w-0"></div>
|
||||
@endif
|
||||
|
||||
<section
|
||||
x-cloak
|
||||
x-show="open"
|
||||
@@ -52,22 +111,39 @@
|
||||
x-transition:leave="transition-[translate] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate"
|
||||
x-transition:leave-start="translate-y-0"
|
||||
x-transition:leave-end="translate-y-full"
|
||||
x-bind:style="dragged ? { translate: `0 ${dragged}px`, transition: 'none' } : {}"
|
||||
x-bind:style="sheetStyle"
|
||||
x-on:pointerdown="dragStart($event)"
|
||||
id="{{ $id }}"
|
||||
role="dialog"
|
||||
@unless ($standard) aria-modal="true" @endunless
|
||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||
style="--sheet-max-height: min({{ $height }}, calc(100dvh - 72px))"
|
||||
@if ($stops === [])
|
||||
style="--sheet-max-height: min({{ $height }}, calc(100dvh - 72px))"
|
||||
@else
|
||||
style="--sheet-stop: {{ $stops[$start] }}; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))"
|
||||
@endif
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||
'fixed inset-x-0 bottom-0 z-50 mx-auto flex max-h-(--sheet-max-height) w-full max-w-160 touch-pan-y flex-col rounded-t-corner-xl bg-surface-container-low pb-[var(--material-safe-bottom,env(safe-area-inset-bottom))] text-on-surface shadow-elevation-1',
|
||||
// With stops the sheet is the height of its stop, not of its content, and it moves
|
||||
// between them on the spatial track — anything that changes size does.
|
||||
'h-(--sheet-max-height) transition-[height] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default' => $stops !== [],
|
||||
$attributes->get('class'),
|
||||
]) }}
|
||||
>
|
||||
<div class="flex shrink-0 cursor-grab justify-center py-5.5 active:cursor-grabbing" data-drag-handle>
|
||||
<button type="button" class="touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary" aria-label="{{ __('Close') }}" x-on:click="close()"></button>
|
||||
<button
|
||||
type="button"
|
||||
class="touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant outline-offset-4 focus-visible:outline-3 focus-visible:outline-secondary"
|
||||
aria-label="{{ $stops === [] ? __('Close') : __('Change the sheet height') }}"
|
||||
@if ($stops !== []) x-bind:aria-label="handleLabel" @endif
|
||||
x-on:click="activate()"
|
||||
></button>
|
||||
</div>
|
||||
|
||||
@if ($stops !== [])
|
||||
<span class="sr-only" aria-live="polite" x-text="announcement"></span>
|
||||
@endif
|
||||
|
||||
<div x-ref="body" class="min-h-0 flex-1 overflow-y-auto px-6 pb-6">
|
||||
@if (filled($title))
|
||||
<h2 id="{{ $id }}-title" class="mb-4 type-title-lg">{{ $title }}</h2>
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
filled and outlined 0 → 1); its corner does not move, because M3 gives a card one shape.
|
||||
Never a stretched link, and never a whole-card `<a>` around buttons.
|
||||
|
||||
`data-dragged` is M3's dragged card: the top of a card's elevation scale — 8dp elevated, 6dp
|
||||
filled and outlined — under the 16% dragged state layer, and it holds while the pointer is
|
||||
down rather than falling back to the press state. Nothing in the browser tells a card it is
|
||||
being carried, so the application sets the attribute when its drag starts and takes it off on
|
||||
drop. M3 requires a single-pointer alternative beside any drag, so keep the same reorder or
|
||||
delete actions in a menu on the card
|
||||
(docs/reference/m3/components-actions-communication-containment.md § Cards → Specs,
|
||||
Accessibility; resources/css/components/list.css draws it).
|
||||
|
||||
Do not pass a `bg-*` class to change its fill — it races the card's own in Tailwind's emit
|
||||
order; use `variant`, or colour a wrapper inside. --}}
|
||||
|
||||
|
||||
@@ -21,40 +21,67 @@
|
||||
extra-large corner (28px, CarouselDefaults' item shape) and moved by
|
||||
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
|
||||
item shows unmasked, at its `item-width`. In a `full-screen` carousel it is none of that: the
|
||||
item fills the row, edge to edge, with no corner and no mask. Only inside `<x-carousel>`. --}}
|
||||
item fills the row, edge to edge, with no corner and no mask.
|
||||
|
||||
In a `multi-aspect` carousel the item is as wide as its own `aspect` makes it at the row's
|
||||
height, and nothing masks it: M3's uncontained multi-aspect-ratio layout is for items whose
|
||||
widths "vary… ranging from a 9:16 minimum to a 16:9 maximum aspect ratio", so `aspect` is
|
||||
held inside that range (`16/9`, `16:9` or a plain number; a square by default, and ignored in
|
||||
every other layout, where the keylines size the items)
|
||||
(docs/reference/m3/components-actions-communication-containment.md § Carousel → Variants,
|
||||
Specs). Only inside `<x-carousel>`. --}}
|
||||
|
||||
@props([
|
||||
'label' => null,
|
||||
'aspect' => null,
|
||||
])
|
||||
|
||||
{{-- The layout of the `<x-carousel>` around it: the full-screen one is a vertical row of
|
||||
edge-to-edge items, which M3 gives no corner and no mask. --}}
|
||||
edge-to-edge items, which M3 gives no corner and no mask, and the multi-aspect one sizes each
|
||||
item by its own aspect ratio and masks none of them. --}}
|
||||
@aware([
|
||||
'layout' => 'multi-browse',
|
||||
])
|
||||
|
||||
@php
|
||||
$vertical = $layout === 'full-screen';
|
||||
$multiAspect = $layout === 'multi-aspect';
|
||||
|
||||
// M3's 9:16 minimum and 16:9 maximum. `16/9`, `16:9` and `1.78` all say the same thing.
|
||||
$ratio = null;
|
||||
|
||||
if ($multiAspect) {
|
||||
$value = $aspect ?? 1;
|
||||
|
||||
if (is_string($value) && preg_match('/^\s*([\d.]+)\s*[\/:]\s*([\d.]+)\s*$/', $value, $parts) && (float) $parts[2] > 0) {
|
||||
$value = (float) $parts[1] / (float) $parts[2];
|
||||
}
|
||||
|
||||
$ratio = round(min(max((float) $value, 9 / 16), 16 / 9), 4);
|
||||
}
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes
|
||||
->class([
|
||||
'focus-ring relative h-full shrink-0 snap-start snap-always focus-visible:-outline-offset-3',
|
||||
'w-full' => $vertical,
|
||||
'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical,
|
||||
'w-auto rounded-corner-xl' => $multiAspect,
|
||||
'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical && ! $multiAspect,
|
||||
])
|
||||
->merge([
|
||||
->merge(array_filter([
|
||||
'role' => 'group',
|
||||
'aria-roledescription' => __('slide'),
|
||||
'aria-label' => '[material-carousel-position]',
|
||||
'data-material-carousel-item' => true,
|
||||
'tabindex' => '0',
|
||||
]) }}>
|
||||
'style' => $ratio === null ? null : "aspect-ratio: {$ratio}",
|
||||
], fn ($value): bool => $value !== null)) }}>
|
||||
<div
|
||||
data-material-carousel-surface
|
||||
@class([
|
||||
'relative size-full overflow-hidden bg-surface-container-highest text-on-surface',
|
||||
'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical,
|
||||
'rounded-corner-xl' => $multiAspect,
|
||||
'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical && ! $multiAspect,
|
||||
])
|
||||
>
|
||||
<div data-material-carousel-content class="size-full [&>img]:size-full [&>img]:object-cover">
|
||||
|
||||
@@ -19,6 +19,12 @@
|
||||
does.
|
||||
- `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves —
|
||||
HorizontalUncontainedCarousel. No snapping, as Compose's uncontained fling.
|
||||
- `multi-aspect`: M3's **uncontained multi-aspect-ratio** layout, added November 2025 —
|
||||
"same as Uncontained but items vary in width, ranging from a 9:16 minimum to a 16:9 maximum
|
||||
aspect ratio", and "only use this layout if the items have various widths". Each
|
||||
`<x-carousel-item aspect="16/9">` keeps its own ratio at the row's fixed `height`, so the
|
||||
widths follow from the art rather than from a keyline. Uncontained scrolling, 16px of
|
||||
leading padding, 8px between items, the extra-large corner.
|
||||
- `full-screen`: one edge-to-edge item at a time, scrolled **vertically** — M3: "this layout
|
||||
works best with content that is taller than it is wide, and scrolls vertically. It only
|
||||
works in portrait orientation in compact and medium breakpoints. Don't use this layout in
|
||||
@@ -26,6 +32,15 @@
|
||||
the row is never wider than the 840px medium window it is meant for. `item-width` and
|
||||
`padding` do not apply; `height` is the height of each item, so give it the room a
|
||||
portrait image wants.
|
||||
The keyline machinery fits every layout but `multi-aspect`: an Arrangement is a count of
|
||||
large, medium and small items of **one** size each, and the snap positions and masks it
|
||||
produces all follow from that one size, so a row of items of different widths has no
|
||||
arrangement to fit. That layout is therefore a plain flex row — the browser scrolls it and
|
||||
each item is laid out at its own aspect ratio, unmasked — and only the parts of
|
||||
resources/js/carousel.js that need no Strategy work on it: the previous and next buttons, the
|
||||
arrow keys, Home and End, and bringing a clipped item into view, all from resting positions
|
||||
measured off the DOM rather than computed from keylines.
|
||||
|
||||
`item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's
|
||||
sample). `padding` is Compose's `contentPadding` in pixels: the first and last items rest
|
||||
that far in from the edges while items in between scroll to them. M3's specs table gives
|
||||
@@ -70,8 +85,9 @@
|
||||
])
|
||||
|
||||
@php
|
||||
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse';
|
||||
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'multi-aspect', 'full-screen'], true) ? $layout : 'multi-browse';
|
||||
$vertical = $layout === 'full-screen';
|
||||
$multiAspect = $layout === 'multi-aspect';
|
||||
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
|
||||
$label ??= __('Carousel');
|
||||
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
|
||||
@@ -86,11 +102,12 @@
|
||||
$preferredWidth = match ($layout) {
|
||||
'multi-browse', 'uncontained' => $cssLength($itemWidth) ?? '186px',
|
||||
'hero' => $cssLength($itemWidth),
|
||||
'full-screen' => null,
|
||||
'multi-aspect', 'full-screen' => null,
|
||||
};
|
||||
// The full-screen item is `h-full w-full`: it takes the row, not a slot.
|
||||
// The full-screen item is `h-full w-full` and a multi-aspect one is as wide as its own ratio
|
||||
// makes it: neither takes a slot.
|
||||
$slotWidth = match (true) {
|
||||
$vertical => null,
|
||||
$vertical, $multiAspect => null,
|
||||
$preferredWidth === null => 'calc(100% - 64px)',
|
||||
default => $preferredWidth,
|
||||
};
|
||||
@@ -114,7 +131,7 @@
|
||||
// only for uncontained, none for full-screen, which is edge to edge.
|
||||
$padding = max(0, (float) $padding);
|
||||
$paddingStart = $vertical ? 0.0 : $padding;
|
||||
$paddingEnd = $vertical || $layout === 'uncontained' ? 0.0 : $padding;
|
||||
$paddingEnd = $vertical || $layout === 'uncontained' || $multiAspect ? 0.0 : $padding;
|
||||
|
||||
$attributes = $attributes
|
||||
->class('relative')
|
||||
@@ -126,6 +143,9 @@
|
||||
'style' => implode('; ', array_filter([
|
||||
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
|
||||
$slotWidth ? "--material-carousel-slot: {$slotWidth}" : null,
|
||||
// Without keylines there is nothing to anchor the first item in from the edge, so
|
||||
// the multi-aspect row carries the specs table's leading padding itself.
|
||||
$multiAspect ? "--material-carousel-pad: {$paddingStart}px" : null,
|
||||
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
|
||||
])),
|
||||
], fn ($value): bool => $value !== null));
|
||||
@@ -148,7 +168,10 @@
|
||||
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||
'mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain' => $vertical,
|
||||
'h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2' => ! $vertical,
|
||||
'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained',
|
||||
'ps-(--material-carousel-pad)' => $multiAspect,
|
||||
// M3's two scrolling modes: snap-scrolling everywhere but the two uncontained
|
||||
// layouts, which it gives default scrolling.
|
||||
'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained' && ! $multiAspect,
|
||||
])
|
||||
>
|
||||
{!! $slides !!}
|
||||
|
||||
@@ -2,23 +2,49 @@
|
||||
|
||||
Horizontal by default; `vertical` stands it between items in a row (give the row a height).
|
||||
`inset` indents it from the start by 16px, as under a list's leading icon; `middle` from both
|
||||
ends. It is `role="separator"`; with `decorative` it is hidden from assistive tech. --}}
|
||||
ends. It is `role="separator"`; with `decorative` it is hidden from assistive tech.
|
||||
|
||||
`text` is the divider with a subheader, as M3 draws it to head a group in a list or a menu:
|
||||
the label at the start and the rule running on from it. The divider specs table gives the
|
||||
geometry — "space between divider & supporting text 4dp", "divider right margin 8dp",
|
||||
"divider bottom margin 8dp" — so the rule starts 4px after the words, stops 8px short of the
|
||||
end and leaves 8px under the row. The label is M3's subhead: title-small in
|
||||
on-surface-variant, the type the rich tooltip's specs give a subhead, since the divider's own
|
||||
page names none (docs/reference/m3/components-actions-communication-containment.md
|
||||
§ Divider → Specs, § Tooltips → Specs). The words stay readable text that names the group
|
||||
after them; only the rule is the separator, and `decorative` hides the rule and leaves the
|
||||
words. `text` is for a horizontal divider; a vertical one ignores it. --}}
|
||||
|
||||
@props([
|
||||
'vertical' => false,
|
||||
'inset' => false,
|
||||
'middle' => false,
|
||||
'decorative' => false,
|
||||
'text' => null,
|
||||
])
|
||||
|
||||
<div
|
||||
@if ($decorative) aria-hidden="true" @else role="separator" aria-orientation="{{ $vertical ? 'vertical' : 'horizontal' }}" @endif
|
||||
{{ $attributes->class([
|
||||
'shrink-0 bg-outline-variant',
|
||||
'h-px w-auto' => ! $vertical,
|
||||
'w-px self-stretch' => $vertical,
|
||||
'ms-4' => $inset && ! $vertical,
|
||||
'mx-4' => $middle && ! $vertical,
|
||||
'my-2' => $middle && $vertical,
|
||||
]) }}
|
||||
></div>
|
||||
@if (filled($text) && ! $vertical)
|
||||
<div {{ $attributes->class([
|
||||
'flex shrink-0 items-center gap-1 pb-2',
|
||||
'ms-4' => $inset,
|
||||
'mx-4' => $middle,
|
||||
]) }}>
|
||||
<span class="shrink-0 type-title-sm text-on-surface-variant">{{ $text }}</span>
|
||||
<div
|
||||
@if ($decorative) aria-hidden="true" @else role="separator" aria-orientation="horizontal" @endif
|
||||
class="me-2 h-px min-w-0 flex-1 bg-outline-variant"
|
||||
></div>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
@if ($decorative) aria-hidden="true" @else role="separator" aria-orientation="{{ $vertical ? 'vertical' : 'horizontal' }}" @endif
|
||||
{{ $attributes->class([
|
||||
'shrink-0 bg-outline-variant',
|
||||
'h-px w-auto' => ! $vertical,
|
||||
'w-px self-stretch' => $vertical,
|
||||
'ms-4' => $inset && ! $vertical,
|
||||
'mx-4' => $middle && ! $vertical,
|
||||
'my-2' => $middle && $vertical,
|
||||
]) }}
|
||||
></div>
|
||||
@endif
|
||||
|
||||
@@ -15,6 +15,25 @@
|
||||
elevation 1; `side` `end` (the default) or `start`; `width` from `medium` (a caller's `w-*`
|
||||
would race the sheet's own — a compact window gets the full-bleed sheet).
|
||||
|
||||
As a **standard** side sheet (`standard`, from `expanded`) it is M3's other variant: co-planar
|
||||
with the content rather than over it — no scrim, no focus trap, nothing inert, 0dp elevation
|
||||
(material-components-android's `SideSheet.md`: "standard side sheet elevation = 0dp,
|
||||
coplanar"), `surface` rather than surface-container-low, no corner, and an outline-variant
|
||||
divider down its inner edge in place of the scrim. It sits in the page flow beside the
|
||||
content, spans the window's height and scrolls on its own. M3 calls the standard sheet
|
||||
"supplementary surfaces mainly for medium to expanded breakpoints" and the modal one
|
||||
"preferred at compact breakpoints"; the library switches at `expanded` (840px) rather than at
|
||||
medium, because M3 also caps a side sheet at 400dp and a 600px window has too little left
|
||||
beside one — below `expanded` a `standard` sheet is the modal sheet
|
||||
(docs/reference/m3/components-actions-communication-containment.md § Side sheets).
|
||||
|
||||
`standard` and `pane` are not the same thing and neither replaces the other: a **pane** is the
|
||||
second pane of a list-detail layout — it shows what the list beside it has selected, is
|
||||
`22.5rem` wide (M3's 360dp fixed pane), sticks under the top of the viewport and sits on
|
||||
`surface-container` with a large corner. A **standard** sheet is supplementary content beside
|
||||
the primary content — filters, details, a list of actions — full height, flat on `surface`,
|
||||
divided from the content by a rule. Pass one or the other; `pane` wins if both are given.
|
||||
|
||||
As a pane (`pane`, from `expanded`) nothing is covered: the page renders the drawer after its
|
||||
list in an `expanded:flex expanded:items-start expanded:gap-6` row, the drawer sticks under the
|
||||
top of the viewport, the
|
||||
@@ -29,8 +48,8 @@
|
||||
sheet's open/close flow or tell whether it is transient or permanent
|
||||
(docs/reference/m3/components-actions-communication-containment.md § Side sheets →
|
||||
Accessibility) — so `with-close-button` is on by default, and `:with-close-button="false"` is
|
||||
ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane, which has
|
||||
neither.
|
||||
ignored where nothing else closes the sheet: Escape off, the scrim off, or a pane or a
|
||||
standard sheet, which have neither from `expanded`.
|
||||
|
||||
maryUI's API, kept: `title`, `subtitle`, `separator`, `with-close-button`, `close-on-escape`,
|
||||
`without-backdrop-close`, `right` (ignored; use `side`), and an `actions` slot. --}}
|
||||
@@ -48,6 +67,7 @@
|
||||
'pane' => false,
|
||||
'paneWidth' => '22.5rem',
|
||||
'paneCloseOnEscape' => false,
|
||||
'standard' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -55,9 +75,21 @@
|
||||
$id = $attributes->get('id') ?? 'material-sheet-'.substr(md5($model.'|'.$title), 0, 10);
|
||||
$start = $side === 'start';
|
||||
|
||||
// One or the other: a pane is the list-detail companion, a standard sheet is supplementary
|
||||
// content beside the primary content.
|
||||
$standard = $standard && ! $pane;
|
||||
|
||||
// Both stop being modal from `expanded`, so both watch the window for that width.
|
||||
$wide = $pane || $standard;
|
||||
|
||||
// The side-sheet specs table caps the sheet at 400dp, which is where `width` already starts;
|
||||
// a wider one is the modal sheet's to take, not the co-planar standard sheet's.
|
||||
$sheetWidth = $standard ? "min({$width}, 25rem)" : $width;
|
||||
|
||||
// M3 requires a close affordance; the prop can only ever add one, never take away the last
|
||||
// way out of the sheet.
|
||||
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || ($pane && ! $paneCloseOnEscape);
|
||||
// way out of the sheet. A standard sheet keeps no scrim and no trap from `expanded`, and
|
||||
// Escape leaves it open there, so it always draws one.
|
||||
$closeButton = $withCloseButton || ! $closeOnEscape || $withoutBackdropClose || $standard || ($pane && ! $paneCloseOnEscape);
|
||||
@endphp
|
||||
|
||||
<div
|
||||
@@ -65,7 +97,7 @@
|
||||
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
||||
wide: false,
|
||||
close() { this.open = typeof this.open === 'boolean' ? false : null; },
|
||||
@if ($pane)
|
||||
@if ($wide)
|
||||
init() {
|
||||
const query = window.matchMedia('(width >= 52.5rem)');
|
||||
this.wide = query.matches;
|
||||
@@ -79,6 +111,12 @@
|
||||
x-bind:class="! open && 'expanded:hidden'"
|
||||
class="expanded:sticky expanded:top-[calc(var(--material-safe-top,env(safe-area-inset-top))+1.25rem)] expanded:shrink-0 expanded:self-start"
|
||||
data-pane
|
||||
@elseif ($standard)
|
||||
{{-- A standard sheet spans the window's height beside the content and takes no room while
|
||||
closed; sticky, so it stays put as the page scrolls past it. --}}
|
||||
x-bind:class="! open && 'expanded:hidden'"
|
||||
class="expanded:sticky expanded:top-0 expanded:h-dvh expanded:shrink-0 expanded:self-start"
|
||||
data-standard
|
||||
@endif
|
||||
>
|
||||
<div
|
||||
@@ -86,7 +124,7 @@
|
||||
x-show="open"
|
||||
x-transition.opacity.duration.200ms
|
||||
@if (! $withoutBackdropClose) x-on:click="close()" @endif
|
||||
@class(['fixed inset-0 z-40 bg-scrim/32', 'expanded:hidden' => $pane])
|
||||
@class(['fixed inset-0 z-40 bg-scrim/32', 'expanded:hidden' => $wide])
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
@@ -95,24 +133,30 @@
|
||||
x-show="open"
|
||||
x-trap.inert.noscroll="open && ! wide"
|
||||
x-transition:enter="transition-[translate,opacity] duration-(--md-sys-motion-spatial-default-duration) ease-emphasized-decelerate"
|
||||
x-transition:enter-start="{{ $pane ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
|
||||
x-transition:enter-start="{{ $wide ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
|
||||
x-transition:enter-end="translate-x-0 opacity-100"
|
||||
x-transition:leave="transition-[translate,opacity] duration-(--md-sys-motion-effects-default-duration) ease-emphasized-accelerate"
|
||||
x-transition:leave-start="translate-x-0 opacity-100"
|
||||
x-transition:leave-end="{{ $pane ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
|
||||
x-transition:leave-end="{{ $wide ? ($start ? 'max-expanded:-translate-x-full expanded:opacity-0' : 'max-expanded:translate-x-full expanded:opacity-0') : ($start ? '-translate-x-full' : 'translate-x-full') }}"
|
||||
id="{{ $id }}"
|
||||
x-bind:role="wide ? 'region' : 'dialog'"
|
||||
x-bind:aria-modal="wide ? null : 'true'"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
|
||||
style="--sheet-width: {{ $width }}; --pane-width: {{ $paneWidth }}"
|
||||
style="--sheet-width: {{ $sheetWidth }}; --pane-width: {{ $paneWidth }}"
|
||||
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->class([
|
||||
'fixed top-[var(--material-safe-top,env(safe-area-inset-top))] bottom-0 z-50 flex w-full flex-col overflow-y-auto bg-surface-container-low p-6 text-on-surface shadow-elevation-1',
|
||||
'end-0 medium:rounded-s-corner-lg' => ! $start,
|
||||
'start-0 medium:rounded-e-corner-lg' => $start,
|
||||
'medium:w-(--sheet-width) medium:max-w-[calc(100vw-4rem)]',
|
||||
'expanded:relative expanded:top-0 expanded:z-auto expanded:max-h-[calc(100dvh-2.5rem-var(--material-safe-top,env(safe-area-inset-top)))] expanded:w-(--pane-width) expanded:max-w-none expanded:rounded-corner-lg expanded:bg-surface-container expanded:shadow-none' => $pane,
|
||||
// M3's standard side sheet from `expanded`: co-planar on `surface`, square, 0dp
|
||||
// elevation, the window's full height, with an outline-variant rule down the edge it
|
||||
// meets the content on — the divider its anatomy lists, in place of the scrim.
|
||||
'expanded:relative expanded:top-0 expanded:z-auto expanded:h-full expanded:rounded-corner-none expanded:bg-surface expanded:shadow-none' => $standard,
|
||||
'expanded:border-s expanded:border-outline-variant' => $standard && ! $start,
|
||||
'expanded:border-e expanded:border-outline-variant' => $standard && $start,
|
||||
$attributes->get('class'),
|
||||
]) }}
|
||||
>
|
||||
|
||||
@@ -5,15 +5,27 @@
|
||||
|
||||
`title` (or the slot) is the body-large headline; `overline` sits above it (label-small),
|
||||
`description` under it (body-medium, up to two lines). The leading element is one of `icon`,
|
||||
`avatar` (an image URL, or initials in primary-container) or `image` (a 56px thumbnail), or a
|
||||
`leading` slot (a checkbox, a switch). The trailing element is `trailing` text (label-small),
|
||||
`icon-right`, or an `end` slot for controls (a menu, a switch). One-, two- and three-line
|
||||
`avatar` (an image URL, or initials in primary-container), `image` (a 56px thumbnail) or
|
||||
`video`, or a `leading` slot (a checkbox, a switch). The trailing element is `trailing` text
|
||||
(label-small), `icon-right`, or an `end` slot for controls (a menu, a switch). One-, two- and three-line
|
||||
heights (56, 72, 88px) follow from what is given, 16px between the item and what leads or
|
||||
trails it, and a three-line item top-aligns rather than centring — M3 aligns an item middle
|
||||
"by default, top-aligned if the item is 88dp+ or has 3+ lines of text". Its icons are 24px,
|
||||
or 20px in a `segmented` list, which is M3 Expressive's (ListTokens, androidx Compose
|
||||
Material 3, Apache-2.0).
|
||||
|
||||
`video` is M3's leading media in its landscape size — a poster URL, or a `<x-slot:video>`
|
||||
holding a `<video>` or a thumbnail with a play badge. ListTokens gives it two: 100×56px in a
|
||||
two-line item and 114×64px in a three-line one (`LeadingVideoSmall` 56dp × 100dp,
|
||||
`LeadingVideoLarge` 64dp × 114dp — height first, both 16:9), so a video always lifts the item
|
||||
to at least the 72px two-line height, where 56 + 2×8 comes to 72 exactly, and to 88 with the
|
||||
large one, where 64 + 2×12 comes to 88. It takes the small corner the leading image takes
|
||||
(`ItemLeadingImageShape` = CornerSmall; the site publishes no separate video shape) and sits
|
||||
in the leading slot, 16px in from the edge like every other leading element. M3's slot model
|
||||
asks that the leading slot stay narrower than the content slot, so give an item with a large
|
||||
video the room its text needs
|
||||
(docs/reference/m3/components-actions-communication-containment.md § Lists → Anatomy, Specs).
|
||||
|
||||
`link` makes the whole item the link. Otherwise, to make it open something while its trailing
|
||||
controls keep their own presses, give it `data-list-row` and put `data-list-open` on the one
|
||||
control that opens — see resources/js/list-rows.js. `selected` (true) is M3's selected item,
|
||||
@@ -33,6 +45,7 @@
|
||||
'icon' => null,
|
||||
'avatar' => null,
|
||||
'image' => null,
|
||||
'video' => null,
|
||||
'trailing' => null,
|
||||
'iconRight' => null,
|
||||
'link' => null,
|
||||
@@ -53,6 +66,15 @@
|
||||
@php
|
||||
$isLink = filled($link) && ! $disabled;
|
||||
$lines = (filled($overline) ? 1 : 0) + (filled($description) ? 1 : 0);
|
||||
|
||||
// `video` is either a poster URL or a `<x-slot:video>`; a slot is an object, which `filled()`
|
||||
// would call full even when it holds nothing.
|
||||
$hasVideo = $video instanceof \Illuminate\View\ComponentSlot ? $video->isNotEmpty() : filled($video);
|
||||
|
||||
// The tallest element sets the item's height: the small video needs the 72px two-line row,
|
||||
// and the large one goes with the 88px three-line item M3 draws it in.
|
||||
$videoLarge = $hasVideo && $lines === 2;
|
||||
$lines = $hasVideo ? max($lines, 1) : $lines;
|
||||
$initials = filled($avatar) && ! str_contains((string) $avatar, '/') && ! str_contains((string) $avatar, '.');
|
||||
$option = $selectable || $selection !== null;
|
||||
$check = $option && $selected && blank($iconRight);
|
||||
@@ -82,6 +104,18 @@
|
||||
>
|
||||
@isset($leading)
|
||||
<div class="flex shrink-0 items-center">{{ $leading }}</div>
|
||||
@elseif ($hasVideo)
|
||||
<div @class([
|
||||
'shrink-0 overflow-hidden rounded-corner-sm bg-surface-container-highest *:size-full *:object-cover',
|
||||
'h-16 w-28.5' => $videoLarge,
|
||||
'h-14 w-25' => ! $videoLarge,
|
||||
])>
|
||||
@if ($video instanceof \Illuminate\View\ComponentSlot)
|
||||
{{ $video }}
|
||||
@else
|
||||
<img src="{{ $video }}" alt="" />
|
||||
@endif
|
||||
</div>
|
||||
@elseif ($avatar)
|
||||
@if ($initials)
|
||||
<span class="grid size-10 shrink-0 place-items-center rounded-corner-full bg-primary-container type-title-md text-on-primary-container" aria-hidden="true">{{ $avatar }}</span>
|
||||
|
||||
Reference in New Issue
Block a user