Files
livewire-material/tests/Support/ComponentStylesheet.php
T
Andreas Reinhold / reiniandClaude Opus 5 6c7414d60e 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
2026-09-14 14:10:16 +02:00

300 lines
8.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}