Skip to content

Commit cbc304b

Browse files
authored
Add Icon object
1 parent fb28ca4 commit cbc304b

File tree

8 files changed

+418
-43
lines changed

8 files changed

+418
-43
lines changed

src/Icons/src/IconRegistryInterface.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\UX\Icons;
1313

1414
use Symfony\UX\Icons\Exception\IconNotFoundException;
15+
use Symfony\UX\Icons\Svg\Icon;
1516

1617
/**
1718
* @author Kevin Bond <[email protected]>
@@ -23,9 +24,7 @@
2324
interface IconRegistryInterface extends \IteratorAggregate, \Countable
2425
{
2526
/**
26-
* @return array{0: string, 1: array<string, string|bool>}
27-
*
2827
* @throws IconNotFoundException
2928
*/
30-
public function get(string $name): array;
29+
public function get(string $name): Icon;
3130
}

src/Icons/src/IconRenderer.php

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,34 +29,15 @@ public function __construct(
2929
*/
3030
public function renderIcon(string $name, array $attributes = []): string
3131
{
32-
[$content, $iconAttr] = $this->registry->get($name);
32+
// TODO generate class name(s)
33+
// TODO add role/aria
3334

34-
$iconAttr = array_merge($iconAttr, $this->defaultIconAttributes);
35+
// TODO catch IconNotFoundException
36+
// --> only possible if we add a new method to IconRegistryInterface
3537

36-
return sprintf(
37-
'<svg%s>%s</svg>',
38-
self::normalizeAttributes([...$iconAttr, ...$attributes]),
39-
$content,
40-
);
41-
}
42-
43-
/**
44-
* @param array<string,string|bool> $attributes
45-
*/
46-
private static function normalizeAttributes(array $attributes): string
47-
{
48-
return array_reduce(
49-
array_keys($attributes),
50-
static function (string $carry, string $key) use ($attributes) {
51-
$value = $attributes[$key];
52-
53-
return match ($value) {
54-
true => "{$carry} {$key}",
55-
false => $carry,
56-
default => sprintf('%s %s="%s"', $carry, $key, $value),
57-
};
58-
},
59-
''
60-
);
38+
return $this->registry->get($name)
39+
->withAttributes([...$this->defaultIconAttributes, ...$attributes])
40+
->toHtml()
41+
;
6142
}
6243
}

src/Icons/src/Registry/CacheIconRegistry.php

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Contracts\Cache\CacheInterface;
1616
use Symfony\UX\Icons\Exception\IconNotFoundException;
1717
use Symfony\UX\Icons\IconRegistryInterface;
18+
use Symfony\UX\Icons\Svg\Icon;
1819

