Let <x-icon> pick the optical size 20 cut
M3's optical size axis redraws a symbol so its strokes look equally heavy at every size; drawing the 24 cut at 20px thins them by about a sixth (styles.md § Icons). `optical` picks the cut — 24 by default, 20 for an icon drawn at 20px or smaller — and combines with `filled`; anything else falls back to 24, so no caller can land on a folder that does not exist. SvgFile::symbol() takes the size as a fourth argument and resolves the `-20` folders; the catalogue is the same in both cuts, so symbolNames() and DesignGuard's icon-name check are unchanged. The callers that size their own icons pass it in a later step. Plan step 10; finding C16. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
c22d693334
commit
699827d696
@@ -179,15 +179,18 @@ Your export is ready.
|
||||
|
||||
### `<x-icon>`
|
||||
|
||||
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: `<x-icon name="lock" class="size-5 text-on-surface-variant" />`.
|
||||
24px (`size-6`) unless a `size-*`, `w-*` or `h-*` class is passed. Colour follows the text: `<x-icon name="lock" class="size-5 text-on-surface-variant" optical="20" />`.
|
||||
|
||||
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.
|
||||
|
||||
### `<x-shape>`
|
||||
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
+14
-4
@@ -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<string, string> */
|
||||
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<string>
|
||||
*/
|
||||
|
||||
@@ -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 `<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('class="shrink-0 size-6"', false)
|
||||
@@ -32,6 +43,34 @@ it('draws the filled symbol when asked', function () {
|
||||
->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="size-5" />');
|
||||
$dense = (string) $this->blade('<x-icon name="home" class="size-5" optical="20" />');
|
||||
|
||||
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('<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].');
|
||||
@@ -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('<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(file_get_contents(__DIR__."/../../../resources/svg/symbols/outlined/{$name}.svg"))
|
||||
->toStartWith('<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path')
|
||||
->not->toContain('width=');
|
||||
expect(symbolGeometry("outlined-20/{$name}"))->not->toBe(symbolGeometry("outlined/{$name}"))
|
||||
->and(symbolGeometry("filled-20/{$name}"))->not->toBe(symbolGeometry("filled/{$name}"));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user