diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 9774cdf6..239b3f1a 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -179,15 +179,18 @@ Your export is ready. ### `` -A Material Symbol (Rounded, weight 400, grade 0, 24px), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws. +A Material Symbol (Rounded, weight 400, grade 0), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws. | Prop | Default | | |---|---|---| | `name` | required | `calendar_month`, `cloud_upload`, `content_copy` | | `filled` | `false` | the filled symbol — M3 uses it for active or selected | +| `optical` | `24` | the cut the glyph is drawn from, `24` or `20`; anything else falls back to 24 | | `label` | `null` | names the icon for screen readers when it carries the meaning alone; otherwise it is `aria-hidden` | -24px (`size-6`) unless a `size-*`, `w-*` or `h-*` class is passed. Colour follows the text: ``. +24px (`size-6`) unless a `size-*`, `w-*` or `h-*` class is passed. Colour follows the text: ``. + +M3's optical size axis redraws a symbol so its strokes look equally heavy at every size, so **an icon drawn at 20px or smaller (`size-5` and below) takes `optical="20"`**; scaling the 24 cut down thins its strokes by about a sixth. The cut is not a size — pass both, and where a component sizes an icon for you, pass the cut alongside the size class. ### `` diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index c3c0f7a8..364eab2d 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -1,9 +1,17 @@ -{{-- A Material Symbol (Rounded, weight 400, grade 0, optical size 24), inline. +{{-- A Material Symbol (Rounded, weight 400, grade 0), inline. `name` is Google's name for it, underscores and all (`arrow_back`), as listed on fonts.google.com/icons; every symbol Google publishes is available, and an unknown name throws. `filled` draws the filled state, which in M3 means active or selected. + `optical` is the cut the glyph is drawn from, `24` (the default) or `20`; anything else + falls back to 24, and `filled` combines with either. M3's optical size axis redraws a + symbol so its strokes look equally heavy at every size, which means an icon drawn at + 20px or smaller uses the 20 cut: scaling the 24 cut down to 20px instead makes its + strokes about a sixth too thin (docs/reference/m3/styles.md § Icons). The cut and the + size are separate decisions, so a caller that draws `size-5` passes `optical="20"` + with it; the components that size their own icons do that for you. + Decorative by default and hidden from screen readers, because nearly every icon sits beside words that already say what it means. Pass `label` when the icon alone carries the meaning. @@ -16,6 +24,7 @@ 'name', 'filled' => false, 'label' => null, + 'optical' => 24, ]) @php @@ -31,4 +40,4 @@ ])); @endphp -{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::symbol($name, (bool) $filled, $attributes) }} +{{ \NoNameWeb\LivewireMaterial\Support\SvgFile::symbol($name, (bool) $filled, $attributes, (int) $optical) }} diff --git a/src/Support/SvgFile.php b/src/Support/SvgFile.php index facf6e6d..8ec089a7 100644 --- a/src/Support/SvgFile.php +++ b/src/Support/SvgFile.php @@ -10,7 +10,7 @@ use InvalidArgumentException; * Inline SVGs from the package's own folders: Material Symbols and M3 Expressive shapes. * * Not blade-icons: its view-factory hook registers one Blade component per icon on every - * request, which for the ~8,000 symbol files here would be ~8,000 registrations each time. + * request, which for the ~16,500 symbol files here would be ~16,500 registrations each time. * A file is read the first time it is drawn and kept for the life of the worker; the * contents never change while the application runs, so nothing request-specific is held. */ @@ -19,13 +19,22 @@ class SvgFile /** @var array */ protected static array $contents = []; - public static function symbol(string $name, bool $filled, ComponentAttributeBag $attributes): HtmlString + /** + * One symbol, from the cut drawn for the optical size it will be seen at. + * + * The package ships Google's 24 and 20 cuts; the 20 one lives in a `-20` folder under the + * same names, so a symbol that exists exists in both. Any optical size but 20 draws the + * standard 24 cut, which is also what a caller who says nothing gets. + */ + public static function symbol(string $name, bool $filled, ComponentAttributeBag $attributes, int $optical = 24): HtmlString { if (preg_match('/^[a-z0-9_]+$/', $name) !== 1) { throw new InvalidArgumentException("[{$name}] is not a Material Symbol name. Symbols are named in lowercase with underscores, as on fonts.google.com/icons: `calendar_month`, `arrow_back`."); } - $path = static::directory('svg/symbols/'.($filled ? 'filled' : 'outlined'))."/{$name}.svg"; + $folder = ($filled ? 'filled' : 'outlined').($optical === 20 ? '-20' : ''); + + $path = static::directory('svg/symbols/'.$folder)."/{$name}.svg"; if (! is_file($path)) { throw new InvalidArgumentException("There is no Material Symbol named [{$name}]."); @@ -54,7 +63,8 @@ class SvgFile } /** - * The names of every Material Symbol the package ships. + * The names of every Material Symbol the package ships, in either cut: Google draws the + * same catalogue at optical size 24 and at 20, so one folder answers for all four. * * @return list */ diff --git a/tests/Feature/Components/IconTest.php b/tests/Feature/Components/IconTest.php index 29ebcc38..e3e28242 100644 --- a/tests/Feature/Components/IconTest.php +++ b/tests/Feature/Components/IconTest.php @@ -3,6 +3,17 @@ 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 every file opens with the same `` 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('') ->assertSee('class="shrink-0 size-6"', false) @@ -32,6 +43,34 @@ it('draws the filled symbol when asked', function () { ->and($filled)->toContain('blade(''); + $dense = (string) $this->blade(''); + + expect($dense)->not->toBe($standard) + ->and($dense)->toContain('class="shrink-0 size-5"') + ->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(''); + $filled = (string) $this->blade(''); + + 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(''); + + expect((string) $this->blade(''))->toBe($standard) + ->and((string) $this->blade(''))->toBe($standard) + ->and((string) $this->blade(''))->toBe($standard); +}); + it('refuses a name that is not a symbol', function () { expect(fn () => $this->blade('')) ->toThrow(ViewException::class, 'There is no Material Symbol named [not_a_symbol_at_all].'); @@ -42,16 +81,29 @@ it('points a Heroicon name at the symbol catalogue', function () { ->toThrow(ViewException::class, 'is not a Material Symbol name'); }); -it('ships every symbol outlined and filled, painted in the text colour', function () { +it('ships every symbol outlined and filled in both cuts, painted in the text colour', function () { $symbols = SvgFile::symbolNames(); - $filled = array_map(fn (string $file): string => basename($file, '.svg'), glob(__DIR__.'/../../../resources/svg/symbols/filled/*.svg')); - expect(count($symbols))->toBeGreaterThan(4000) - ->and($filled)->toEqual($symbols); + 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('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(file_get_contents(__DIR__."/../../../resources/svg/symbols/outlined/{$name}.svg")) - ->toStartWith('not->toContain('width='); + expect(symbolGeometry("outlined-20/{$name}"))->not->toBe(symbolGeometry("outlined/{$name}")) + ->and(symbolGeometry("filled-20/{$name}"))->not->toBe(symbolGeometry("filled/{$name}")); } });