14 Commits
main ... 1.1.0

10 changed files with 159 additions and 53 deletions

12
CHANGELOG.md Normal file
View File

@@ -0,0 +1,12 @@
# Changelog
## [X.X.X] - XXXX-XX-XX
- Allow passing in a space-separated string to the `classes` option
- Add new exceptions and messages to make debugging a little bit faster
- Set `aria-hidden` attribute depending on whether a `title` has been set
## [1.1.0] - 2024-08-05
- Changing requirements to work with Symfony 7
## [1.0.0] - 2023-06-27
- First major version release

View File

@@ -1,5 +1,3 @@
# DEPRECATED in favour of Symfony UX Icon
# PCM Icon Bundle # PCM Icon Bundle
Use icons inside of Twig templates with ease! Project must be using Tailwind for the colours to work properly. Use icons inside of Twig templates with ease! Project must be using Tailwind for the colours to work properly.
@@ -64,7 +62,7 @@ COLOUR:
`hover (string)` Name of the colour to use when the icon is hovered over `hover (string)` Name of the colour to use when the icon is hovered over
`classes (string[])` Additional classes to add to the icon. This can cause Tailwind class collisions, so only use it as a last resort. `classes (string[]|string)` Additional classes to add to the icon as either an array of strings or a space-separated string. This can cause Tailwind class collisions, so it is not recommended to use.
## Reminders ## Reminders
Remember to add `./config/packages/*.yaml` as a value in your Tailwind config content array if it does not already exist. This will ensure Tailwind classes inside of the config file get compiled. Remember to add `./config/packages/*.yaml` as a value in your Tailwind config content array if it does not already exist. This will ensure Tailwind classes inside of the config file get compiled.

View File

