diff --git a/resources/css/components.css b/resources/css/components.css index 48216f2d..6abf51ab 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -57,5 +57,6 @@ /* Containment */ @import './components/divider.css'; +@import './components/collapse.css'; /* Navigation */ diff --git a/resources/css/components/collapse.css b/resources/css/components/collapse.css index 0ba2d2cf..492f2286 100644 --- a/resources/css/components/collapse.css +++ b/resources/css/components/collapse.css @@ -1,22 +1,42 @@ /* - * ``'s height, eased open. + * : not an M3 component — M3 has expandable list items and menu expansion but no + * standalone disclosure — built on the native `
` so it opens without script, keeps its + * state through a Livewire morph, and is announced as a disclosure by the browser. Judged against + * foundations rather than any M3 component spec (docs/audits/m3-alignment/containment.md, C-24). * - * The native `
` opens and closes by itself; what it cannot do on its own is animate, so - * the component sets `interpolate-size: allow-keywords` (which makes `0` → `auto` an animatable - * pair) and this transitions `block-size` on `::details-content`, the pseudo-element that holds - * everything after the ``. `content-visibility` goes with it, `allow-discrete`, so the - * content stays rendered while it closes rather than vanishing on the first frame. + * `interpolate-size: allow-keywords` makes `
`'s `0 -> auto` block-size an animatable pair, + * so `::details-content` (the pseudo-element that holds everything after the summary) can transition + * `block-size` on the fast spatial spring, the same one that turns the chevron; `content-visibility` + * goes with it, `allow-discrete`, so the content stays rendered while it closes rather than + * vanishing on the first frame. Under reduced motion the duration token is zero + * (tokens/motion.css), so the section snaps open with nothing else to do. * - * The fast spatial spring, as the chevron beside it: M3's spatial track is for anything that - * changes size or position. Under reduced motion the duration token is zero - * (resources/css/tokens/motion.css), so the section snaps open with nothing else to do. + * `data-md-variant="filled"` is a surface-container tile with a large corner; the summary inherits + * it (`border-radius: inherit`) so the shared `md-state-layer`'s own `::before`, which also + * inherits, draws inside the same shape. `md-state-layer` and `md-focus-ring` + * (foundation/interaction.css) sit on the summary; this file only refines their target — the + * 48px summary height, the icon and chevron colour — never their opacities or ring. * - * Not an M3 component — M3 has expandable list items and menu expansion, no standalone disclosure - * — so the geometry is the library's and only the motion tokens are M3's. + * Icon-to-title and title-to-chevron gaps are 12px, midway between two spacing steps + * (`space100` 8px, `space200` 16px); written as their average rather than a bare literal so the + * value still comes from the scale. */ -@layer components { - [data-collapse]::details-content { +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; + +@layer material.components { + [data-md-collapse] { + interpolate-size: allow-keywords; + } + + [data-md-collapse][data-md-variant='filled'] { + border-radius: var(--md-sys-shape-corner-lg); + background-color: var(--md-sys-color-surface-container); + } + + [data-md-collapse]::details-content { block-size: 0; overflow: hidden; transition: @@ -24,7 +44,56 @@ content-visibility var(--md-sys-motion-spatial-fast-duration) allow-discrete; } - [data-collapse][open]::details-content { + [data-md-collapse][open]::details-content { block-size: auto; } + + [data-md-collapse-summary] { + display: flex; + min-block-size: var(--md-sys-measurement-space600); + align-items: center; + gap: calc((var(--md-sys-measurement-space100) + var(--md-sys-measurement-space200)) / 2); + border-radius: inherit; + cursor: pointer; + list-style-type: none; + font: var(--md-sys-typescale-title-md); + letter-spacing: var(--md-sys-typescale-title-md-tracking); + + &::-webkit-details-marker { + display: none; + } + } + + [data-md-collapse][data-md-variant='filled'] [data-md-collapse-summary] { + padding-inline: var(--md-sys-measurement-space200); + } + + [data-md-collapse-icon] { + color: var(--md-sys-color-on-surface-variant); + } + + [data-md-collapse-title] { + min-width: 0; + flex: 1 1 0%; + } + + [data-md-collapse-chevron] { + color: var(--md-sys-color-on-surface-variant); + transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast); + } + + [data-md-collapse][open] [data-md-collapse-chevron] { + rotate: 180deg; + } + + [data-md-collapse-body] { + padding-block: var(--md-sys-measurement-space50) var(--md-sys-measurement-space200); + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-body-md); + letter-spacing: var(--md-sys-typescale-body-md-tracking); + } + + [data-md-collapse][data-md-variant='filled'] [data-md-collapse-body] { + padding-inline: var(--md-sys-measurement-space200); + } } diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css index d030938a..7e81645d 100644 --- a/resources/css/tailwind.css +++ b/resources/css/tailwind.css @@ -19,7 +19,6 @@ /* Containment — leaving in step 36 */ @import './components/list.css'; -@import './components/collapse.css'; @import './components/dialog.css'; /* Navigation — leaving in step 36 */ diff --git a/resources/views/components/collapse.blade.php b/resources/views/components/collapse.blade.php index ec7ef45e..fe11a05e 100644 --- a/resources/views/components/collapse.blade.php +++ b/resources/views/components/collapse.blade.php @@ -4,10 +4,10 @@ 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 — where the browser supports animating `details` content (`interpolate-size`) — a height - that eases open on the same spring (`data-collapse`, resources/css/components/collapse.css). - `open` starts it open. `variant`: `plain` (the default, on the surface around - it) or `filled` (a surface-container tile with a large corner). + and a height that eases open on the same spring + (`data-md-collapse`, resources/css/components/collapse.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 @@ -18,7 +18,10 @@ 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. --}} + 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, @@ -31,6 +34,7 @@ $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); @@ -39,7 +43,8 @@
whereDoesntStartWith('wire:model')->class([ - 'group/collapse [interpolate-size:allow-keywords]', - 'rounded-corner-lg bg-surface-container' => $variant === 'filled', - ]) }} + {{ $attributes->whereDoesntStartWith('wire:model') }} > - $variant === 'filled', - ])> + @if ($icon) - + @endif - {{ $heading ?? $title }} + {{ $heading ?? $title }} - + -
$variant === 'filled', 'pt-1'])> +
{{ $slot }}
diff --git a/tests/Feature/Components/CollapseTest.php b/tests/Feature/Components/CollapseTest.php index 8c04fbd9..f72e0cc1 100644 --- a/tests/Feature/Components/CollapseTest.php +++ b/tests/Feature/Components/CollapseTest.php @@ -10,24 +10,25 @@ it('discloses on the native details element, kept open through a morph', functio ->toContain('toContain('wire:ignore.self') ->toContain(' open ') - ->toContain('rounded-corner-lg bg-surface-container') - ->toContain('data-collapse') - ->toContain('[interpolate-size:allow-keywords]') + ->toContain('data-md-collapse') + ->toContain('data-md-variant="filled"') + ->toContain('data-md-collapse-icon') + ->toContain('data-md-collapse-chevron') ->toContain('How long do links last?') ->toContain('Until the expiry.') - ->toContain('group-open/collapse:rotate-180'); + ->toContain('class="md-state-layer md-focus-ring"'); }); it('has no Alpine on it without a binding', function () { $html = (string) $this->blade('Body'); expect($html) - ->toMatch('/toMatch('/not->toContain('x-data') ->not->toContain('x-modelable') ->not->toContain('x-on:toggle') ->and((string) $this->blade('Body')) - ->toMatch('/toMatch('/