From e99c5993d8d09bff9dd12b1db266b4e0e5bc59d8 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:50:55 +0200 Subject: [PATCH] Add the surface layout component Plan step 35: , a tonal region in surface or a surface-container step, padded with a spacing token, rounded with a corner token and optionally edged in outline-variant. A pane on a surface keeps its margin again, since the surface is a new edge. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 7 ++ resources/css/layout.css | 1 + resources/css/layout/surface.css | 107 ++++++++++++++++++ resources/views/components/surface.blade.php | 48 ++++++++ .../views/showcase/sections/layout.blade.php | 1 + tests/Browser/LayoutTest.php | 16 +++ tests/Feature/Components/SurfaceTest.php | 73 ++++++++++++ 7 files changed, 253 insertions(+) create mode 100644 resources/css/layout/surface.css create mode 100644 resources/views/components/surface.blade.php create mode 100644 tests/Feature/Components/SurfaceTest.php diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 55c8832e..751cb3d8 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -783,6 +783,13 @@ Breakpoints are M3's five, in px: compact below 600, `medium` 600, `expanded` 84 M3's margin — 16px on a compact window, 24px from `medium` — is drawn once: by the scaffold's content region, or by the outermost pane or canonical layout when there is no scaffold. A pane inside one of those draws none, and one on an ``, a new edge, draws it again. +#### `` + +A tonal region: ``. + +- `level`: `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest`, `surface-container-low`, `surface-container` (default), `surface-container-high`, `surface-container-highest`; the text on it is `on-surface`. A higher container step reads as nearer — how M3 separates regions without a shadow. +- `padding`: a spacing token. `corner`: `none`, `xs`, `sm`, `md`, `lg`, `lg-increased`, `xl`, `xl-increased`, `xxl`, `full`. `outlined`: M3's 1dp outline-variant edge. + #### `` Children one under another inside a pane: ``. `align` across it: `stretch` (default), `start`, `center`, `end`. diff --git a/resources/css/layout.css b/resources/css/layout.css index cc9cf0d4..ab0f41e5 100644 --- a/resources/css/layout.css +++ b/resources/css/layout.css @@ -9,3 +9,4 @@ @import './layout/stack.css'; @import './layout/row.css'; @import './layout/grid.css'; +@import './layout/surface.css'; diff --git a/resources/css/layout/surface.css b/resources/css/layout/surface.css new file mode 100644 index 00000000..f8ef8856 --- /dev/null +++ b/resources/css/layout/surface.css @@ -0,0 +1,107 @@ +/* + * : a tonal region, M3's surface or one of its container steps. + * + * `data-md-level` names the colour role the region is filled with — `surface`, `surface-dim`, + * `surface-bright`, or `surface-container-lowest` … `surface-container-highest` — and its text is + * `on-surface`, the role M3 pairs with every one of them (docs/reference/m3/styles.md § Color → + * surface roles). The container steps are how M3 separates regions without a shadow: a higher step + * reads as nearer. `data-md-corner` rounds it with a corner token (`xs` … `full`, + * resources/css/tokens/shape.css), `data-md-padding` pads it with a spacing token (spacing.css), + * and `data-md-outlined` draws the 1dp outline-variant edge M3 gives an outlined container, for a + * boundary where the tones alone would be too close. + * + * A surface is an edge of its own: a pane or a canonical layout on it keeps its content off the + * surface's edge by M3's margin again, even inside a region that already drew one (pane.css, + * `--md-layout-margin`), which `initial` puts back to "not drawn yet". + * + * In `material.layout`, under every component, so a component drawn on a surface keeps its own + * container; `hide-below`/`hide-from` come from visibility.css. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './spacing.css'; +@import './visibility.css'; + +@layer material.layout { + [data-md-surface] { + background-color: var(--md-sys-color-surface-container); + color: var(--md-sys-color-on-surface); + } + + [data-md-surface][data-md-level='surface'] { + background-color: var(--md-sys-color-surface); + } + + [data-md-surface][data-md-level='surface-dim'] { + background-color: var(--md-sys-color-surface-dim); + } + + [data-md-surface][data-md-level='surface-bright'] { + background-color: var(--md-sys-color-surface-bright); + } + + [data-md-surface][data-md-level='surface-container-lowest'] { + background-color: var(--md-sys-color-surface-container-lowest); + } + + [data-md-surface][data-md-level='surface-container-low'] { + background-color: var(--md-sys-color-surface-container-low); + } + + [data-md-surface][data-md-level='surface-container-high'] { + background-color: var(--md-sys-color-surface-container-high); + } + + [data-md-surface][data-md-level='surface-container-highest'] { + background-color: var(--md-sys-color-surface-container-highest); + } + + [data-md-surface][data-md-corner='none'] { + border-radius: var(--md-sys-shape-corner-none); + } + + [data-md-surface][data-md-corner='xs'] { + border-radius: var(--md-sys-shape-corner-xs); + } + + [data-md-surface][data-md-corner='sm'] { + border-radius: var(--md-sys-shape-corner-sm); + } + + [data-md-surface][data-md-corner='md'] { + border-radius: var(--md-sys-shape-corner-md); + } + + [data-md-surface][data-md-corner='lg'] { + border-radius: var(--md-sys-shape-corner-lg); + } + + [data-md-surface][data-md-corner='lg-increased'] { + border-radius: var(--md-sys-shape-corner-lg-increased); + } + + [data-md-surface][data-md-corner='xl'] { + border-radius: var(--md-sys-shape-corner-xl); + } + + [data-md-surface][data-md-corner='xl-increased'] { + border-radius: var(--md-sys-shape-corner-xl-increased); + } + + [data-md-surface][data-md-corner='xxl'] { + border-radius: var(--md-sys-shape-corner-xxl); + } + + [data-md-surface][data-md-corner='full'] { + border-radius: var(--md-sys-shape-corner-full); + } + + [data-md-surface][data-md-outlined] { + border: 1px solid var(--md-sys-color-outline-variant); + } + + [data-md-surface] > * { + --md-layout-margin: initial; + } +} diff --git a/resources/views/components/surface.blade.php b/resources/views/components/surface.blade.php new file mode 100644 index 00000000..f9fe7ed4 --- /dev/null +++ b/resources/views/components/surface.blade.php @@ -0,0 +1,48 @@ +{{-- A tonal region: M3's surface, or a step of its surface containers. + + + + `level` is the colour role it is filled with: `surface`, `surface-dim`, `surface-bright`, + `surface-container-lowest`, `surface-container-low`, `surface-container` (the default), + `surface-container-high` or `surface-container-highest`; the text on it is `on-surface` + (docs/reference/m3/styles.md § Color). Higher steps read as nearer, which is how M3 sets + regions apart without a shadow. An unknown level is the default. + + `padding` is a spacing token's name, `space25` … `space900` + (docs/reference/m3/styles-supplement.md § Spacing); anything else is no padding. `corner` is a + corner token, `none`, `xs`, `sm`, `md`, `lg`, `lg-increased`, `xl`, `xl-increased`, `xxl` or + `full` (resources/css/tokens/shape.css); anything else leaves it square. `outlined` draws + the 1dp outline-variant edge. + + Like every layout component it takes `as` (the element, `div` by default: `section`, + `article`, `aside` …) and `hide-below` / `hide-from` (`medium`, `expanded`, `large` or + `extra-large`), and the caller's `class` and `style` land on it untouched. Drawn by + resources/css/layout/surface.css; the props are read in src/Support/Layout.php. --}} + +@props([ + 'as' => null, + 'level' => null, + 'padding' => null, + 'corner' => null, + 'outlined' => false, + 'hideBelow' => null, + 'hideFrom' => null, +]) + +@php + $layout = \NoNameWeb\LivewireMaterial\Support\Layout::class; + $element = $layout::element($as); + + $attributes = $attributes->merge(array_filter([ + 'data-md-surface' => true, + 'data-md-level' => $layout::choice($level, [ + 'surface', 'surface-dim', 'surface-bright', 'surface-container-lowest', 'surface-container-low', + 'surface-container', 'surface-container-high', 'surface-container-highest', + ], 'surface-container'), + 'data-md-padding' => $layout::spacing($padding), + 'data-md-corner' => $layout::choice($corner, ['none', 'xs', 'sm', 'md', 'lg', 'lg-increased', 'xl', 'xl-increased', 'xxl', 'full']), + 'data-md-outlined' => $outlined ? true : null, + ] + $layout::visibility($hideBelow, $hideFrom), fn ($value): bool => $value !== null)); +@endphp + +<{{ $element }} {{ $attributes }}>{{ $slot }} diff --git a/resources/views/showcase/sections/layout.blade.php b/resources/views/showcase/sections/layout.blade.php index 45d6869f..9660cffb 100644 --- a/resources/views/showcase/sections/layout.blade.php +++ b/resources/views/showcase/sections/layout.blade.php @@ -113,6 +113,7 @@

