Files
Andreas Reinhold / reiniandClaude Opus 5 9f46630352 Ease a collapse open and shut in Firefox and Safari too
collapse.css animated `<details>`' `::details-content` from `block-size: 0`
to `auto`, which needs `interpolate-size: allow-keywords` (Chrome 129 and
nothing else) and a `content-visibility` held through the close with
`allow-discrete` (not Firefox), so in Firefox and Safari the section
snapped open and shut. No stylesheet can do it there.

resources/js/collapse.js animates the `<details>` itself, the same way
in every engine: a Web Animation of its `block-size` from the height it
is drawn at to the height it is going to, on the fast spatial spring's
tokens, with `overflow: clip` and a `min-block-size` that keeps the
summary whole through the spring's overshoot, all inside the keyframes,
so nothing is left in `style`. Content below moves with it. Opening sets
`open` at once and grows the height; closing keeps `open` while the
height goes, marks `data-md-collapse-closing` so the chevron turns back
at the start, and drops `open` (and fires `toggle`) when the height has.
A press mid-way turns it round from the height it has reached. The CSS
animation is gone, so Chrome takes the same path and never animates
twice.

Routes: a press on the summary (pointer, Enter, Space) is taken over;
the Alpine and Livewire bindings call `materialCollapse()` from the
view's `x-effect` (its first call, as Alpine starts, sets the state at
once); a `name` group's open member closes on the same spring, its
`name` lifted for the close so the browser's exclusivity does not shut
it first and put back once closed. Anything else that sets `open`
(find-in-page, an application's own script) stays instant, as the
browser draws it, and is followed. Under reduced motion every route is
the browser's own.

Browser tests in Chrome, Firefox and Safari catch the height part-way
open and shut (and what is under it moving) from a press and from the
Alpine binding, the chevron turning at the start of a close, the clip
while it moves, Enter on the summary, a reversal mid-way, a name group,
and reduced motion. The four motion tests fail on main in Firefox and
Safari.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:00:23 +02:00

73 lines
3.1 KiB
PHP

{{-- A section that opens to show more: an FAQ answer, advanced settings.
Not an M3 component; built on the native `<details>` so it opens without script, keeps its
state through a Livewire morph (`wire:ignore.self` stops the server's HTML from closing it),
and is announced as a disclosure. Drawn in M3's terms: a title-medium `title` (or a
`heading` slot), an optional leading `icon`, a chevron that turns over on the spatial spring,
and a height that eases open and shut on the same spring in every engine
(`data-md-collapse`, resources/css/components/collapse.css, and resources/js/collapse.js,
which animates the `<details>` itself because only Chrome can animate its content's height
from CSS). `open` starts it open.
`variant`: `plain` (the default, on the surface around 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.
It renders the foundation's `md-state-layer` and `md-focus-ring` (foundation/interaction.css)
on the summary; nothing here copies their rules. --}}
@props([
'title' => null,
'icon' => null,
'open' => false,
'variant' => 'plain',
])
@php
$model = $attributes->wire('model')->value() ?: null;
$bound = $model !== null || count($attributes->whereStartsWith('x-model')->getAttributes()) > 0;
$expanded = (bool) $open;
$variant = $variant === 'filled' ? 'filled' : 'plain';
if ($model !== null && ($component = \Livewire\Livewire::current()) !== null) {
$expanded = (bool) data_get($component, $model);
}
@endphp
<details
wire:ignore.self
data-md-collapse
data-md-variant="{{ $variant }}"
@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="materialCollapse($el, collapseOpen)"
x-on:toggle="collapseOpen = $el.open"
@endif
@if ($expanded) open @endif
{{ $attributes->whereDoesntStartWith('wire:model') }}
>
<summary data-md-collapse-summary class="md-state-layer md-focus-ring">
@if ($icon)
<x-livewire-material::icon :name="$icon" data-md-collapse-icon />
@endif
<span data-md-collapse-title>{{ $heading ?? $title }}</span>
<x-livewire-material::icon name="expand_more" data-md-collapse-chevron />
</summary>
<div data-md-collapse-body>
{{ $slot }}
</div>
</details>