From 6c7414d60eeb15df21bb6a659df5253870ec5432 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 14:10:16 +0200 Subject: [PATCH] Draw the loading indicator without Tailwind Plan step 36. renders data-md-loading and data-md-contained, and loading.css in material.components draws the 48px indicator in primary, the contained circle and the still shape under reduced motion. The view no longer reads the caller's classes to decide its size and colour: a caller's utility outranks the package's layer on its own. ActionStylesheetsTest checks the group's stylesheets for the package's shape (layer statement, plain imports, material.components, tokens, px breakpoints, imports of rendered components) and its views for class lists; tests/Support/ComponentStylesheet reads a rule's declarations. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/loading.css | 53 ++++ resources/views/components/loading.blade.php | 36 +-- .../Components/ActionStylesheetsTest.php | 77 +++++ tests/Feature/Components/LoadingTest.php | 45 ++- tests/Support/ComponentStylesheet.php | 299 ++++++++++++++++++ 6 files changed, 476 insertions(+), 35 deletions(-) create mode 100644 resources/css/components/loading.css create mode 100644 tests/Feature/Components/ActionStylesheetsTest.php create mode 100644 tests/Support/ComponentStylesheet.php diff --git a/resources/css/components.css b/resources/css/components.css index 50ca58df..93c02802 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -12,6 +12,7 @@ @import './components/shape.css'; /* Actions and communication */ +@import './components/loading.css'; /* Inputs, selection and data */ diff --git a/resources/css/components/loading.css b/resources/css/components/loading.css new file mode 100644 index 00000000..45bbc47f --- /dev/null +++ b/resources/css/components/loading.css @@ -0,0 +1,53 @@ +/* + * : M3 Expressive's loading indicator, a shape that morphs through seven Expressive + * shapes while it turns, for a wait with no known length. + * + * The drawing is resources/svg/loading-indicator/, ported from androidx Compose Material 3's + * LoadingIndicator in bin/loading-indicator.mjs (Apache-2.0): LoadingIndicatorTokens' 38px + * indicator in a 48px container, which is the size here unless the caller's CSS sizes it. It is + * drawn in the text colour, `primary` (ActiveIndicatorColor) unless the caller colours it; + * `contained` puts it on a `primary-container` circle in `on-primary-container` + * (ContainedContainerColor, ContainedIndicatorColor), for a spinner over content. + * + * The animated drawing is SMIL, so it runs without script. Under `prefers-reduced-motion` the + * still drawing of the same shape takes its place: the view renders both, and this file shows one. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@layer material.components { + [data-md-loading] { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + inline-size: 48px; + block-size: 48px; + color: var(--md-sys-color-primary); + + & > svg { + inline-size: 100%; + block-size: 100%; + } + + & > [data-md-loading-still] { + display: none; + } + + @media (prefers-reduced-motion: reduce) { + & > [data-md-loading-animated] { + display: none; + } + + & > [data-md-loading-still] { + display: block; + } + } + } + + [data-md-loading][data-md-contained] { + border-radius: var(--md-sys-shape-corner-full); + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + } +} diff --git a/resources/views/components/loading.blade.php b/resources/views/components/loading.blade.php index e6c925ab..c105ad22 100644 --- a/resources/views/components/loading.blade.php +++ b/resources/views/components/loading.blade.php @@ -1,16 +1,17 @@ {{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while it turns, for a wait that has no known length. - In the text colour — primary unless the caller colours it — and 48px unless sized. `contained` - puts it on a primary-container circle, for a spinner over content. It is a `progressbar` - named `label` ("Loading" by default); pass `:label="false"` where something else already - says what is happening, as a button's own spinner does. + In the text colour — primary unless the caller colours it — and 48px unless the caller's CSS + sizes it. `contained` puts it on a primary-container circle, for a spinner over content. It + is a `progressbar` named `label` ("Loading" by default); pass `:label="false"` where something + else already says what is happening, as a button's own spinner does. Pure SVG with SMIL, so it animates in every engine without script. Under `prefers-reduced-motion` the shape rests. The geometry is androidx Compose Material 3's LoadingIndicator and Morph, ported in bin/loading-indicator.mjs (Apache-2.0): LoadingIndicatorTokens' 38px indicator in a 48px container, a morph every 650ms on a spring, - a quarter turn per morph and a full turn every 4666ms. --}} + a quarter turn per morph and a full turn every 4666ms. Drawn by + resources/css/components/loading.css. --}} @props([ 'contained' => false, @@ -18,26 +19,19 @@ ]) @php - $sized = preg_match('/(^|\s)(size|w|h)-/', (string) $attributes->get('class')) === 1; - $coloured = preg_match('/(^|\s)text-(?!(xs|sm|base|lg|xl|[2-9]xl|left|center|right|start|end)(\s|$))/', (string) $attributes->get('class')) === 1; $decorative = $label === false; $label = $decorative ? null : ($label ?? __('Loading')); - $attributes = $attributes - ->class([ - 'inline-flex shrink-0 items-center justify-center', - 'size-12' => ! $sized, - 'rounded-corner-full bg-primary-container text-on-primary-container' => $contained, - 'text-primary' => ! $contained && ! $coloured, - ]) - ->merge(array_filter([ - 'role' => $decorative ? null : 'progressbar', - 'aria-label' => $label, - 'aria-hidden' => $decorative ? 'true' : null, - ])); + $attributes = $attributes->merge(array_filter([ + 'data-md-loading' => true, + 'data-md-contained' => $contained ? true : null, + 'role' => $decorative ? null : 'progressbar', + 'aria-label' => $label, + 'aria-hidden' => $decorative ? 'true' : null, + ], fn ($value): bool => $value !== null)); @endphp - {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['class' => 'size-full motion-reduce:hidden', 'aria-hidden' => 'true', 'focusable' => 'false'])) }} - {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['class' => 'hidden size-full motion-reduce:block', 'aria-hidden' => 'true', 'focusable' => 'false'])) }} + {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['data-md-loading-animated' => true, 'aria-hidden' => 'true', 'focusable' => 'false'])) }} + {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['data-md-loading-still' => true, 'aria-hidden' => 'true', 'focusable' => 'false'])) }} diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php new file mode 100644 index 00000000..dce6b285 --- /dev/null +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -0,0 +1,77 @@ +css)->toStartWith('/*') + ->and($css->statements()[0] ?? null)->toBe('@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;') + ->and(array_slice($css->statements(), 1))->each->toMatch('/^@import \'\.\/[a-z-]+\.css\';$/') + ->and($css->blocks())->each->toBe('@layer material.components') + ->and($css->css)->not->toMatch('/@(?:tailwind|theme|utility|variant|custom-variant|apply|source|config|plugin|reference)\b|--(?:theme|spacing|alpha)\(|\btheme\(/'); + + foreach ($css->imports() as $import) { + expect(is_file(dirname(ComponentStylesheet::path($name)).'/'.$import))->toBeTrue("{$name}.css imports {$import}, which does not exist"); + } +})->with('action components'); + +it('imports the stylesheet of every component its view renders', function (string $name) { + preg_match_all('/unique() + // A component still written for Tailwind has no stylesheet of its own to import yet. + ->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag))) + ->map(fn (string $tag): string => "./{$tag}.css") + ->values() + ->all(); + + expect(array_values(array_diff($rendered, ComponentStylesheet::read($name)->imports())))->toBe([]); +})->with('action components'); + +it('writes no class list into the view', function (string $name) { + $view = File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"); + + // A class the caller hands in (`hint-class`, `icon-class`, a slot's attributes) passes through. + expect($view)->not->toMatch('/\bclass="(?!\{\{ \$)|@class\(|->class\(|toCssClasses|(?:x-bind)?:class=/'); +})->with('action components'); + +it('takes its values from the tokens and its breakpoints in px', function (string $name) { + $css = ComponentStylesheet::read($name); + $source = (string) preg_replace('~/\*.*?\*/~s', '', $css->css); + + // Colours are roles, shadows are elevation levels, type is a type style, motion is a spring. + expect($source)->not->toMatch('/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i') + ->not->toMatch('/\bbox-shadow:\s*(?!none|var\(--md-)/') + ->not->toMatch('/\bfont:\s*(?!var\(--md-sys-typescale-)/') + ->not->toMatch('/\btransition[a-z-]*:[^;]*(?:\d+m?s\b|\bease\b|ease-in|ease-out|cubic-bezier)/'); + + foreach ($css->mediaQueries() as $query) { + preg_match_all('/(\d*\.?\d+)(px|rem|em)\b/', $query, $lengths, PREG_SET_ORDER); + + foreach ($lengths as [, $number, $unit]) { + expect($unit)->toBe('px', "{$name}.css: {$query}") + ->and(in_array($number, ['600', '840', '1200', '1600'], true))->toBeTrue("{$name}.css: {$query} is not at an M3 breakpoint"); + } + } +})->with('action components'); + +it('is imported from the actions and communication block of components.css', function (string $name) { + $components = File::get(__DIR__.'/../../../resources/css/components.css'); + $block = substr($components, (int) strpos($components, '/* Actions and communication */')); + $block = substr($block, 0, (int) strpos($block, '/* Inputs, selection and data */')); + + expect($block)->toContain("@import './components/{$name}.css';"); +})->with('action components'); diff --git a/tests/Feature/Components/LoadingTest.php b/tests/Feature/Components/LoadingTest.php index c3513db1..02c4b5b4 100644 --- a/tests/Feature/Components/LoadingTest.php +++ b/tests/Feature/Components/LoadingTest.php @@ -1,30 +1,47 @@ blade(''); expect($html) + ->toContain('data-md-loading') ->toContain('role="progressbar"') ->toContain('aria-label="Loading"') - ->toContain('size-12') - ->toContain('text-primary') ->toContain('toContain('class="size-full motion-reduce:hidden"') - ->toContain('class="hidden size-full motion-reduce:block"'); + ->toMatch('/