Skip to content

Commit

Permalink
Feat(web-twig): Allow Icon to render as a Symbol tag #DS-1223
Browse files Browse the repository at this point in the history
  • Loading branch information
crishpeen committed May 16, 2024
1 parent d331d79 commit e6079a8
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 67 deletions.
4 changes: 3 additions & 1 deletion packages/web-twig/src/Resources/components/Icon/Icon.twig
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
{%- set props = props | default([]) -%}
{%- set _ariaHidden = props.ariaHidden ?? true -%}
{%- set _isReusable = props.isReusable ?? true -%}
{%- set _isSymbol = props.isSymbol | default(false) -%}
{%- set _name = props.name -%}
{%- set _boxSize = props.boxSize | default('24') -%}
{%- set _title = props.title | default(null) -%}

{# Miscellaneous #}
{%- set _styleProps = useStyleProps(props) -%}
{%- set _symbolName = _isSymbol ? _name : '' -%}
{%- set _mainProps = props | filter((value, prop) => prop is not same as('ariaHidden')) | merge({
'aria-hidden': _ariaHidden ? 'true' : 'false',
}) -%}

{{ inlineSvg('@icons-assets/' ~ _name ~ '.svg', { class: _styleProps.className, style: _styleProps.style, size: _boxSize, title: _title, mainProps: _mainProps }, _isReusable) }}
{{ inlineSvg('@icons-assets/' ~ _name ~ '.svg', { class: _styleProps.className, style: _styleProps.style, size: _boxSize, title: _title, mainProps: _mainProps }, _isReusable, false, _symbolName) }}
42 changes: 33 additions & 9 deletions packages/web-twig/src/Resources/components/Icon/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,45 @@ Without lexer:
}} %}{% endembed %}
```

## Render as Symbol

If you need to prerender the icon as a [symbol][mdn-symbol], you can use the `isSymbol` prop:

```html
<Icon name="warning" isSymbol />
```

The ID of the symbol will be the same as the `name` prop and the whole SVG element will be hidden.

⚠️ Please note that SVG IDs are global and you might encounter ID conflicts if you use the same in
`name` prop as an existing element on your site.

👉 Even though the `svg` only includes the `symbol` element, the `svg` still takes some space in browser,
so you might want to hide it using either wrapping element with `hidden` attribute or use CSS.

```html
<div hidden>
<Icon name="warning" isSymbol />
</div>
```

## API

| Name | Type | Default | Required | Description |
| ------------ | -------- | ------- | -------- | ------------------------------------- |
| `ariaHidden` | `bool` | `true` || If true, icon is hidden from a11y API |
| `boxSize` | `number` | `24` || Size of the icon |
| `isReusable` | `bool` | `true` || Enables reusability of SVG icons |
| `name` | `string` ||| Name of the icon, case sensitive |
| `title` | `string` | `null` || Optional title to display on hover |
| Name | Type | Default | Required | Description |
| ------------ | -------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| `ariaHidden` | `bool` | `true` || If true, icon is hidden from a11y API |
| `boxSize` | `number` | `24` || Size of the icon |
| `isReusable` | `bool` | `true` || Enables reusability of SVG icons |
| `isSymbol` | `bool` | `false` || If true, the element will be rendered as SVG symbol with the name assigned to the ID attribute, other props are skipped |
| `name` | `string` ||| Name of the icon, case sensitive |
| `title` | `string` | `null` || Optional title to display on hover |

Get the list of `name` options in the [Icon package][icon-package] or your source of icons.

Also, UNSAFE styling props are available, see the [Escape hatches][escape-hatches]
section in README to learn how and when to use them.

[inlinesvg-docs]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web-twig/docs/inlineSVG.md
[icon-package]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/icons
[escape-hatches]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web-twig/README.md#escape-hatches
[icon-package]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/icons
[inlinesvg-docs]: https://github.com/lmc-eu/spirit-design-system/tree/main/packages/web-twig/docs/inlineSVG.md
[mdn-symbol]: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/symbol
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Icon name="warning" />

<!-- Render with title -->
<Icon name="warning" title="This is warning!" />

<!-- Render with boxSize -->
<Icon name="warning" boxSize="32" />

<!-- Render as symbol -->
<Icon name="info" isSymbol />

<!-- Render with all props -->
<Icon
ariaHidden={ false }
boxSize="48"
isReusable={ false }
name="info"
title="This is info!"
/>

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@
<body>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewbox="0 0 24 24" fill="none" id="f0c4c080075841bbc911dd74dc9df8be" aria-hidden="true">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 21.9991C17.5228 21.9991 22 17.522 22 11.9991C22 6.4763 17.5228 1.99915 12 1.99915C6.47715 1.99915 2 6.4763 2 11.9991C2 17.522 6.47715 21.9991 12 21.9991ZM12 12C11.45 12 11 11.55 11 11V9C11 8.45 11.45 8 12 8C12.55 8 13 8.45 13 9V11C13 11.55 12.55 12 12 12ZM13 14V16H11V14H13Z" fill="currentColor">
</path></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewbox="0 0 24 24" fill="none" id="bb6d031b3d8f5bbf783f291e56d73a0a" aria-hidden="true">
</path></svg> <!-- Render with title -->
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewbox="0 0 24 24" fill="none" id="bb6d031b3d8f5bbf783f291e56d73a0a" aria-hidden="true">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 21.9991C17.5228 21.9991 22 17.522 22 11.9991C22 6.4763 17.5228 1.99915 12 1.99915C6.47715 1.99915 2 6.4763 2 11.9991C2 17.522 6.47715 21.9991 12 21.9991ZM12 12C11.45 12 11 11.55 11 11V9C11 8.45 11.45 8 12 8C12.55 8 13 8.45 13 9V11C13 11.55 12.55 12 12 12ZM13 14V16H11V14H13Z" fill="currentColor">
</path>
<title>
This is warning!
</title></svg> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewbox="0 0 24 24" fill="none" id="8bc4779b0487f150e0300458b1973f89" aria-hidden="true">
</title></svg> <!-- Render with boxSize -->
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewbox="0 0 24 24" fill="none" id="88980d19ca0d40366928acb0ed6d6f1c" aria-hidden="true">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 21.9991C17.5228 21.9991 22 17.522 22 11.9991C22 6.4763 17.5228 1.99915 12 1.99915C6.47715 1.99915 2 6.4763 2 11.9991C2 17.522 6.47715 21.9991 12 21.9991ZM12 12C11.45 12 11 11.55 11 11V9C11 8.45 11.45 8 12 8C12.55 8 13 8.45 13 9V11C13 11.55 12.55 12 12 12ZM13 14V16H11V14H13Z" fill="currentColor">
</path></svg> <!-- Render as symbol -->
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="info" viewbox="0 0 24 24">
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM13 17H11V11H13V17ZM13 9H11V7H13V9Z" fill="currentColor">
</path>
</symbol></svg> <!-- Render with all props -->
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewbox="0 0 24 24" fill="none" aria-hidden="false">
<path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM13 17H11V11H13V17ZM13 9H11V7H13V9Z" fill="currentColor">
</path>
<title>
This is warning!
This is info!
</title></svg>
</body>
</html>
121 changes: 76 additions & 45 deletions packages/web-twig/src/Twig/SvgExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ class SvgExtension extends AbstractExtension
{
private const ALLOW_EXTENSION = '.svg';

private const ATTR = 'attr';

private const ATTR_CLASS = 'class';

private const ATTR_STYLE = 'style';
Expand Down Expand Up @@ -48,6 +46,8 @@ class SvgExtension extends AbstractExtension

private const SIMPLE_XML_ROOT_DECLARATION = "<?xml version=\"1.0\"?>\n";

private const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';

private LoggerInterface $logger;

public function __construct(LoggerInterface $logger)
Expand Down Expand Up @@ -80,7 +80,7 @@ private function replaceXmlDeclaration($svgElement): string
/**
* @param array<string, string|array<string>> $params
*/
public function getInlineSvg(Environment $environment, string $path, array $params = [], bool $reUsage = true, bool $ignoreMissing = false): string
public function getInlineSvg(Environment $environment, string $path, array $params = [], bool $reUsage = true, bool $ignoreMissing = false, ?string $symbolName = ''): string
{
$loader = $environment->getLoader();
$twigSource = null;
Expand All @@ -98,6 +98,7 @@ public function getInlineSvg(Environment $environment, string $path, array $para
$this->logger->critical('Missing svg "{path}"', [
'path' => $path,
]);

return '';
}
}
Expand All @@ -109,13 +110,13 @@ public function getInlineSvg(Environment $environment, string $path, array $para
}

if (! $reUsage) {
$svgElement = $this->makeRegularSvg($twigSource, null, $params);
$svgElement = $this->makeRegularSvg($twigSource, null, $params, $symbolName);

return $this->replaceXmlDeclaration($svgElement);
}

if (! array_key_exists($iconId, $this->cacheIcon)) {
$this->cacheIcon[$iconId] = $this->makeRegularSvg($twigSource, $iconId, $params);
$this->cacheIcon[$iconId] = $this->makeRegularSvg($twigSource, $iconId, $params, $symbolName);

return $this->cacheIcon[$iconId] !== false ? $this->replaceXmlDeclaration($this->cacheIcon[$iconId]) : '';
}
Expand All @@ -133,68 +134,98 @@ public function getInlineSvg(Environment $environment, string $path, array $para
* @param array<string, string|array<string>> $params
* @return false | SimpleXMLElement
*/
private function makeRegularSvg(?Source $source, ?string $iconId, array $params = [])
private function makeRegularSvg(?Source $source, ?string $iconId, array $params = [], ?string $symbolName = '')
{
if (! $source instanceof Source) {
return false;
}

$svgString = $source->getCode();
$svg = @simplexml_load_string($svgString);

if ($svg === false) {
$this->logger->error('Error parsing SVG by simplexml_load_string from {class} in path "{path}"', [
'class' => Source::class,
'path' => $source->getPath(),
]);
return false;
}

$this->applyAttributes($svg, $params, $iconId);

if ($symbolName && $symbolName !== '') {
$svgWithSymbol = new SimpleXMLElement('<svg xmlns="' . self::SVG_NAMESPACE . '"></svg>');
$symbol = $svgWithSymbol->addChild('symbol');
$symbol->addAttribute('id', $symbolName);

if (isset($svg['viewBox'])) {
$symbol->addAttribute('viewBox', (string) $svg['viewBox']);
}

foreach ($svg->children() as $child) {
$this->simplexmlAppend($symbol, $child);
}

$svg = $svgWithSymbol;
}

return $svg;
}

/**
* Utility function to append one SimpleXMLElement to another
*/
private function simplexmlAppend(SimpleXMLElement $to, SimpleXMLElement $from): void
{
$toDom = dom_import_simplexml($to);
$fromDom = dom_import_simplexml($from);

if ($toDom->ownerDocument) {
$toDom->appendChild($toDom->ownerDocument->importNode($fromDom, true));
}
}

/**
* @param array<string, string|array<string>> $params
*/
private function applyAttributes(SimpleXMLElement $svg, array $params, ?string $iconId): void
{
$hasClasses = array_key_exists(self::ATTR_CLASS, $params);
$hasStyles = array_key_exists(self::ATTR_STYLE, $params);
$hasMainProps = array_key_exists(self::ATTR_MAIN_PROPS, $params);
$hasSize = array_key_exists(self::ATTR_SIZE, $params);
$hasTitle = array_key_exists(self::ATTR_TITLE, $params);
$hasAttributes = array_key_exists(self::ATTR, $params);

$svg = @simplexml_load_string($svgString);

if ($svg !== false) {
if ($iconId !== null) {
$this->replaceAttribute($svg, self::ATTR_ID, $iconId);
}
if ($iconId !== null) {
$this->replaceAttribute($svg, self::ATTR_ID, $iconId);
}

if ($hasClasses && is_string($params[self::ATTR_CLASS])) {
$this->replaceAttribute($svg, self::ATTR_CLASS, $params[self::ATTR_CLASS]);
}
if ($hasClasses && is_string($params[self::ATTR_CLASS])) {
$this->replaceAttribute($svg, self::ATTR_CLASS, $params[self::ATTR_CLASS]);
}

if ($hasStyles && is_string($params[self::ATTR_STYLE])) {
$this->replaceAttribute($svg, self::ATTR_STYLE, $params[self::ATTR_STYLE]);
}
if ($hasStyles && is_string($params[self::ATTR_STYLE])) {
$this->replaceAttribute($svg, self::ATTR_STYLE, $params[self::ATTR_STYLE]);
}

if ($hasSize && is_string($params[self::ATTR_SIZE]) && trim($params[self::ATTR_SIZE]) !== '') {
$this->replaceAttribute($svg, 'width', $params[self::ATTR_SIZE]);
$this->replaceAttribute($svg, 'height', $params[self::ATTR_SIZE]);
}
if ($hasSize && is_string($params[self::ATTR_SIZE]) && trim($params[self::ATTR_SIZE]) !== '') {
$this->replaceAttribute($svg, 'width', $params[self::ATTR_SIZE]);
$this->replaceAttribute($svg, 'height', $params[self::ATTR_SIZE]);
}

if ($hasMainProps && is_array($params[self::ATTR_MAIN_PROPS])) {
foreach ($params[self::ATTR_MAIN_PROPS] as $propName => $propValue) {
if (preg_match('/^(data|aria)-*/', $propName) > 0) {
if (trim($propValue) !== '') {
$this->replaceAttribute($svg, $propName, $propValue);
}
if ($hasMainProps && is_array($params[self::ATTR_MAIN_PROPS])) {
foreach ($params[self::ATTR_MAIN_PROPS] as $propName => $propValue) {
if (preg_match('/^(data|aria)-*/', $propName) > 0) {
if (trim($propValue) !== '') {
$this->replaceAttribute($svg, $propName, $propValue);
}
}
}

if ($hasTitle && is_string($params[self::ATTR_TITLE]) && trim($params[self::ATTR_TITLE]) !== '') {
$svg->addChild(self::ATTR_TITLE, htmlspecialchars($params[self::ATTR_TITLE], ENT_QUOTES));
}

if ($hasAttributes && is_array($params[self::ATTR])) {
foreach ($params[self::ATTR] as $key => $value) {
$this->replaceAttribute($svg, $key, $value);
}
}
} else {
$this->logger->error('Error parse svg by simplexml_load_string from {class} in path "{path}"', [
'class' => Source::class,
'path' => $source->getPath(),
]);
}

return $svg;
if ($hasTitle && is_string($params[self::ATTR_TITLE]) && trim($params[self::ATTR_TITLE]) !== '') {
$svg->addChild(self::ATTR_TITLE, htmlspecialchars($params[self::ATTR_TITLE], ENT_QUOTES));
}
}

/**
Expand Down
16 changes: 10 additions & 6 deletions packages/web-twig/tests/Twig/SvgExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public function setUp(): void
* @param array<string, string> $params
* @dataProvider getSvgDataProvider
*/
public function testShouldGetInlineSvg(string $filePath, string $fileResultPath, array $params = []): void
public function testShouldGetInlineSvg(string $filePath, string $fileResultPath, array $params = [], ?string $symbolName = ''): void
{
$fixturesPattern = __DIR__ . '/../fixtures/%s';
$source = file_get_contents(sprintf($fixturesPattern, $filePath));
Expand All @@ -50,7 +50,7 @@ public function testShouldGetInlineSvg(string $filePath, string $fileResultPath,
->withNoArgs()
->andReturn($loaderMock);

$result = $this->svgExtension->getInlineSvg($environmentMock, $filePath, $params);
$result = $this->svgExtension->getInlineSvg($environmentMock, $filePath, $params, true, false, $symbolName);

$this->assertXmlStringEqualsXmlString((string) $expectedSource, $result);
}
Expand Down Expand Up @@ -98,16 +98,18 @@ public function testShouldNotGetInlineSvgForInvalidSource(): void
->withNoArgs()
->andReturn($loaderMock);

// Make sure to set up your mock to expect the error log with exact arguments
$this->loaderInterface->shouldReceive('error')
->with('Error parse svg by simplexml_load_string from {class} in path "{path}"', [
->with('Error parsing SVG by simplexml_load_string from {class} in path "{path}"', [
'class' => Source::class,
'path' => 'test.svg',
'path' => $filePath,
])
->once();
->once()
->andReturn();

$result = $this->svgExtension->getInlineSvg($environmentMock, $filePath);

$this->assertTrue($result === '');
$this->assertSame('', $result);
}

public function testShouldReuseSvg(): void
Expand Down Expand Up @@ -158,6 +160,8 @@ public function getSvgDataProvider(): array
'test.svg', 'test_with_style.svg', [
'style' => 'position: absolute;',
], ],
'load with symbol' => [
'test.svg', 'test_with_symbol.svg', [], 'test', ],
];
}
}
Loading

0 comments on commit e6079a8

Please sign in to comment.