diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 54463cb9..06f979fe 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -490,6 +490,8 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (a divider u An M3 side sheet, bound like ``; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, and on a pane), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). `pane` (with `pane-width`, `22.5rem` — M3's 360dp fixed pane) turns it into a second pane from `expanded`, where M3 shows two panes: render it after the list inside `
`. Escape leaves a pane open unless `pane-close-on-escape`. Its body is a size container — lay out inside with `@md:` (a *container* query), never a window class. +`standard` is M3's other side-sheet variant, and it is not the same thing as `pane`. A **pane** is the list-detail companion: it shows what the list beside it has selected, 360dp wide on `surface-container` with a large corner. A **standard** sheet is supplementary content beside the primary content — filters, details, a list of actions: co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, the window's full height, an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it in the same `expanded:flex` row as a pane. + ### `` An M3 bottom sheet, bound like ``: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`50dvh` — M3 caps a modal sheet's initial position at half the screen; whatever you pass is held under a ceiling of the screen less M3's 72dp top margin), `actions` slot. diff --git a/resources/views/components/drawer.blade.php b/resources/views/components/drawer.blade.php index 7d4c2d47..f8dd493e 100644 --- a/resources/views/components/drawer.blade.php +++ b/resources/views/components/drawer.blade.php @@ -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
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 >
$pane]) + @class(['fixed inset-0 z-40 bg-scrim/32', 'expanded:hidden' => $wide]) aria-hidden="true" >
@@ -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'), ]) }} > diff --git a/resources/views/showcase/sections/containment.blade.php b/resources/views/showcase/sections/containment.blade.php index e29f1ff1..718b85d6 100644 --- a/resources/views/showcase/sections/containment.blade.php +++ b/resources/views/showcase/sections/containment.blade.php @@ -140,6 +140,27 @@
BLADE, + 'A standard side sheet: co-planar from expanded, modal below' => <<<'BLADE' +
+
+
+ +

+ From expanded (840px) the sheet beside this text is co-planar: no scrim, no focus trap, flat on the surface, divided from this column by a rule. Narrow the window and the same sheet becomes the modal one. +

+
+ + + + + + + + + +
+
+ BLADE, 'Sheets' => <<<'BLADE'
diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index af8632db..74f6494f 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -143,3 +143,27 @@ it('draws a modal bottom sheet with a drag handle, or a standard one without a s ->and((string) $this->blade('Body')) ->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))'); }); + +it('is M3\'s standard side sheet from expanded, and the modal one below', function () { + $html = (string) $this->blade('Body'); + + expect($html) + ->toContain('data-standard') + ->toContain('expanded:sticky expanded:top-0 expanded:h-dvh expanded:shrink-0 expanded:self-start') + ->toContain('expanded:relative expanded:top-0 expanded:z-auto expanded:h-full expanded:rounded-corner-none expanded:bg-surface expanded:shadow-none') + ->toContain('expanded:border-s expanded:border-outline-variant') + ->toContain('--sheet-width: min(25rem, 25rem)') + ->toContain("matchMedia('(width >= 52.5rem)')") + ->toContain('x-trap.inert.noscroll="open && ! wide"') + ->toContain('bg-scrim/32 expanded:hidden') + ->toContain('aria-label="Close"') + ->and((string) $this->blade('Body')) + ->toContain('expanded:border-e expanded:border-outline-variant') + ->toContain('--sheet-width: min(30rem, 25rem)') + ->and((string) $this->blade('Body')) + ->toContain('data-pane') + ->not->toContain('data-standard') + ->and((string) $this->blade('Body')) + ->not->toContain('data-standard') + ->not->toContain('expanded:hidden'); +});