diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 69a51eb6..679791ad 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -427,6 +427,15 @@ A card or list item that opens something is a **row**: `data-list-row` on it and A disclosure on native `
`: `` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph. +Bind the open state to a boolean, both ways, with `wire:model` (any modifiers; `.live` sends each toggle at once) or `x-model`: + +```blade + +
+``` + +Toggling writes the property; changing the property (in an action or in Alpine) opens or closes it. With `wire:model` the server renders it open or closed as the property is, so there is no flash, and `open` is ignored; with `x-model`, `open` is only the first paint until Alpine starts. The bound state is `collapseOpen` in the `
` scope. Without a binding there is no Alpine on it. + ### `` An M3 dialog on native ``. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope. diff --git a/resources/views/components/collapse.blade.php b/resources/views/components/collapse.blade.php index e641cc25..e1ed8913 100644 --- a/resources/views/components/collapse.blade.php +++ b/resources/views/components/collapse.blade.php @@ -6,7 +6,18 @@ `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 - it) or `filled` (a surface-container tile with a large corner). --}} + it) or `filled` (a surface-container tile with a large corner). + + Its open state can be bound, both ways: + - `wire:model` (any modifiers; `.live` tells the server at once) entangles it with a Livewire + boolean property. The server renders `open` from the property, so the first paint matches it + and `open` is ignored; toggling writes the property, and the property changing opens or + closes it. + - `x-model` binds an Alpine property through `x-modelable`; `open` is then only the first + paint, until Alpine starts and applies the property. + The state lives in `collapseOpen` on the `
`, a name kept clear of `open`, which a + dialog inside the slot may be reading from a scope around it. Without a binding there is no + Alpine on it at all. --}} @props([ 'title' => null, @@ -15,10 +26,26 @@ 'variant' => 'plain', ]) +@php + $model = $attributes->wire('model')->value() ?: null; + $bound = $model !== null || count($attributes->whereStartsWith('x-model')->getAttributes()) > 0; + $expanded = (bool) $open; + + if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) { + $expanded = (bool) data_get($component, $model); + } +@endphp +
class([ + @if ($bound) + x-data="{ collapseOpen: @if ($model !== null) @entangle($attributes->wire('model')) @else @js($expanded) @endif }" + @if ($model === null) x-modelable="collapseOpen" @endif + x-effect="$el.open = collapseOpen" + x-on:toggle="collapseOpen = $el.open" + @endif + @if ($expanded) open @endif + {{ $attributes->whereDoesntStartWith('wire:model')->class([ 'group/collapse [interpolate-size:allow-keywords]', 'rounded-corner-lg bg-surface-container' => $variant === 'filled', ]) }} diff --git a/resources/views/showcase/sections/containment.blade.php b/resources/views/showcase/sections/containment.blade.php index d9fc1063..cd03d3d6 100644 --- a/resources/views/showcase/sections/containment.blade.php +++ b/resources/views/showcase/sections/containment.blade.php @@ -55,6 +55,18 @@
LeftRight
BLADE, + 'Collapse bound to a property' => <<<'BLADE' +
+
+ + + advanced: +
+ + Bound with x-model here. In a Livewire view, wire:model="advanced" binds a boolean property the same way, and the page arrives with it open or closed as the property is. + +
+ BLADE, 'Dialogs' => <<<'BLADE'
diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index 5e76a92d..0c4c2562 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -41,6 +41,73 @@ class OverlayProbe extends Component } } +class CollapseProbe extends Component +{ + public bool $fineTuning = false; + + public int $renders = 0; + + public function touch(): void + { + $this->renders++; + } + + public function expand(): void + { + $this->fineTuning = true; + } + + public function collapse(): void + { + $this->fineTuning = false; + } + + public function render(): string + { + return <<<'BLADE' +
+

fine tuning: {{ var_export($fineTuning, true) }}

+

renders: {{ $renders }}

+ + + + + + Zones and paces. + +
+

advanced:

+ + Everything else. +
+
+ BLADE; + } +} + +function collapseProbe() +{ + Livewire::component('collapse-probe', CollapseProbe::class); + + Route::middleware('web')->get('/collapse-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + return visit('/collapse-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + function overlayProbe() { Livewire::component('overlay-probe', OverlayProbe::class); @@ -155,3 +222,40 @@ it('opens a row\'s opener from a press anywhere on the row, but not from its own $page->click('#containment [data-card][data-list-row] button:has-text("Copy link")') ->assertScript('window.__opened === 1'); }); + +it('binds a collapse to a Livewire property both ways', function () { + $collapse = "document.querySelector('#fine-tuning-collapse')"; + + $page = collapseProbe() + ->assertScript("{$collapse}.open === false"); + + $page->click('#fine-tuning-collapse summary') + ->assertScript("{$collapse}.open === true") + ->click('button:has-text("Re-render")') + ->assertSeeIn('#renders', '1') + ->assertSeeIn('#fine-tuning', 'true') + ->assertScript("{$collapse}.open === true"); + + $page->click('button:has-text("Close from the server")') + ->assertSeeIn('#fine-tuning', 'false') + ->assertScript("{$collapse}.open === false"); + + $page->click('button:has-text("Open from the server")') + ->assertSeeIn('#fine-tuning', 'true') + ->assertScript("{$collapse}.open === true"); +}); + +it('binds a collapse to an Alpine property both ways', function () { + $collapse = "document.querySelector('#advanced-collapse')"; + + $page = collapseProbe() + ->assertScript("{$collapse}.open === false"); + + $page->click('#advanced-collapse summary') + ->assertScript("{$collapse}.open === true") + ->assertSeeIn('#advanced', 'true'); + + $page->click('button:has-text("Close from Alpine")') + ->assertSeeIn('#advanced', 'false') + ->assertScript("{$collapse}.open === false"); +}); diff --git a/tests/Feature/Components/CollapseTest.php b/tests/Feature/Components/CollapseTest.php index 7988ad9a..11d9f24f 100644 --- a/tests/Feature/Components/CollapseTest.php +++ b/tests/Feature/Components/CollapseTest.php @@ -1,5 +1,8 @@ blade('Until the expiry.'); @@ -12,3 +15,50 @@ it('discloses on the native details element, kept open through a morph', functio ->toContain('Until the expiry.') ->toContain('group-open/collapse:rotate-180'); }); + +it('has no Alpine on it without a binding', function () { + $html = (string) $this->blade('Body'); + + expect($html) + ->toMatch('/not->toContain('x-data') + ->not->toContain('x-modelable') + ->not->toContain('x-on:toggle') + ->and((string) $this->blade('Body')) + ->toMatch('/blade('Body'); + + expect($html) + ->toMatch('/x-data="{ collapseOpen:\s*true\s*}"/') + ->toContain('x-modelable="collapseOpen"') + ->toContain('x-model="advanced"') + ->toContain('x-effect="$el.open = collapseOpen"') + ->toContain('x-on:toggle="collapseOpen = $el.open"') + ->toMatch('/\sopen\s/'); +}); + +it('entangles its open state with a Livewire property and renders open as the property is', function (bool $fineTuning) { + Livewire::component('collapse-probe', new class extends Component + { + public bool $fineTuning = false; + + public function render(): string + { + return '
Body
'; + } + }); + + $html = Livewire::test('collapse-probe', ['fineTuning' => $fineTuning])->html(); + + expect($html) + ->toContain(".entangle('fineTuning').live") + ->not->toContain('x-modelable') + ->not->toContain('wire:model'); + + preg_match('/]*>/', $html, $tag); + + expect(preg_match('/\sopen\s/', $tag[0]) === 1)->toBe($fineTuning); +})->with(['open' => true, 'closed' => false]);