Draw the loading indicator without Tailwind

Plan step 36. <x-loading> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:10:16 +02:00
co-authored by Claude Opus 5
parent d7653aa6d0
commit 6c7414d60e
6 changed files with 476 additions and 35 deletions
+1
View File
@@ -12,6 +12,7 @@
@import './components/shape.css';
/* Actions and communication */
@import './components/loading.css';
/* Inputs, selection and data */
+53
View File
@@ -0,0 +1,53 @@
/*
* <x-loading>: 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);
}
}
+15 -21
View File
@@ -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
<span {{ $attributes }}>
{{ \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'])) }}
</span>
@@ -0,0 +1,77 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
/**
* The actions and communication components, rewritten without Tailwind (plan step 36): each view
* renders `data-md-*` attributes and no class list of its own, and each has a stylesheet in
* `material.components` that imports the stylesheets of the components its view renders, writes
* its values from the tokens and its breakpoints as px range queries, and is imported from the
* "Actions and communication" block of components.css.
*/
dataset('action components', [
'loading',
]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
$css = ComponentStylesheet::read($name);
expect($css->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('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"), $tags);
$rendered = collect($tags[1])->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');
+31 -14
View File
@@ -1,30 +1,47 @@
<?php
it('is a named progressbar in the primary colour, 48px by default', function () {
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('is a named progressbar, drawn by its stylesheet rather than by classes', function () {
$html = (string) $this->blade('<x-loading />');
expect($html)
->toContain('data-md-loading')
->toContain('role="progressbar"')
->toContain('aria-label="Loading"')
->toContain('size-12')
->toContain('text-primary')
->toContain('<animateTransform')
->toContain('class="size-full motion-reduce:hidden"')
->toContain('class="hidden size-full motion-reduce:block"');
->toMatch('/<svg data-md-loading-animated="data-md-loading-animated" aria-hidden="true" focusable="false"/')
->toMatch('/<svg data-md-loading-still="data-md-loading-still" aria-hidden="true" focusable="false"/')
->not->toContain('class=');
});
it('sits on a primary-container circle when contained', function () {
expect((string) $this->blade('<x-loading contained class="size-8" label="Uploading" />'))
->toContain('rounded-corner-full bg-primary-container text-on-primary-container')
->toContain('size-8')
->not->toContain('size-12')
it('is 48px in the primary colour unless the caller sizes or colours it', function () {
$css = ComponentStylesheet::read('loading');
expect($css->declarations('[data-md-loading]'))
->toMatchArray(['inline-size' => '48px', 'block-size' => '48px', 'color' => 'var(--md-sys-color-primary)'])
// A caller's class lands on the root untouched, and outranks the package's layer.
->and((string) $this->blade('<x-loading class="size-24 text-tertiary" label="Uploading" />'))
->toContain('class="size-24 text-tertiary"')
->toContain('aria-label="Uploading"');
});
it('takes the caller\'s colour', function () {
expect((string) $this->blade('<x-loading class="text-tertiary" />'))
->toContain('text-tertiary')
->not->toContain('text-primary');
it('sits on a primary-container circle when contained', function () {
expect((string) $this->blade('<x-loading contained />'))->toContain('data-md-contained')
->and((string) $this->blade('<x-loading />'))->not->toContain('data-md-contained')
->and(ComponentStylesheet::read('loading')->declarations('[data-md-loading][data-md-contained]'))->toBe([
'border-radius' => 'var(--md-sys-shape-corner-full)',
'background-color' => 'var(--md-sys-color-primary-container)',
'color' => 'var(--md-sys-color-on-primary-container)',
]);
});
it('rests the same shape under reduced motion', function () {
$css = ComponentStylesheet::read('loading');
expect($css->declarations('[data-md-loading] > [data-md-loading-still]'))->toBe(['display' => 'none'])
->and($css->declarations('[data-md-loading] > [data-md-loading-animated]', ['@media (prefers-reduced-motion: reduce)']))->toBe(['display' => 'none'])
->and($css->declarations('[data-md-loading] > [data-md-loading-still]', ['@media (prefers-reduced-motion: reduce)']))->toBe(['display' => 'block']);
});
it('says nothing where something else already does', function () {
+299
View File
@@ -0,0 +1,299 @@
<?php
namespace NoNameWeb\LivewireMaterial\Tests\Support;
use RuntimeException;
/**
* A component stylesheet (resources/css/components/<name>.css), read the way a render test asks
* about it: its imports, its layer blocks, and the declarations of a rule by its selector.
*
* Nesting is resolved as the browser resolves it — `&` stands for the parent selector, and a
* nested selector without `&` is a descendant of it — so a test names the selector the rule
* applies to (`[data-md-button]:focus-visible`), not how the file happens to nest it. At-rules
* (`@media`, `@starting-style`, `@supports`) are kept beside the selector they wrap.
*/
final class ComponentStylesheet
{
/** @var list<array{selector: string, at: list<string>, declarations: array<string, string>}> */
private array $rules = [];
/** @var list<string> */
private array $statements = [];
/** @var list<string> */
private array $blocks = [];
private function __construct(public readonly string $name, public readonly string $css)
{
$source = self::withoutComments($css);
foreach (self::items($source) as $item) {
if (isset($item['statement'])) {
$this->statements[] = $item['statement'];
continue;
}
$this->blocks[] = $item['prelude'];
$this->collect($item['prelude'], $item['body'], null, []);
}
}
public static function read(string $name): self
{
$path = self::path($name);
if (! is_file($path)) {
throw new RuntimeException("No stylesheet [{$name}].");
}
return new self($name, (string) file_get_contents($path));
}
public static function path(string $name): string
{
return dirname(__DIR__, 2)."/resources/css/components/{$name}.css";
}
/**
* The statements before the first block, in order: the layer statement and the imports.
*
* @return list<string>
*/
public function statements(): array
{
return $this->statements;
}
/**
* The files this stylesheet imports, as written.
*
* @return list<string>
*/
public function imports(): array
{
return array_values(array_filter(array_map(
fn (string $statement): ?string => preg_match('/^@import\s+([\'"])(.+?)\1;$/', $statement, $match) === 1 ? $match[2] : null,
$this->statements,
)));
}
/**
* The preludes of the top-level blocks.
*
* @return list<string>
*/
public function blocks(): array
{
return $this->blocks;
}
/**
* The declarations of every rule for this selector (and inside these at-rules, outermost
* first), merged in source order.
*
* @param list<string> $at
* @return array<string, string>
*/
public function declarations(string $selector, array $at = []): array
{
$selector = self::normalise($selector);
$at = array_map(self::normalise(...), $at);
$found = false;
$declarations = [];
foreach ($this->rules as $rule) {
if ($rule['selector'] === $selector && $rule['at'] === $at) {
$found = true;
$declarations = array_merge($declarations, $rule['declarations']);
}
}
if (! $found) {
throw new RuntimeException("{$this->name}.css has no rule [{$selector}]".($at === [] ? '' : ' inside ['.implode(' ', $at).']').'.');
}
return $declarations;
}
public function has(string $selector, array $at = []): bool
{
try {
$this->declarations($selector, $at);
return true;
} catch (RuntimeException) {
return false;
}
}
/**
* Every media query the stylesheet writes.
*
* @return list<string>
*/
public function mediaQueries(): array
{
preg_match_all('/@media\s*([^{]+)\{/', self::withoutComments($this->css), $matches);
return array_map(fn (string $query): string => self::normalise($query), $matches[1]);
}
/**
* @param list<string> $at
*/
private function collect(string $prelude, string $body, ?string $parent, array $at): void
{
if (str_starts_with($prelude, '@layer')) {
foreach (self::items($body) as $item) {
if (isset($item['prelude'])) {
$this->collect($item['prelude'], $item['body'], $parent, $at);
}
}
return;
}
if (str_starts_with($prelude, '@')) {
$at[] = self::normalise($prelude);
$selector = $parent;
} else {
$selector = $parent === null ? self::normalise($prelude) : self::nest($parent, $prelude);
}
$declarations = [];
foreach (self::items($body, true) as $item) {
if (isset($item['prelude'])) {
$this->collect($item['prelude'], $item['body'], $selector, $at);
} elseif (isset($item['statement']) && str_contains($item['statement'], ':') && $selector !== null) {
[$property, $value] = explode(':', rtrim($item['statement'], ';'), 2);
$declarations[trim($property)] = self::normalise($value);
}
}
if ($selector !== null && $declarations !== []) {
$this->rules[] = ['selector' => $selector, 'at' => $at, 'declarations' => $declarations];
}
}
private static function nest(string $parent, string $child): string
{
$parents = self::split($parent);
return implode(', ', array_merge(...array_map(
fn (string $each): array => array_map(
fn (string $part): string => str_contains($part, '&') ? str_replace('&', $each, $part) : "{$each} {$part}",
self::split($child),
),
$parents,
)));
}
/**
* A selector list split at its top-level commas.
*
* @return list<string>
*/
private static function split(string $selector): array
{
$parts = [];
$depth = 0;
$current = '';
foreach (str_split(self::normalise($selector)) as $char) {
$depth += match ($char) {
'(', '[' => 1,
')', ']' => -1,
default => 0,
};
if ($char === ',' && $depth === 0) {
$parts[] = trim($current);
$current = '';
continue;
}
$current .= $char;
}
$parts[] = trim($current);
return $parts;
}
private static function normalise(string $text): string
{
return trim((string) preg_replace('/\s+/', ' ', $text));
}
private static function withoutComments(string $css): string
{
return (string) preg_replace('~("(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')|/\*.*?\*/~s', '$1', $css);
}
/**
* The statements and blocks at the top of a piece of CSS. Inside a rule a declaration may end
* at the closing brace without a semicolon.
*
* @return list<array{statement: string}|array{prelude: string, body: string}>
*/
private static function items(string $css, bool $declarations = false): array
{
$items = [];
$start = 0;
$depth = 0;
$parens = 0;
$quote = null;
$opening = 0;
for ($i = 0, $length = strlen($css); $i < $length; $i++) {
$char = $css[$i];
if ($quote !== null) {
if ($char === '\\') {
$i++;
} elseif ($char === $quote) {
$quote = null;
}
continue;
}
if ($char === '"' || $char === "'") {
$quote = $char;
} elseif ($char === '(') {
$parens++;
} elseif ($char === ')') {
$parens--;
} elseif ($char === '{') {
if ($depth++ === 0) {
$opening = $i;
}
} elseif ($char === '}' && --$depth === 0) {
$items[] = [
'prelude' => self::normalise(substr($css, $start, $opening - $start)),
'body' => substr($css, $opening + 1, $i - $opening - 1),
];
$start = $i + 1;
} elseif ($char === ';' && $depth === 0 && $parens === 0) {
$items[] = ['statement' => self::normalise(substr($css, $start, $i - $start)).';'];
$start = $i + 1;
}
}
$rest = trim(substr($css, $start));
if ($rest !== '') {
if (! $declarations) {
throw new RuntimeException('The stylesheet ends inside an unclosed rule.');
}
$items[] = ['statement' => self::normalise($rest).';'];
}
return $items;
}
}