diff --git a/resources/css/components.css b/resources/css/components.css index 8cca514c..35e33bd1 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -63,5 +63,6 @@ @import './components/list.css'; @import './components/modal.css'; @import './components/drawer.css'; +@import './components/bottom-sheet.css'; /* Navigation */ diff --git a/resources/css/components/bottom-sheet.css b/resources/css/components/bottom-sheet.css new file mode 100644 index 00000000..e5abe397 --- /dev/null +++ b/resources/css/components/bottom-sheet.css @@ -0,0 +1,154 @@ +/* + * ``: M3's modal and standard bottom sheets (SheetBottomTokens.kt, androidx + * Compose Material 3, Apache-2.0; docs/reference/m3/ + * components-actions-communication-containment.md § Bottom sheets → Specs). + * + * [data-md-bottom-sheet] the wrapper; data-md-open, data-md-standard + * [data-md-bottom-sheet-scrim] modal only + * [data-md-bottom-sheet-probe] hidden, measures a stop's px height for a drag to settle on + * [data-md-bottom-sheet-panel] surface-container-low, extra-large top corners, elevation 1; + * data-md-preset while `heights`/`snap` gives it stops + * [data-md-bottom-sheet-handle] the drag target and M3's 48dp hit area (C-01) + * [data-md-bottom-sheet-grip] the 32×4px bar, the shared classes' touch target + * [data-md-bottom-sheet-announce] a live region, only while stops exist + * [data-md-bottom-sheet-body], [data-md-bottom-sheet-title], [data-md-bottom-sheet-actions] + * + * 640px max width, centred; 28px top corners only (`DockedContainerShape` = CornerExtraLargeTop); + * 50dvh default open height under a ceiling of the screen less M3's 72dp top margin (C-19). It + * rises on emphasized decelerate and only falls on emphasized accelerate — the asymmetric timing a + * more specific selector for the open state supplies, since a CSS transition always takes its + * duration and easing from the *after-change* style (the technique resources/css/components/ + * drawer.css also uses). `data-md-preset` adds the height-between-stops transition on the spatial + * spring, independent of the open/close slide. + * + * The handle's own 22px top/bottom padding (`SheetDefaults.kt`'s `DragHandleVerticalPadding`) is a + * literal — 22 is not on the measurement scale — matching alert.css's 12px for the same reason. + * The grip button draws the shared `md-focus-ring` and `md-touch-target` classes rather than a + * hand-rolled ring: its own visible bar is not a smaller indicator drawn inside a bigger box those + * classes cannot reach (unlike the datepicker's day) — the box they draw *is* the button's whole + * hit area, so nothing here refines them further. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-bottom-sheet-scrim] { + position: fixed; + inset: 0; + z-index: 40; + background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent); + transition-property: opacity, display; + transition-duration: var(--md-sys-motion-effects-default-duration); + transition-timing-function: var(--md-sys-motion-effects-default); + transition-behavior: allow-discrete; + } + + @starting-style { + [data-md-bottom-sheet-scrim] { + opacity: 0; + } + } + + [data-md-bottom-sheet-probe] { + position: fixed; + inset-inline-start: 0; + top: 0; + width: 0; + visibility: hidden; + pointer-events: none; + } + + [data-md-bottom-sheet-panel] { + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 50; + display: flex; + width: 100%; + max-width: 640px; + margin-inline: auto; + flex-direction: column; + max-height: var(--sheet-max-height); + touch-action: pan-y; + border-radius: var(--md-sys-shape-corner-xl) var(--md-sys-shape-corner-xl) var(--md-sys-shape-corner-none) var(--md-sys-shape-corner-none); + background-color: var(--md-sys-color-surface-container-low); + color: var(--md-sys-color-on-surface); + box-shadow: var(--md-sys-elevation-1); + padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom)); + translate: 0 100%; + transition-property: translate, display; + transition-duration: var(--md-sys-motion-effects-default-duration); + transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate); + transition-behavior: allow-discrete; + } + + [data-md-bottom-sheet][data-md-open] [data-md-bottom-sheet-panel] { + translate: 0 0; + transition-duration: var(--md-sys-motion-spatial-default-duration); + transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate); + } + + @starting-style { + [data-md-bottom-sheet][data-md-open] [data-md-bottom-sheet-panel] { + translate: 0 100%; + } + } + + /* With stops the panel is the height of its current one, moving between them on the spatial + spring, independent of the translate that opens and closes the sheet. */ + [data-md-bottom-sheet-panel][data-md-preset] { + height: var(--sheet-max-height); + transition-property: translate, height, display; + transition-duration: var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration); + transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate), var(--md-sys-motion-spatial-default), var(--md-sys-motion-easing-emphasized-accelerate); + } + + [data-md-bottom-sheet][data-md-open] [data-md-bottom-sheet-panel][data-md-preset] { + transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration); + transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate), var(--md-sys-motion-spatial-default), var(--md-sys-motion-easing-emphasized-decelerate); + } + + [data-md-bottom-sheet-handle] { + display: flex; + flex-shrink: 0; + justify-content: center; + padding-block: 22px; + cursor: grab; + } + + [data-md-bottom-sheet-handle]:active { + cursor: grabbing; + } + + [data-md-bottom-sheet-grip] { + width: 32px; + height: 4px; + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-sys-color-on-surface-variant); + } + + [data-md-bottom-sheet-body] { + min-height: 0; + flex: 1 1 0%; + overflow-y: auto; + padding-inline: var(--md-sys-measurement-space300); + padding-bottom: var(--md-sys-measurement-space300); + } + + [data-md-bottom-sheet-title] { + margin-bottom: var(--md-sys-measurement-space200); + font: var(--md-sys-typescale-title-lg); + letter-spacing: var(--md-sys-typescale-title-lg-tracking); + } + + [data-md-bottom-sheet-actions] { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--md-sys-measurement-space100); + padding-inline: var(--md-sys-measurement-space300); + padding-bottom: var(--md-sys-measurement-space300); + } +} diff --git a/resources/css/components/menu.css b/resources/css/components/menu.css index 65e1bd56..0ad92292 100644 --- a/resources/css/components/menu.css +++ b/resources/css/components/menu.css @@ -38,6 +38,7 @@ @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; @import './icon.css'; +@import './bottom-sheet.css'; @layer material.components { [data-md-menu] { diff --git a/resources/js/bottom-sheet.js b/resources/js/bottom-sheet.js index 015d323e..d7e23ed8 100644 --- a/resources/js/bottom-sheet.js +++ b/resources/js/bottom-sheet.js @@ -111,7 +111,7 @@ window.materialBottomSheet = (standard = false, presets = {}) => ({ dragStart(event) { const sheet = this.$refs.sheet - const onHandle = event.target.closest('[data-drag-handle]') + const onHandle = event.target.closest('[data-md-bottom-sheet-handle]') const atTop = this.$refs.body.scrollTop <= 0 if (event.button !== 0 || (!onHandle && !atTop) || event.target.closest('input, textarea, select, [contenteditable]')) { diff --git a/resources/views/components/bottom-sheet.blade.php b/resources/views/components/bottom-sheet.blade.php index 58bbf498..b67a897a 100644 --- a/resources/views/components/bottom-sheet.blade.php +++ b/resources/views/components/bottom-sheet.blade.php @@ -33,9 +33,19 @@ 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). --}} + The handle's wrapper is the drag target and M3's 48dp hit area: the button's own visual bar is + 32×4px, and `md-touch-target` (foundation/interaction.css) extends it, while the wrapper's own + 22px top/bottom padding (`SheetDefaults.kt`'s `DragHandleVerticalPadding`) gives the row its + M3-specified height regardless — 4 + 22 + 22 = 48px (C-01). The button renders the shared + `md-focus-ring` and `md-touch-target` classes rather than a hand-rolled ring: unlike the + datepicker's day, its own visible bar is not a smaller indicator drawn inside a bigger box the + classes cannot reach — the box the classes draw is the button's whole hit area. + + The root renders `data-md-bottom-sheet` (data-md-open, data-md-standard); the parts are + `data-md-bottom-sheet-scrim`, `-panel` (data-md-preset while stops exist), `-handle` (the drag + target), `-grip` (the 32×4px bar), `-announce`, `-body`, `-title`, `-actions` — drawn by + resources/css/components/bottom-sheet.css. `` renders this component + for its compact presentation. --}} @props([ 'title' => null, @@ -89,15 +99,18 @@ @if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif }" x-on:keydown.window.escape="if (open && ! standard) close()" + x-bind:data-md-open="open ? '' : null" + data-md-bottom-sheet + @if ($standard) data-md-standard @endif > @unless ($standard) - + @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. --}} - + @endif diff --git a/tests/Browser/ActionsTest.php b/tests/Browser/ActionsTest.php index 69579742..f7b4bdf3 100644 --- a/tests/Browser/ActionsTest.php +++ b/tests/Browser/ActionsTest.php @@ -799,8 +799,10 @@ it('filters a menu\'s items as its field is typed into, moves the highlight with it('opens a sheet-at-compact menu below 600px, focused on its first item, and the popover from 600px', function () { $trigger = '#menus button:has-text("Photo")'; - // The sheet is , still x-show-based (not rewritten in this batch), so its - // openness shows as visibility, not the Popover API's :popover-open. + // The sheet is , rewritten (plan step 36) but still x-show-driven — its CSS + // now animates open/close through @starting-style and transition-behavior: allow-discrete + // rather than Alpine's x-transition classes, but x-show still owns the element's display, so + // its openness shows as visibility, not the Popover API's :popover-open. $dialog = '[role="dialog"][aria-label="Photo actions"]'; $addToAlbum = '#menus [role="menuitem"]:has-text("Add to album")'; @@ -847,8 +849,8 @@ it('filters in the sheet too, and a Livewire render keeps it open with the field $page->script('window.eval("Livewire.first().touch()")'); - // The sheet is , still x-show-based, so open shows as visible, not - // :popover-open. + // The sheet is , still x-show-driven (see the note above), so open shows as + // visible, not :popover-open. $page->assertSeeIn('#renders', '1') ->assertVisible('[role="dialog"][aria-label="Assign to"]') ->assertScript(focused("getAttribute('aria-label') === 'Find a person'")) diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index 1f1c297a..389e86c0 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -183,7 +183,7 @@ it('slides a side sheet in, traps the page, and closes on the scrim', function ( ->assertScript("getComputedStyle({$sheet}).display !== 'none'") ->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null"); - // This sheet's own scrim: the standard side sheet above it on the page is a `[data-sheet]` too. + // This sheet's own scrim: the standard side sheet above it on the page is a `[data-md-drawer]` too. $page->script("{$sheet}.parentElement.querySelector(':scope > [aria-hidden=\"true\"]').click()"); $page->assertScript("getComputedStyle({$sheet}).display === 'none'") @@ -200,7 +200,7 @@ it('dismisses a bottom sheet dragged down past a quarter of its height', functio $page->script(<< { - const handle = {$sheet}.querySelector('[data-drag-handle]'); + const handle = {$sheet}.querySelector('[data-md-bottom-sheet-handle]'); const box = handle.getBoundingClientRect(); const x = box.x + box.width / 2, y = box.y + box.height / 2; handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 })); @@ -212,6 +212,259 @@ it('dismisses a bottom sheet dragged down past a quarter of its height', functio $page->assertScript("getComputedStyle({$sheet}).display === 'none'"); }); +class NestedDialogProbe extends Component +{ + public bool $outer = false; + + public bool $inner = false; + + public function render(): string + { + return <<<'BLADE' +
+ + + +

Outer body, long enough to scroll on its own: line one, line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve.

+ + + + +

Inner body, long enough to scroll on its own, independent of the outer dialog it opened from: line one, line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve.

+
+
+
+ BLADE; + } +} + +function nestedDialogProbe() +{ + Livewire::component('nested-dialog-probe', NestedDialogProbe::class); + + Route::middleware('web')->get('/nested-dialog-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + return visit('/nested-dialog-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + +it('marks a scrolling dialog\'s divider at the edge with more content, and a nested dialog keeps its own marks', function () { + $page = nestedDialogProbe(); + + $page->click('button:has-text("Open outer")'); + + $outer = "[...document.querySelectorAll('dialog[open]')].find((d) => d.textContent.includes('Outer'))"; + $outerHead = "{$outer}.querySelector('[data-md-modal-head]')"; + $outerBody = "{$outer}.querySelector('[data-md-modal-body]')"; + + // Scrolled to the top: nothing hidden above, so the head's rule stays off. + $page->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'"); + + $page->click('button:has-text("Open inner")'); + + $inner = "[...document.querySelectorAll('dialog[open]')].find((d) => d.textContent.includes('Inner'))"; + $innerHead = "{$inner}.querySelector('[data-md-modal-head]')"; + $innerBody = "{$inner}.querySelector('[data-md-modal-body]')"; + + $page->assertScript("getComputedStyle({$innerHead}, '::after').opacity === '0'"); + + $page->script("{$innerBody}.scrollTop = {$innerBody}.scrollHeight"); + + // The inner dialog's own body scrolled to its end marks its own head; the outer dialog, whose + // body was never touched, keeps its own mark off — each dialog watches only its own scroll. + $page->assertScript("getComputedStyle({$innerHead}, '::after').opacity === '1'") + ->assertScript("getComputedStyle({$outerHead}, '::after').opacity === '0'"); +}); + +it('shows a full-screen dialog\'s 56px phone bar below 600px, and the basic dialog above it', function () { + $page = containment()->resize(400, 800); + + $page->click('#containment button:has-text("Full screen on a phone")'); + + $dialog = "document.querySelector('#containment dialog[open]')"; + $bar = "{$dialog}.querySelector('[data-md-modal-bar]')"; + + $page->assertScript("getComputedStyle({$bar}).display !== 'none'") + ->assertScript("Math.round({$bar}.getBoundingClientRect().height) === 56"); + + $page->resize(900, 800) + ->assertScript("getComputedStyle({$bar}).display === 'none'"); +}); + +it('draws separator dividers whatever the scroll, on a dialog that fits without scrolling', function () { + $page = containment(); + + $page->click('#containment button:has-text("Always divided (separator)")'); + + $dialog = "document.querySelector('#containment dialog[open]')"; + $head = "{$dialog}.querySelector('[data-md-modal-head]')"; + $actions = "{$dialog}.querySelector('[data-md-modal-actions]')"; + $body = "{$dialog}.querySelector('[data-md-modal-body]')"; + + // The body fits without scrolling, so an unmarked dialog would show neither rule. + $page->assertScript("{$body}.scrollHeight <= {$body}.clientHeight + 1") + ->assertScript("getComputedStyle({$head}, '::after').opacity === '1'") + ->assertScript("getComputedStyle({$actions}, '::before').opacity === '1'"); +}); + +it('opens a dialog with no fade under reduced motion', function () { + $page = visit('/material/containment', ['reducedMotion' => 'reduce'])->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); + + $page->click('#containment button:has-text("Basic dialog")'); + + $dialog = "document.querySelector('#containment dialog[open]')"; + + // Reduced motion takes the spring's duration to zero, so the dialog is at its final opacity + // at once rather than mid-fade. + $page->assertScript("getComputedStyle({$dialog}).opacity === '1'"); +}); + +class TextOnlyDialogProbe extends Component +{ + public bool $open = false; + + public function render(): string + { + return <<<'BLADE' +
+ + + +

Line one of a body with nothing else focusable inside it, so the dialog hands its first focus to the scrolling body itself: line two, line three, line four, line five, line six, line seven, line eight, line nine, line ten, line eleven, line twelve, line thirteen, line fourteen, line fifteen.

+
+
+ BLADE; + } +} + +function textOnlyDialogProbe() +{ + Livewire::component('text-only-dialog-probe', TextOnlyDialogProbe::class); + + Route::middleware('web')->get('/text-only-dialog-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + return visit('/text-only-dialog-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + +it('gives the scrolling body an inset focus ring when a text-only dialog opens from the keyboard', function () { + $page = textOnlyDialogProbe(); + + $page->script("document.querySelector('button').focus()"); + + $page->keys(':focus', 'Enter'); + + $body = "document.querySelector('dialog[open] [data-md-modal-body]')"; + + $page->assertScript("document.activeElement.matches('[data-md-modal-body]')") + ->assertScript("getComputedStyle({$body}).outlineStyle === 'solid'") + ->assertScript("getComputedStyle({$body}).outlineWidth === '3px'") + ->assertScript("getComputedStyle({$body}).outlineOffset === '-3px'"); +}); + +it('cycles a bottom sheet\'s preset heights from its handle, announcing each, and closes from the last', function () { + $page = containment(); + + $page->click('#containment button:has-text("Bottom sheet with preset heights")'); + + $sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')"; + $handle = "{$sheet}.querySelector('[data-md-bottom-sheet-grip]')"; + $announce = "{$sheet}.querySelector('[data-md-bottom-sheet-announce]')"; + + $page->assertScript("getComputedStyle({$sheet}).display !== 'none'") + ->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'"); + + $page->click($handle); + + $page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '90dvh'") + ->assertScript("{$announce}.textContent.trim() === 'Height 3 of 3'"); + + // From the last stop, activating the handle closes the sheet, as a handle with no stops does. + $page->click($handle); + + $page->assertScript("getComputedStyle({$sheet}).display === 'none'"); +}); + +it('settles a dragged bottom sheet on the nearest preset height', function () { + $page = containment(); + + $page->click('#containment button:has-text("Bottom sheet with preset heights")'); + + $sheet = "document.querySelector('#containment [data-md-bottom-sheet-panel][data-md-preset]')"; + + $page->assertScript("getComputedStyle({$sheet}).display !== 'none'") + ->wait(0.5); + + // Drag up from the 50dvh stop a little way — not as far as 90dvh — and release: it settles + // back on 50dvh, the nearest stop, rather than creep on to the next one. + $page->script(<< { + const handle = {$sheet}.querySelector('[data-md-bottom-sheet-handle]'); + const box = handle.getBoundingClientRect(); + const x = box.x + box.width / 2, y = box.y + box.height / 2; + handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 })); + window.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y - 40 })); + window.dispatchEvent(new PointerEvent('pointerup', { clientX: x, clientY: y - 40 })); + })() + JS); + + $page->assertScript("{$sheet}.style.getPropertyValue('--sheet-stop').trim() === '50dvh'"); +}); + +it('is a standard side sheet from 840px, without a scrim or a focus trap, and the modal one below', function () { + $page = containment()->resize(1000, 800); + + $sheet = "document.querySelector('#containment aside[data-md-side]')"; + $scrim = "{$sheet}.parentElement.querySelector(':scope > [data-md-drawer-scrim]')"; + $toggle = "document.querySelector('#containment button:has-text(\"Show or hide the filters\")')"; + + // From 840px it opens co-planar (the example starts `open: true`): no scrim shown, and the + // rest of the page stays interactive. + $page->assertScript("getComputedStyle({$sheet}).position === 'relative'") + ->assertScript("getComputedStyle({$scrim}).display === 'none'") + ->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null") + ->assertScript("! {$sheet}.hasAttribute('inert')"); + + $page->script("{$toggle}.click()"); + + $page->assertScript("getComputedStyle({$sheet}).opacity === '0'"); + + // Below 840px the same sheet becomes the modal one: fixed, with a scrim, and the page inert. + $page->resize(700, 800) + ->script("{$toggle}.click()"); + + $page->assertScript("getComputedStyle({$sheet}).position === 'fixed'") + ->assertScript("getComputedStyle({$scrim}).display !== 'none'") + ->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null"); +}); + it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () { $page = containment(); diff --git a/tests/Feature/Components/ContainmentStylesheetsTest.php b/tests/Feature/Components/ContainmentStylesheetsTest.php index 81fd783c..3bc95b9e 100644 --- a/tests/Feature/Components/ContainmentStylesheetsTest.php +++ b/tests/Feature/Components/ContainmentStylesheetsTest.php @@ -23,6 +23,7 @@ dataset('containment components', [ 'list-item', 'modal', 'drawer', + 'bottom-sheet', ]); /** diff --git a/tests/Feature/Components/MenuTest.php b/tests/Feature/Components/MenuTest.php index ba310015..8d41c906 100644 --- a/tests/Feature/Components/MenuTest.php +++ b/tests/Feature/Components/MenuTest.php @@ -306,7 +306,7 @@ it('opens a sheet-at-compact menu\'s items in a modal bottom sheet as well as th ->toContain('aria-modal="true"') ->toContain('x-trap.inert.noscroll="open"') ->toContain('aria-label="Photo actions"') - ->toContain('rounded-t-corner-xl bg-surface-container-low') + ->toContain('data-md-bottom-sheet-panel') // The same slot, drawn again as a menu: the items keep their roles and their actions. ->toMatch("/
and(substr_count($html, 'role="menuitem"'))->toBe(4) diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 8568ebba..5849f45a 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -200,18 +200,19 @@ it('slides a side sheet in from either edge', function () { it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () { expect((string) $this->blade('Body')) ->toContain('...materialBottomSheet(false, JSON.parse(') - ->toContain('bg-scrim/32') + ->toContain('data-md-bottom-sheet-scrim') ->toContain('x-trap.inert.noscroll="open"') - ->toContain('rounded-t-corner-xl bg-surface-container-low') - ->toContain('data-drag-handle') + ->toContain('data-md-bottom-sheet-panel') + ->toContain('data-md-bottom-sheet-handle') + ->toContain('data-md-bottom-sheet-grip') ->toContain('aria-modal="true"') - ->toContain('py-5.5') - ->toContain('touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant ') + ->toContain('md-focus-ring md-touch-target') ->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))') ->and((string) $this->blade('Body')) ->toContain('...materialBottomSheet(true, JSON.parse(') - ->not->toContain('bg-scrim/32') + ->not->toContain('data-md-bottom-sheet-scrim') ->not->toContain('aria-modal') + ->toContain('data-md-standard') ->and((string) $this->blade('Body')) ->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))'); }); @@ -258,12 +259,13 @@ it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', fun expect($html) ->toContain('--sheet-stop: 50dvh; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))') - ->toContain('h-(--sheet-max-height) transition-[height]') + ->toContain('data-md-preset') ->toContain('x-ref="probe"') + ->toContain('data-md-bottom-sheet-probe') ->toContain('aria-label="Change the sheet height"') ->toContain('x-bind:aria-label="handleLabel"') ->toContain('x-on:click="activate()"') - ->toContain('') + ->toMatch('/<\/span>/') ->toContain('25dvh') ->toContain('90dvh') ->toContain('Height 2 of 3') @@ -275,6 +277,16 @@ it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', fun ->and((string) $this->blade('Body')) ->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))') ->not->toContain('--sheet-stop') - ->not->toContain('sr-only') + ->not->toContain('data-md-preset') ->toContain('aria-label="Close"'); }); + +it('draws the bottom sheet from a stylesheet imported from the containment block and by the menu', function () { + expect(File::get(__DIR__.'/../../../resources/css/components.css')) + ->toContain("@import './components/bottom-sheet.css';") + ->and(File::get(__DIR__.'/../../../resources/css/components/menu.css')) + ->toContain("@import './bottom-sheet.css';") + ->and(File::get(__DIR__.'/../../../resources/js/bottom-sheet.js')) + ->toContain('data-md-bottom-sheet-handle') + ->not->toContain('data-drag-handle'); +});