@@ -1,7 +1,6 @@
{ {
"name": "pcm/icon-bundle", "name": "pcm/icon-bundle",
"description": "Twig icon bundle", "description": "Twig icon bundle",
"type": "symfony-bundle", "type": "symfony-bundle",
"license": "MIT", "license": "MIT",
"authors": [ "authors": [
@@ -10,30 +9,26 @@
"email": "bg@pcmsystems.co.uk" "email": "bg@pcmsystems.co.uk"
}, },
{ {
"name": "Matt Feeney", "name": "Matt Feeny",
"email": "mf@pcmsystems.co.uk" "email": "mf@pcmsystems.co.uk"
} }
], ],
"require": { "require": {
"symfony/dependency-injection": "^6.1", "symfony/dependency-injection": "^6.1 || ^7.0",
"symfony/framework-bundle": "^6.1", "symfony/framework-bundle": "^6.1 || ^7.0",
"symfony/yaml": "^6.1", "symfony/yaml": "^6.1 || ^7.0",
"symfony/http-client": "^6.1", "symfony/http-client": "^6.1 || ^7.0",
"symfony/twig-bundle": "^6.1" "symfony/twig-bundle": "^6.1 || ^7.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^9.5", "phpunit/phpunit": "^9.5",
"symfony/phpunit-bridge": "^6.1" "symfony/phpunit-bridge": "^6.1 || ^7.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Pcm\\IconBundle\\": "src/" "Pcm\\IconBundle\\": "src/"
} }
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"Pcm\\IconBundle\\Tests\\": "tests/" "Pcm\\IconBundle\\Tests\\": "tests/"

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Exception;
class EmptyFileException extends \Exception {};

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Pcm\IconBundle\Exception;
class InvalidSvgException extends \Exception {};

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace Pcm\IconBundle\Twig\Runtime; namespace Pcm\IconBundle\Twig\Runtime;
use Pcm\IconBundle\Exception\ColourNotFound; use Pcm\IconBundle\Exception\ColourNotFound;
use Pcm\IconBundle\Exception\EmptyFileException;
use Pcm\IconBundle\Exception\IconNotFound; use Pcm\IconBundle\Exception\IconNotFound;
use Pcm\IconBundle\Exception\InvalidSvgException;
use Twig\Extension\RuntimeExtensionInterface; use Twig\Extension\RuntimeExtensionInterface;
final class IconRuntime implements RuntimeExtensionInterface final class IconRuntime implements RuntimeExtensionInterface
@@ -16,7 +18,7 @@ final class IconRuntime implements RuntimeExtensionInterface
'size' => null, 'size' => null,
'colour' => null, 'colour' => null,
'hover' => null, 'hover' => null,
'classes' => [], 'classes' => "",
]; ];
public function __construct(private array $defaultOptions, private array $directories, private array $colours) public function __construct(private array $defaultOptions, private array $directories, private array $colours)
@@ -31,7 +33,8 @@ final class IconRuntime implements RuntimeExtensionInterface
* 'size' => (int) Height and width in px * 'size' => (int) Height and width in px
* 'colour' => (string) Main colour * 'colour' => (string) Main colour
* 'hover' => (?string) Hover colour * 'hover' => (?string) Hover colour
* 'classes' => (array) Additional classes to add to the icon. Not recommended. * 'classes' => (string[]|string) Additional classes to add to the icon, given as
* an array of strings or a space-separated string.
* ] * ]
* ``` * ```
* *
@@ -43,6 +46,14 @@ final class IconRuntime implements RuntimeExtensionInterface
$svg = $this->getSvg($options['icon']); $svg = $this->getSvg($options['icon']);
if (empty($svg)) {
throw new EmptyFileException(\sprintf("The file %s.svg was found, but it was empty!", $options['icon']));
}
if (!$this->isValidXml($svg)) {
throw new InvalidSvgException(\sprintf("The file %s.svg was found, but it does not contain valid SVG code!", $options['icon']));
}
$this->sanitiseSvg($svg); $this->sanitiseSvg($svg);
$colourClasses = $this->getColourClasses($options['colour'], $options['hover']); $colourClasses = $this->getColourClasses($options['colour'], $options['hover']);
@@ -51,8 +62,11 @@ final class IconRuntime implements RuntimeExtensionInterface
$classes = trim($colourClasses.' '.$extraClasses); $classes = trim($colourClasses.' '.$extraClasses);
$this->addClassesToSvg($svg, $classes); $this->addClassesToSvg($svg, $classes);
$svg = $this->addAttribute($svg, 'aria-hidden', 'true');
if (null !== $options['title']) { if (null !== $options['title']) {
$this->addTitleToSvg($svg, $options['title']); $this->addTitleToSvg($svg, $options['title']);
$svg = $this->addAttribute($svg, 'aria-hidden', 'false');
} }
$this->setSvgHeightAndWidth($svg, $options['size']); $this->setSvgHeightAndWidth($svg, $options['size']);
@@ -60,9 +74,9 @@ final class IconRuntime implements RuntimeExtensionInterface
return $svg; return $svg;
} }
private function getExtraClasses(array $extraClasses): string private function getExtraClasses(array|string $extraClasses): string
{ {
return implode(' ', $extraClasses); return \is_array($extraClasses) ? implode(' ', $extraClasses) : $extraClasses;
} }
private function mergeWithDefaultOptions(array $userOptions): array private function mergeWithDefaultOptions(array $userOptions): array
@@ -103,6 +117,9 @@ final class IconRuntime implements RuntimeExtensionInterface
$this->removeBlackFillAttributes($svg); $this->removeBlackFillAttributes($svg);
} }
/**
* @throws IconNotFound when an svg file with the passed in name cannot be found
*/
private function findSvgFilepath(string $iconName): string private function findSvgFilepath(string $iconName): string
{ {
foreach ($this->directories as $directory) { foreach ($this->directories as $directory) {
@@ -202,4 +219,23 @@ final class IconRuntime implements RuntimeExtensionInterface
$svg = $this->removeXMLDeclaration($svgAsXml->saveXML()); $svg = $this->removeXMLDeclaration($svgAsXml->saveXML());
} }
private function addAttribute(string $svg, string $attribute, string $value): string
{
$xml = new \SimpleXMLElement($svg);
$xml = $this->addAttributeToXmlElement($xml, $attribute, $value);
return $this->removeXMLDeclaration($xml->saveXML());
}
private function isValidXml(string $input): bool
{
try {
new \SimpleXMLElement($input);
} catch (\Exception) {
return false;
}
return true;
}
} }

View File

