Files
livewire-material/tests/Feature/Components/IconTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 7a95449c82 Draw the 67 Material Symbols Google ships without a viewBox at every size
Google publishes 67 symbols per cut (auto_awesome, insights,
tips_and_updates, battery_50 ...) with width and height but no viewBox,
their paths in 24 units, or 20 in the 20 cut, where every other symbol
draws in `0 -960 960 960`. bin/fetch-symbols dropped the size so CSS
decides it, which left these with no coordinate system: `<x-icon>`
drew them at 1:1, whole only at exactly 24px and cut off or misplaced
at any other size, the 20 cut every small button uses included.
normalise() now turns a file's width and height into its viewBox when
it has none, before dropping them. The 268 files are regenerated from
Google's originals at the recorded SOURCE commit with the fixed step
(each shipped file matched the old step's output first; the other
16,272 do not change), and every path lies inside its new viewBox.
IconTest checks that each shipped symbol has a viewBox and that
`<x-icon name="auto_awesome">` renders in 20 units at size 16 and in 24
at size 32; both fail without the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 04:18:10 +02:00

158 lines
7.5 KiB
PHP

<?php
use Illuminate\View\ViewException;
use NoNameWeb\LivewireMaterial\Support\SvgFile;
/**
* What a symbol file draws, from its first shape on: the part that tells one cut from
* another, since nearly every file opens with the same `<svg>` and viewBox.
*/
function symbolGeometry(string $file): string
{
$svg = trim((string) file_get_contents(__DIR__.'/../../../resources/svg/symbols/'.$file.'.svg'));
return substr($svg, (int) strpos($svg, '><') + 1);
}
it('draws a symbol inline, hidden from screen readers, 24px by default', function () {
$this->blade('<x-icon name="calendar_month" />')
->assertSee('data-md-icon', false)
->assertDontSee('--md-icon-size', false)
->assertDontSee('class=', false)
->assertSee('aria-hidden="true"', false)
->assertSee('fill="currentColor"', false)
->assertSee('viewBox="0 -960 960 960"', false);
});
it('draws the size it is given, and still takes a caller\'s class', function () {
$this->blade('<x-icon name="calendar_month" size="32" />')
->assertSee('style="--md-icon-size: 32px"', false);
$this->blade('<x-icon name="calendar_month" class="app-hero-icon" />')
->assertSee('class="app-hero-icon"', false);
});
it('ignores a size that is not a whole number of pixels in range', function () {
foreach (['large', '4', '300', '20.5'] as $size) {
$this->blade('<x-icon name="calendar_month" size="'.$size.'" />')
->assertDontSee('--md-icon-size', false);
}
});
it('gives every symbol it ships a viewBox, so each is drawn whole at any size', function () {
foreach (['outlined', 'filled', 'outlined-20', 'filled-20'] as $folder) {
$files = glob(__DIR__."/../../../resources/svg/symbols/{$folder}/*.svg") ?: [];
$withoutViewBox = array_filter($files, fn (string $file): bool => ! str_contains((string) file_get_contents($file), ' viewBox="'));
expect($files)->not->toBeEmpty()
->and(array_values(array_map(basename(...), $withoutViewBox)))->toBe([]);
}
});
it('draws a symbol Google drew in 24 or 20 units in its own units, not only at that size', function () {
// One of the symbols Google publishes with a width and height but no 960-unit viewBox.
expect((string) $this->blade('<x-icon name="auto_awesome" size="16" />'))->toContain('viewBox="0 0 20 20"')
->and((string) $this->blade('<x-icon name="auto_awesome" size="32" filled />'))->toContain('viewBox="0 0 24 24"');
});
it('takes the 20 cut for a size of 20 or less, unless optical says otherwise', function () {
expect((string) $this->blade('<x-icon name="home" size="20" />'))->toContain(symbolGeometry('outlined-20/home'))
->and((string) $this->blade('<x-icon name="home" size="18" />'))->toContain(symbolGeometry('outlined-20/home'))
->and((string) $this->blade('<x-icon name="home" size="24" />'))->toContain(symbolGeometry('outlined/home'))
->and((string) $this->blade('<x-icon name="home" size="20" optical="24" />'))->toContain(symbolGeometry('outlined/home'));
});
it('mirrors a directional symbol only when asked', function () {
$this->blade('<x-icon name="arrow_back" mirror-rtl />')->assertSee('data-md-mirror-rtl', false);
$this->blade('<x-icon name="arrow_back" />')->assertDontSee('data-md-mirror-rtl', false);
});
it('draws its size from a stylesheet in the components layer', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/icon.css');
expect($css)->toContain('@layer material.components')
->toContain('inline-size: var(--md-icon-size, 24px)')
->toContain("[data-md-icon][data-md-mirror-rtl]:is([dir='rtl'], [dir='rtl'] *)")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/icon.css';");
});
it('names the icon for a screen reader when it carries the meaning alone', function () {
$this->blade('<x-icon name="lock" label="Password protected" />')
->assertSee('aria-label="Password protected"', false)
->assertSee('role="img"', false)
->assertDontSee('aria-hidden', false);
});
it('draws the filled symbol when asked', function () {
$outlined = (string) $this->blade('<x-icon name="favorite" />');
$filled = (string) $this->blade('<x-icon name="favorite" filled />');
expect($filled)->not->toBe($outlined)
->and($filled)->toContain('<path d=');
});
it('draws the optical size 20 cut when the caller asks for it', function () {
$standard = (string) $this->blade('<x-icon name="home" class="app-list-icon" />');
$dense = (string) $this->blade('<x-icon name="home" class="app-list-icon" optical="20" />');
expect($dense)->not->toBe($standard)
->and($dense)->toContain('class="app-list-icon"')
->and($dense)->toContain('fill="currentColor"')
->and($dense)->toContain(symbolGeometry('outlined-20/home'));
});
it('combines the filled state with the 20 cut', function () {
$outlined = (string) $this->blade('<x-icon name="favorite" optical="20" />');
$filled = (string) $this->blade('<x-icon name="favorite" optical="20" filled />');
expect($filled)->not->toBe($outlined)
->and($outlined)->toContain(symbolGeometry('outlined-20/favorite'))
->and($filled)->toContain(symbolGeometry('filled-20/favorite'))
->and($filled)->not->toContain(symbolGeometry('filled/favorite'));
});
it('falls back to the 24 cut for an optical size it does not ship', function () {
$standard = (string) $this->blade('<x-icon name="home" />');
expect((string) $this->blade('<x-icon name="home" optical="40" />'))->toBe($standard)
->and((string) $this->blade('<x-icon name="home" optical="24" />'))->toBe($standard)
->and((string) $this->blade('<x-icon name="home" optical="" />'))->toBe($standard);
});
it('refuses a name that is not a symbol', function () {
expect(fn () => $this->blade('<x-icon name="not_a_symbol_at_all" />'))
->toThrow(ViewException::class, 'There is no Material Symbol named [not_a_symbol_at_all].');
});
it('points a Heroicon name at the symbol catalogue', function () {
expect(fn () => $this->blade('<x-icon name="o-calendar-days" />'))
->toThrow(ViewException::class, 'is not a Material Symbol name');
});
it('ships every symbol outlined and filled in both cuts, painted in the text colour', function () {
$symbols = SvgFile::symbolNames();
expect(count($symbols))->toBeGreaterThan(4000);
foreach (['filled', 'outlined-20', 'filled-20'] as $folder) {
$names = array_map(fn (string $file): string => basename($file, '.svg'), glob(__DIR__."/../../../resources/svg/symbols/{$folder}/*.svg"));
expect($names)->toEqual($symbols, "{$folder} holds a different catalogue than outlined");
}
foreach (['outlined', 'filled', 'outlined-20', 'filled-20'] as $folder) {
foreach (['home', 'search', 'cloud_upload', 'content_copy', 'delete'] as $name) {
expect(file_get_contents(__DIR__."/../../../resources/svg/symbols/{$folder}/{$name}.svg"))
->toStartWith('<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path')
->not->toContain('width=');
}
}
});
it('draws the two cuts from different geometry, not the same file scaled', function () {
foreach (['home', 'search', 'cloud_upload', 'content_copy', 'delete'] as $name) {
expect(symbolGeometry("outlined-20/{$name}"))->not->toBe(symbolGeometry("outlined/{$name}"))
->and(symbolGeometry("filled-20/{$name}"))->not->toBe(symbolGeometry("filled/{$name}"));
}
});