1920
/**
2021
* @author Kevin Bond <[email protected]>
@@ -30,10 +31,14 @@ public function __construct(private \Traversable $registries, private CacheInter
3031
{
3132
}
3233

33-
public function get(string $name, bool $refresh = false): array
34+
public function get(string $name, bool $refresh = false): Icon
3435
{
36+
if (!Icon::isValidName($name)) {
37+
throw new IconNotFoundException(sprintf('The icon name "%s" is not valid.', $name));
38+
}
39+
3540
return $this->cache->get(
36-
sprintf('ux-icon-%s', str_replace([':', '/'], ['-', '-'], $name)),
41+
sprintf('ux-icon-%s', Icon::nameToId($name)),
3742
function () use ($name) {
3843
foreach ($this->registries as $registry) {
3944
try {

src/Icons/src/Registry/LocalSvgIconRegistry.php

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\Finder\Finder;
1515
use Symfony\UX\Icons\Exception\IconNotFoundException;
1616
use Symfony\UX\Icons\IconRegistryInterface;
17+
use Symfony\UX\Icons\Svg\Icon;
1718

1819
/**
1920
* @author Kevin Bond <[email protected]>
@@ -26,13 +27,18 @@ public function __construct(private string $iconDir)
2627
{
2728
}
2829

29-
public function get(string $name): array
30+
public function get(string $name): Icon
3031
{
32+
if (!Icon::isValidName($name)) {
33+
throw new IconNotFoundException(sprintf('The icon name "%s" is not valid.', $name));
34+
}
35+
3136
if (!file_exists($filename = sprintf('%s/%s.svg', $this->iconDir, str_replace(':', '/', $name)))) {
3237
throw new IconNotFoundException(sprintf('The icon "%s" (%s) does not exist.', $name, $filename));
3338
}
3439

3540
$svg = file_get_contents($filename) ?: throw new \RuntimeException(sprintf('The icon file "%s" could not be read.', $filename));
41+
3642
$doc = new \DOMDocument();
3743
$doc->preserveWhiteSpace = false;
3844

@@ -54,24 +60,27 @@ public function get(string $name): array
5460

5561
$svgElement = $svgElements->item(0) ?? throw new \RuntimeException(sprintf('The icon file "%s" does not contain a valid SVG.', $filename));
5662

57-
$html = '';
63+
$innerSvg = '';
5864

5965
foreach ($svgElement->childNodes as $child) {
60-
$html .= $doc->saveHTML($child);
66+
$innerSvg .= $doc->saveHTML($child);
6167
}
6268

63-
if (!$html) {
69+
if (!$innerSvg) {
6470
throw new \RuntimeException(sprintf('The icon file "%s" contains an empty SVG.', $filename));
6571
}
6672

73+
// @todo: save all attributes in the local object ?
74+
// allow us to defer the decision of which attributes to keep or not
75+
6776
$allAttributes = array_map(fn (\DOMAttr $a) => $a->value, [...$svgElement->attributes]);
6877
$attributes = [];
6978

7079
if (isset($allAttributes['viewBox'])) {
7180
$attributes['viewBox'] = $allAttributes['viewBox'];
7281
}
7382

74-
return [$html, $attributes];
83+
return new Icon($innerSvg, $attributes);
7584
}
7685

7786
public function getIterator(): \Traversable

src/Icons/src/Svg/Icon.php

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php
2+
3+
namespace Symfony\UX\Icons\Svg;
4+
5+
/**
6+
*
7+
* @author Simon André <[email protected]>
8+
*
9+
* @internal
10+
*/
11+
final class Icon implements \Stringable, \Serializable, \ArrayAccess
12+
{
13+
/**
14+
* Transforms a valid icon ID into an icon name.
15+
*
16+
* @throws \InvalidArgumentException if the ID is not valid
17+
* @see isValidId()
18+
*/
19+
public static function idToName(string $id): string
20+
{
21+
if (!self::isValidId($id)) {
22+
throw new \InvalidArgumentException(sprintf('The id "%s" is not a valid id.', $id));
23+
}
24+
25+
return str_replace('--', ':', $id);
26+
}
27+
28+
/**
29+
* Transforms a valid icon name into an ID.
30+
*
31+
* @throws \InvalidArgumentException if the name is not valid
32+
* @see isValidName()
33+
*/
34+
public static function nameToId(string $name): string
35+
{
36+
if (!self::isValidName($name)) {
37+
throw new \InvalidArgumentException(sprintf('The name "%s" is not a valid name.', $name));
38+
}
39+
40+
return str_replace(':', '--', $name);
41+
}
42+
43+
/**
44+
* Returns whether the given string is a valid icon ID.
45+
*
46+
* An icon ID is a string that contains only lowercase letters, numbers, and hyphens.
47+
* It must be composed of slugs separated by double hyphens.
48+
*
49+
* @see https://regex101.com/r/mmvl5t/1
50+
*/
51+
public static function isValidId(string $id): bool
52+
{
53+
return (bool) preg_match('#^([a-z0-9]+(-[a-z0-9]+)*)(--[a-z0-9]+(-[a-z0-9]+)*)*$#', $id);
54+
}
55+
56+
/**
57+
* Returns whether the given string is a valid icon name.
58+
*
59+
* An icon name is a string that contains only lowercase letters, numbers, and hyphens.
60+
* It must be composed of slugs separated by colons.
61+
*
62+
* @see https://regex101.com/r/Gh2Z9s/1
63+
*/
64+
public static function isValidName(string $name): bool
65+
{
66+
return (bool) preg_match('#^([a-z0-9]+(-[a-z0-9]+)*)(:[a-z0-9]+(-[a-z0-9]+)*)*$#', $name);
67+
}
68+
69+
public function __construct(
70+
private readonly string $innerSvg,
71+
private readonly array $attributes = [],
72+
)
73+
{
74+
// @todo validate attributes (?)
75+
// the main idea is to have a way to validate the attributes
76+
// before the icon is cached to improve performances
77+
// (avoiding to validate the attributes each time the icon is rendered)
78+
}
79+
80+
public function toHtml(): string
81+
{
82+
$htmlAttributes = '';
83+
foreach ($this->attributes as $name => $value) {
84+
if (false === $value) {
85+
continue;
86+
}
87+
$htmlAttributes .= ' '.$name;
88+
if (true !== $value) {
89+
$value = htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
90+
$htmlAttributes .= '="'. $value .'"';
91+
}
92+
}
93+
94+
return '<svg'.$htmlAttributes.'>'.$this->innerSvg.'</svg>';
95+
}
96+
97+
public function getInnerSvg(): string
98+
{
99+
return $this->innerSvg;
100+
}
101+
102+
/**
103+
* @return array<string, string|bool>
104+
*/
105+
public function getAttributes(): array
106+
{
107+
return $this->attributes;
108+
}
109+
110+
/**
111+
* @param array<string, string|bool> $attributes
112+
* @return self
113+
*/
114+
public function withAttributes(array $attributes): self
115+
{
116+
foreach ($attributes as $name => $value) {
117+
if (!is_string($name)) {
118+
throw new \InvalidArgumentException(sprintf('Attribute names must be string, "%s" given.', get_debug_type($name)));
119+
}
120+
// @todo regexp would be better ?
121+
if (!ctype_alnum($name) && !str_contains($name, '-')) {
122+
throw new \InvalidArgumentException(sprintf('Invalid attribute name "%s".', $name));
123+
}
124+
if (!is_string($value) && !is_bool($value)) {
125+
throw new \InvalidArgumentException(sprintf('Invalid value type for attribute "%s". Boolean or string allowed, "%s" provided. ', $name, get_debug_type($value)));
126+
}
127+
}
128+
129+
return new self($this->innerSvg, [...$this->attributes, ...$attributes]);
130+
}
131+
132+
public function withInnerSvg(string $innerSvg): self
133+
{
134+
// @todo validate svg ?
135+
// The main idea is to not validate the attributes for every icon
136+
// when they come from a pack (and thus share a set of attributes)
137+
138+
return new self($innerSvg, $this->attributes);
139+
}
140+
141+
public function __toString(): string
142+
{
143+
return $this->toHtml();
144+
}
145+
146+
public function serialize(): string
147+
{
148+
return serialize([$this->innerSvg, $this->attributes]);
149+
}
150+
151+
public function unserialize(string $data): void
152+
{
153+
[$this->innerSvg, $this->attributes] = unserialize($data);
154+
}
155+
156+
public function __serialize(): array
157+
{
158+
return [$this->innerSvg, $this->attributes];
159+
}
160+
161+
public function __unserialize(array $data): void
162+
{
163+
[$this->innerSvg, $this->attributes] = $data;
164+
}
165+
166+
public function offsetExists(mixed $offset): bool
167+
{
168+
return isset($this->attributes[$offset]);
169+
}
170+
171+
public function offsetGet(mixed $offset): mixed
172+
{
173+
return $this->attributes[$offset];
174+
}
175+
176+
public function offsetSet(mixed $offset, mixed $value): void
177+
{
178+
throw new \LogicException('The Icon object is immutable.');
179+
}
180+
181+
public function offsetUnset(mixed $offset): void
182+
{
183+
throw new \LogicException('The Icon object is immutable.');
184+
}
185+
}