@@ -6,7 +6,9 @@ namespace Pcm\IconBundle\Tests\Twig\Functions;
use Pcm\IconBundle\DependencyInjection\Configuration; use Pcm\IconBundle\DependencyInjection\Configuration;
use Pcm\IconBundle\Exception\ColourNotFound; use Pcm\IconBundle\Exception\ColourNotFound;
use Pcm\IconBundle\Exception\EmptyFileException;
use Pcm\IconBundle\Exception\IconNotFound; use Pcm\IconBundle\Exception\IconNotFound;
use Pcm\IconBundle\Exception\InvalidSvgException;
use Pcm\IconBundle\Twig\Runtime\IconRuntime; use Pcm\IconBundle\Twig\Runtime\IconRuntime;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -50,6 +52,18 @@ class IconRuntimeTest extends TestCase
$this->icon->renderIcon(['icon' => random_bytes(8)]); $this->icon->renderIcon(['icon' => random_bytes(8)]);
} }
public function testThrowsIfPassedInAnEmptyFile(): void
{
$this->expectException(EmptyFileException::class);
$this->icon->renderIcon(['icon' => 'empty']);
}
public function testThrowsIfContentsIsNotValidSvg(): void
{
$this->expectException(InvalidSvgException::class);
$this->icon->renderIcon(['icon' => 'invalid']);
}
public function testNoTitleExistsIfNotPassedIn(): void public function testNoTitleExistsIfNotPassedIn(): void
{ {
$content = $this->icon->renderIcon(['icon' => self::ICON]); $content = $this->icon->renderIcon(['icon' => self::ICON]);
@@ -225,19 +239,26 @@ class IconRuntimeTest extends TestCase
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents); $this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents);
} }
public function testExtraClassesThrowsIfNotAnArray(): void public function testExtraClassesThrowsIfNotAnArrayOrString(): void
{ {
$this->expectException(\TypeError::class); $this->expectException(\TypeError::class);
$this->icon->renderIcon(['icon' => self::ICON, 'classes' => 'string_value']); $this->icon->renderIcon(['icon' => self::ICON, 'classes' => 1]);
} }
public function testExtraClassesGetAdded(): void public function testExtraClassesGetAddedFromArray(): void
{ {
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => ['abc', 'def']]); $contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => ['abc', 'def']]);
$this->assertMatchesRegularExpression('/<svg.+class=".*abc.*".*>/', $contents); $this->assertMatchesRegularExpression('/<svg.+class=".*abc.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*def.*".*>/', $contents); $this->assertMatchesRegularExpression('/<svg.+class=".*def.*".*>/', $contents);
} }
public function testExtraClassesGetAddedFromString(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => 'ghi jkl']);
$this->assertMatchesRegularExpression('/<svg.+class=".*ghi.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*jkl.*".*>/', $contents);
}
public function testAddingExtraClassesDoesntStripAwayColourClasses(): void public function testAddingExtraClassesDoesntStripAwayColourClasses(): void
{ {
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => ['abc']]); $contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => ['abc']]);
@@ -259,4 +280,23 @@ class IconRuntimeTest extends TestCase
$this->assertStringNotContainsString($needleA, $contents); $this->assertStringNotContainsString($needleA, $contents);
$this->assertStringNotContainsString($needleB, $contents); $this->assertStringNotContainsString($needleB, $contents);
} }
public function testAriaHiddenAttributeIsAdded(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON]);
$this->assertMatchesRegularExpression('/<svg.+aria-hidden="true".*>/', $contents);
}
public function testAriaHiddenAttributeIsSetToTrueIfAlreadyPresent(): void
{
$contents = $this->icon->renderIcon(['icon' => 'attr']);
$this->assertMatchesRegularExpression('/<svg.+aria-hidden="true".*>/', $contents);
$this->assertDoesNotMatchRegularExpression('/<svg.+aria-hidden="false".*>/', $contents);
}
public function testAriaHiddenAttributeIsSetToFalseIfATitleIsPassedIn(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'title' => 'something']);
$this->assertMatchesRegularExpression('/<svg.+aria-hidden="false".*>/', $contents);
}
} }

10
tests/icons/attr.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg aria-hidden="false" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="96 96 320 320">
<title>Some cross lol</title>
<line fill="rgb(0, 0, 0)"></line>
<line fill="#000"></line>
<line fill="#000000"></line>
<line stroke="currentColor" fill="black"></line>
<line x1="256" y1="112" x2="256" y2="400" width="101" height="101" style="fill: none; stroke: rgb(0,0, 0); stroke-linecap: round; stroke-linejoin: round; stroke-width: 32px;"></line>
<line x1="400" y1="256" x2="112" y2="256" width="102" height="102" style="fill: none; stroke: #000; stroke-linecap: round; stroke-linejoin: round; stroke-width: 32px;"></line>
<polyline points="112 244 256 100 400 244" style="fill:#000;fill:#000000;fill: black;fill:rgb(0,0,0);stroke:#000;stroke:#000000;stroke:black;stroke:rgb(0, 0, 0);stroke-linecap:round;stroke-linejoin:round;stroke-width:48px" stroke='currentColor'/>
</svg>

After

Width:  |  Height:  |  Size: 924 B

0
tests/icons/empty.svg Normal file
View File

1
tests/icons/invalid.svg Normal file
View File

@@ -0,0 +1 @@
<svg><svg>

After

Width:  |  Height:  |  Size: 11 B