Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/Icons/src/IconRegistryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Symfony\UX\Icons;

use Symfony\UX\Icons\Exception\IconNotFoundException;
use Symfony\UX\Icons\Svg\Icon;

/**
* @author Kevin Bond <[email protected]>
Expand All @@ -23,9 +24,7 @@
interface IconRegistryInterface extends \IteratorAggregate, \Countable
{
/**
* @return array{0: string, 1: array<string, string|bool>}
*
* @throws IconNotFoundException
*/
public function get(string $name): array;
public function get(string $name): Icon;
}
31 changes: 14 additions & 17 deletions src/Icons/src/IconRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

namespace Symfony\UX\Icons;

use Symfony\Ux\Icons\Svg\Icon;

/**
* @author Kevin Bond <[email protected]>
*
Expand All @@ -19,8 +21,8 @@
final class IconRenderer
{
public function __construct(
private IconRegistryInterface $registry,
private IconStack $stack,
private readonly IconRegistryInterface $registry,
private readonly IconStack $stack,
private array $defaultIconAttributes = [],
private array $defaultDeferredAttributes = [],
) {
Expand All @@ -32,22 +34,19 @@ public function __construct(
public function renderIcon(string $name, array $attributes = []): string
{
$deferred = $attributes['defer'] ?? false;

unset($attributes['defer']);

if ($deferred) {
$this->stack->push($name);

return sprintf('<svg%s><use xlink:href="#%s"/></svg>', self::normalizeAttributes($attributes), self::idFor($name));
return (new Icon('<use xlink:href="#'.self::idFor($name).'"/>'))
->withAttributes($attributes)
->toHtml();
}

[$content, $iconAttr] = $this->getIcon($name);

return sprintf(
'<svg%s>%s</svg>',
self::normalizeAttributes([...$iconAttr, ...$attributes]),
$content,
);
return $this->getIcon($name)
->withAttributes($attributes)
->toHtml();
}

/**
Expand All @@ -71,17 +70,15 @@ public function renderDeferred(array $attributes = []): string
return $return.'</svg>';
}

private function getIcon(string $name): array
private function getIcon(string $name): Icon
{
[$content, $iconAttr] = $this->registry->get($name);

$iconAttr = array_merge($iconAttr, $this->defaultIconAttributes);

return [$content, $iconAttr];
return $this->registry->get($name)->withAttributes($this->defaultIconAttributes);
}

private static function idFor(string $name): string
{
// @todo Icon::idFor ?

return 'ux-icon-'.str_replace(['/', ':'], ['-', '--'], $name);
}

Expand Down
3 changes: 2 additions & 1 deletion src/Icons/src/Registry/CacheIconRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Symfony\UX\Icons\Exception\IconNotFoundException;
use Symfony\UX\Icons\IconRegistryInterface;
use Symfony\UX\Icons\Svg\Icon;

/**
* @author Kevin Bond <[email protected]>
Expand All @@ -32,7 +33,7 @@ public function __construct(private \Traversable $registries, private CacheInter
{
}

public function get(string $name, bool $refresh = false): array
public function get(string $name, bool $refresh = false): Icon
{
return $this->cache->get(
sprintf('ux-icon-%s', str_replace([':', '/'], ['--', '-'], $name)),
Expand Down
5 changes: 3 additions & 2 deletions src/Icons/src/Registry/LocalSvgIconRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Symfony\Component\Finder\Finder;
use Symfony\UX\Icons\Exception\IconNotFoundException;
use Symfony\UX\Icons\IconRegistryInterface;
use Symfony\UX\Icons\Svg\Icon;

/**
* @author Kevin Bond <[email protected]>
Expand All @@ -28,7 +29,7 @@ public function __construct(private string $iconDir)
{
}

public function get(string $name): array
public function get(string $name): Icon
{
if (!file_exists($filename = sprintf('%s/%s.svg', $this->iconDir, $name))) {
throw new IconNotFoundException(sprintf('The icon "%s" (%s) does not exist.', $name, $filename));
Expand All @@ -43,7 +44,7 @@ public function get(string $name): array
$attributes['viewBox'] = $viewBox;
}

return [$crawler->html(), $attributes];
return new Icon($crawler->html(), $attributes);
}

/**
Expand Down
122 changes: 122 additions & 0 deletions src/Icons/src/Svg/Icon.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

namespace Symfony\UX\Icons\Svg;

/**
*
* @author Simon André <[email protected]>
*
* @internal
*/
final class Icon implements \Stringable, \Serializable, \ArrayAccess
{
public function __construct(
private readonly string $innerSvg,
private readonly array $attributes = [],
)
{
// @todo validate attributes (?)
// the main idea is to have a way to validate the attributes
// before the icon is cached to improve performances
// (avoiding to validate the attributes each time the icon is rendered)
}

public function toHtml(): string
{
$htmlAttributes = '';
foreach ($this->attributes as $name => $value) {
if (false === $value) {
continue;
}
$htmlAttributes .= ' '.$name;
if (true !== $value) {
$value = htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$htmlAttributes .= '="'. $value .'"';
}
}

return '<svg'.$htmlAttributes.'>'.$this->innerSvg.'</svg>';
}

public function getInnerSvg(): string
{
return $this->innerSvg;
}

/**
* @param array<string, string|bool> $attributes
* @return self
*/
public function withAttributes(array $attributes): self
{
foreach ($attributes as $name => $value) {
if (!is_string($name)) {
throw new \InvalidArgumentException(sprintf('Attribute names must be string, "%s" given.', get_debug_type($name)));
}
// @todo regexp would be better ?
if (!ctype_alnum($name) && !str_contains($name, '-')) {
throw new \InvalidArgumentException(sprintf('Invalid attribute name "%s".', $name));
}
if (!is_string($value) && !is_bool($value)) {
throw new \InvalidArgumentException(sprintf('Invalid value type for attribute "%s". Boolean or string allowed, "%s" provided. ', $name, get_debug_type($value)));
}
}

return new self($this->innerSvg, [...$this->attributes, ...$attributes]);
}

public function withInnerSvg(string $innerSvg): self
{
// @todo validate svg ?

// The main idea is to not validate the attributes for every icon
// when they come from a pack (and thus share a set of attributes)

return new self($innerSvg, $this->attributes);
}

public function __toString(): string
{
return $this->toHtml();
}

public function serialize(): string
{
return serialize([$this->innerSvg, $this->attributes]);
}

public function unserialize(string $data): void
{
[$this->innerSvg, $this->attributes] = unserialize($data);
}

public function __serialize(): array
{
return [$this->innerSvg, $this->attributes];
}

public function __unserialize(array $data): void
{
[$this->innerSvg, $this->attributes] = $data;
}

public function offsetExists(mixed $offset): bool
{
return isset($this->attributes[$offset]);
}

public function offsetGet(mixed $offset): mixed
{
return $this->attributes[$offset];
}

public function offsetSet(mixed $offset, mixed $value): void
{
throw new \LogicException('The Icon object is immutable.');
}

public function offsetUnset(mixed $offset): void
{
throw new \LogicException('The Icon object is immutable.');
}
}