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:
co-authored by
Claude Opus 5
parent
973cc0b68c
commit
e63c0f684c
@@ -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 `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
|
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`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
|
||||||
|
<x-collapse title="Fine-tuning" wire:model="fineTuning">…</x-collapse>
|
||||||
|
<div x-data="{ advanced: false }"><x-collapse title="Advanced" x-model="advanced">…</x-collapse></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
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 `<details>` scope. Without a binding there is no Alpine on it.
|
||||||
|
|
||||||
### `<x-modal>`
|
### `<x-modal>`
|
||||||
|
|
||||||
An M3 dialog on native `<dialog>`. 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.
|
An M3 dialog on native `<dialog>`. 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.
|
||||||
|
|||||||
@@ -6,7 +6,18 @@
|
|||||||
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
|
`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
|
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
|
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 `<details>`, 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([
|
@props([
|
||||||
'title' => null,
|
'title' => null,
|
||||||
@@ -15,10 +26,26 @@
|
|||||||
'variant' => 'plain',
|
'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
|
||||||
|
|
||||||
<details
|
<details
|
||||||
wire:ignore.self
|
wire:ignore.self
|
||||||
@if ($open) open @endif
|
@if ($bound)
|
||||||
{{ $attributes->class([
|
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]',
|
'group/collapse [interpolate-size:allow-keywords]',
|
||||||
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
|
'rounded-corner-lg bg-surface-container' => $variant === 'filled',
|
||||||
]) }}
|
]) }}
|
||||||
|
|||||||
@@ -55,6 +55,18 @@
|
|||||||
<div class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
|
<div class="flex h-10 items-center gap-4"><span>Left</span><x-divider vertical /><span>Right</span></div>
|
||||||
</div>
|
</div>
|
||||||
BLADE,
|
BLADE,
|
||||||
|
'Collapse bound to a property' => <<<'BLADE'
|
||||||
|
<div x-data="{ advanced: false }" class="w-full space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<x-button label="Open" variant="tonal" x-on:click="advanced = true" />
|
||||||
|
<x-button label="Close" variant="tonal" x-on:click="advanced = false" />
|
||||||
|
<span class="type-body-md text-on-surface-variant">advanced: <span x-text="advanced"></span></span>
|
||||||
|
</div>
|
||||||
|
<x-collapse title="Advanced settings" icon="tune" variant="filled" x-model="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.
|
||||||
|
</x-collapse>
|
||||||
|
</div>
|
||||||
|
BLADE,
|
||||||
'Dialogs' => <<<'BLADE'
|
'Dialogs' => <<<'BLADE'
|
||||||
<div x-data="{ open: false }">
|
<div x-data="{ open: false }">
|
||||||
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
|
<x-button label="Basic dialog" variant="tonal" x-on:click="open = true" />
|
||||||
|
|||||||
@@ -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()
|
function overlayProbe()
|
||||||
{
|
{
|
||||||
Livewire::component('overlay-probe', OverlayProbe::class);
|
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")')
|
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
|
||||||
->assertScript('window.__opened === 1');
|
->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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
it('discloses on the native details element, kept open through a morph', function () {
|
it('discloses on the native details element, kept open through a morph', function () {
|
||||||
$html = (string) $this->blade('<x-collapse title="How long do links last?" icon="schedule" open variant="filled">Until the expiry.</x-collapse>');
|
$html = (string) $this->blade('<x-collapse title="How long do links last?" icon="schedule" open variant="filled">Until the expiry.</x-collapse>');
|
||||||
|
|
||||||
@@ -12,3 +15,50 @@ it('discloses on the native details element, kept open through a morph', functio
|
|||||||
->toContain('Until the expiry.')
|
->toContain('Until the expiry.')
|
||||||
->toContain('group-open/collapse:rotate-180');
|
->toContain('group-open/collapse:rotate-180');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('has no Alpine on it without a binding', function () {
|
||||||
|
$html = (string) $this->blade('<x-collapse title="Advanced" open>Body</x-collapse>');
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toMatch('/<details\s+wire:ignore.self\s+open\s+class="group\/collapse/')
|
||||||
|
->not->toContain('x-data')
|
||||||
|
->not->toContain('x-modelable')
|
||||||
|
->not->toContain('x-on:toggle')
|
||||||
|
->and((string) $this->blade('<x-collapse title="Advanced">Body</x-collapse>'))
|
||||||
|
->toMatch('/<details\s+wire:ignore.self\s+class="group\/collapse/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds its open state through x-model, drawing the open prop until Alpine starts', function () {
|
||||||
|
$html = (string) $this->blade('<x-collapse title="Advanced" x-model="advanced" open>Body</x-collapse>');
|
||||||
|
|
||||||
|
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 '<div><x-collapse title="Fine-tuning" wire:model.live="fineTuning" :open="! $fineTuning">Body</x-collapse></div>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$html = Livewire::test('collapse-probe', ['fineTuning' => $fineTuning])->html();
|
||||||
|
|
||||||
|
expect($html)
|
||||||
|
->toContain(".entangle('fineTuning').live")
|
||||||
|
->not->toContain('x-modelable')
|
||||||
|
->not->toContain('wire:model');
|
||||||
|
|
||||||
|
preg_match('/<details[^>]*>/', $html, $tag);
|
||||||
|
|
||||||
|
expect(preg_match('/\sopen\s/', $tag[0]) === 1)->toBe($fineTuning);
|
||||||
|
})->with(['open' => true, 'closed' => false]);
|
||||||
|
|||||||
Reference in New Issue
Block a user