src/Icons/tests/Integration/Twig/UXIconExtensionTest.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ public function testRenderIcons(): void
2626
<li id="first">{{ ux_icon('user', {class: 'h-6 w-6'}) }}</li>
2727
<li id="second">{{ ux_icon('user') }}</li>
2828
<li id="third">{{ ux_icon('sub:check') }}</li>
29-
<li id="forth">{{ ux_icon('sub/check') }}</li>
3029
<li id="fifth"><twig:Icon name="user" class="h-6 w-6" /></li>
3130
<li id="sixth"><twig:Icon name="sub:check" /></li>
3231
</ul>
@@ -38,7 +37,6 @@ public function testRenderIcons(): void
3837
<li id="first"><svg viewBox="0 0 24 24" fill="currentColor" class="h-6 w-6"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd"></path></svg></li>
3938
<li id="second"><svg viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd"></path></svg></li>
4039
<li id="third"><svg viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path></svg></li>
41-
<li id="forth"><svg viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path></svg></li>
4240
<li id="fifth"><svg viewBox="0 0 24 24" fill="currentColor" class="h-6 w-6"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM3.751 20.105a8.25 8.25 0 0 1 16.498 0 .75.75 0 0 1-.437.695A18.683 18.683 0 0 1 12 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 0 1-.437-.695Z" clip-rule="evenodd"></path></svg></li>
4341
<li id="sixth"><svg viewBox="0 0 24 24" fill="currentColor"><path fill-rule="evenodd" d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd"></path></svg></li>
4442
</ul>

src/Icons/tests/Unit/Registry/LocalSvgIconRegistryTest.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\UX\Icons\Registry\LocalSvgIconRegistry;
16+
use Symfony\UX\Icons\Svg\Icon;
1617

1718
/**
1819
* @author Kevin Bond <[email protected]>
@@ -24,10 +25,10 @@ final class LocalSvgIconRegistryTest extends TestCase
2425
*/
2526
public function testValidSvgs(string $name, array $expectedAttributes, string $expectedContent): void
2627
{
27-
[$content, $attributes] = $this->registry()->get($name);
28-
29-
$this->assertSame($expectedContent, $content);
30-
$this->assertSame($expectedAttributes, $attributes);
28+
$icon = $this->registry()->get($name);
29+
$this->assertInstanceOf(Icon::class, $icon);
30+
$this->assertSame($expectedContent, $icon->getInnerSvg());
31+
$this->assertSame($expectedAttributes, $icon->getAttributes());
3132
}
3233

3334
public static function validSvgProvider(): iterable

0 commit comments

Comments
 (0)