Bind the collapse's open state through wire:model and x-model

An application keeps a disclosure's state in a Livewire property
(`<x-collapse wire:model="fineTuning">`) or an Alpine one. Either binding
now works both ways: toggling the details writes the property, and the
property changing, in an action or in Alpine, opens or closes it.

`wire:model` is entangled as the dialog and sheets entangle theirs, and
taken off the element. The server renders `open` from the property, so
the first paint matches it. `x-model` binds through `x-modelable`, with
`open` as the first paint until Alpine starts. The state is named
`collapseOpen` so it never hides an `open` that a dialog in the slot reads
from a scope around it.

Without a binding the element has no Alpine and renders as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 18:10:59 +02:00
co-authored by Claude Opus 5
parent 973cc0b68c
commit e63c0f684c
5 changed files with 205 additions and 3 deletions
+104
View File
@@ -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'
<div class="space-y-4 p-4">
<p>fine tuning: <span id="fine-tuning">{{ var_export($fineTuning, true) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-button label="Re-render" wire:click="touch" />
<x-button label="Open from the server" wire:click="expand" />
<x-button label="Close from the server" wire:click="collapse" />
<x-collapse id="fine-tuning-collapse" title="Fine-tuning" wire:model="fineTuning">Zones and paces.</x-collapse>
<div x-data="{ advanced: false }">
<p>advanced: <span id="advanced" x-text="advanced"></span></p>
<x-button label="Close from Alpine" x-on:click="advanced = false" />
<x-collapse id="advanced-collapse" title="Advanced" x-model="advanced">Everything else.</x-collapse>
</div>
</div>
BLADE;
}
}
function collapseProbe()
{
Livewire::component('collapse-probe', CollapseProbe::class);
Route::middleware('web')->get('/collapse-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:collapse-probe />
@livewireScripts
</body>
</html>
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");
});