+
  • <x-surface> — a tonal region: a surface role, padding, a corner and an outline.
  • <x-stack>, <x-row> and <x-grid> — arrangement inside a pane.
  • diff --git a/tests/Browser/LayoutTest.php b/tests/Browser/LayoutTest.php index 2babc284..d4ae986e 100644 --- a/tests/Browser/LayoutTest.php +++ b/tests/Browser/LayoutTest.php @@ -126,3 +126,19 @@ it('fills a grid with columns of a minimum width, capped by the count', function ->assertScript(layoutColumns('#fill').' === 3') ->assertScript(layoutColumns('#capped').' === 2'); }); + +it('draws a surface in its role, padding and corner, and hides it below a breakpoint', function () { + $body = 'Surface' + .'
    '; + + layoutPage($body, 599) + ->assertScript(layoutStyle('#surface', 'display')." === 'none'"); + + layoutPage($body, 600) + ->assertScript(layoutStyle('#surface', 'display')." === 'block'") + ->assertScript(layoutStyle('#surface', 'backgroundColor').' === '.layoutStyle('#role', 'backgroundColor')) + ->assertScript(layoutStyle('#surface', 'paddingTop')." === '24px'") + ->assertScript(layoutStyle('#surface', 'borderTopLeftRadius')." === '16px'") + ->assertScript(layoutStyle('#surface', 'borderTopWidth')." === '1px'") + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/Components/SurfaceTest.php b/tests/Feature/Components/SurfaceTest.php new file mode 100644 index 00000000..23986810 --- /dev/null +++ b/tests/Feature/Components/SurfaceTest.php @@ -0,0 +1,73 @@ +blade('Content')); + + expect($root)->toMatchArray(['<' => 'div', 'data-md-surface' => 'data-md-surface', 'data-md-level' => 'surface-container']) + ->not->toHaveKeys(['data-md-padding', 'data-md-corner', 'data-md-outlined', 'data-md-hide-below', 'data-md-hide-from', 'class', 'style']); +}); + +it('fills the surface with each of M3\'s surface roles, and the default for any other', function () { + foreach (['surface', 'surface-dim', 'surface-bright', 'surface-container-lowest', 'surface-container-low', 'surface-container', 'surface-container-high', 'surface-container-highest'] as $level) { + expect(layoutRoot((string) $this->blade('', ['level' => $level]))['data-md-level'])->toBe($level); + } + + expect(layoutRoot((string) $this->blade(''))['data-md-level'])->toBe('surface-container'); +}); + +it('pads the surface with a spacing token only, and with none for anything else', function () { + expect(layoutRoot((string) $this->blade(''))['data-md-padding'])->toBe('space300'); + + foreach (['24px', '3', 'space1000', 'roomy'] as $padding) { + expect(layoutRoot((string) $this->blade('', ['padding' => $padding]))['data-md-padding'])->toBe('none'); + } +}); + +it('rounds the surface with a corner token, and leaves it square for anything else', function () { + foreach (['none', 'xs', 'sm', 'md', 'lg', 'lg-increased', 'xl', 'xl-increased', 'xxl', 'full'] as $corner) { + expect(layoutRoot((string) $this->blade('', ['corner' => $corner]))['data-md-corner'])->toBe($corner); + } + + expect(layoutRoot((string) $this->blade('')))->not->toHaveKey('data-md-corner'); +}); + +it('draws an outline only when asked', function () { + expect(layoutRoot((string) $this->blade('')))->toHaveKey('data-md-outlined') + ->and(layoutRoot((string) $this->blade('')))->not->toHaveKey('data-md-outlined'); +}); + +it('draws the element it is asked for, and a div for one it does not take', function () { + expect(layoutRoot((string) $this->blade('Content'))['<'])->toBe('section') + ->and((string) $this->blade('Content'))->toContain('') + ->and(layoutRoot((string) $this->blade('Content'))['<'])->toBe('div') + ->and(layoutRoot((string) $this->blade(''))['<'])->toBe('div'); +}); + +it('hides below and from a breakpoint, never below or from compact', function () { + expect(layoutRoot((string) $this->blade(''))) + ->toMatchArray(['data-md-hide-below' => 'expanded', 'data-md-hide-from' => 'extra-large']); + + foreach (['compact', 'tablet', 'Medium', '840'] as $breakpoint) { + expect(layoutRoot((string) $this->blade('', ['b' => $breakpoint]))) + ->not->toHaveKeys(['data-md-hide-below', 'data-md-hide-from']); + } +}); + +it('puts the caller\'s class and style on the surface untouched', function () { + expect(layoutRoot((string) $this->blade(''))) + ->toMatchArray(['class' => 'upload-zone', 'style' => 'min-height: 12rem;', 'data-test' => 'zone']); +}); + +it('draws the surface from a stylesheet in the layout layer, imported by layout.css', function () { + $css = (string) file_get_contents(__DIR__.'/../../../resources/css/layout/surface.css'); + + expect($css)->toContain('@layer material.layout') + ->toContain("[data-md-surface][data-md-level='surface-container-high'] {") + ->toContain('background-color: var(--md-sys-color-surface-container-high);') + ->toContain('border: 1px solid var(--md-sys-color-outline-variant);') + ->toContain("@import './spacing.css';") + ->toContain("@import './visibility.css';") + // A pane on a surface keeps its content off the surface's edge again. + ->toContain("[data-md-surface] > * {\n --md-layout-margin: initial;") + ->and((string) file_get_contents(__DIR__.'/../../../resources/css/layout.css'))->toContain("@import './layout/surface.css';"); +});