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('/