10 Commits
0.0.6 ... 0.0.9

Author SHA1 Message Date
Brabli
7469cc78f8 Update readme 2023-01-03 11:00:38 +00:00
Brabli
87a6204f25 Add 'classes' option to add extra classes 2023-01-03 10:58:16 +00:00
Brabli
d5278f3473 Add to readme 2022-12-05 12:44:28 +00:00
Brabli
b2f6e07d97 Remove space 2022-09-22 09:18:34 +01:00
Brabli
9195df9028 Refactoring 2022-08-31 12:13:59 +01:00
Brabli
f82317cf99 Add documentation 2022-08-30 00:03:48 +01:00
Brabli
03212310fd Throw exception if option is not recognised 2022-08-29 23:32:12 +01:00
Brabli
eb2bea62a7 Update readme 2022-08-29 23:17:07 +01:00
Brabli
b28a6f69f9 Make sure required keys exist in pallete arrays 2022-08-29 23:17:00 +01:00
Brabli
9ef5d58a8a Update readme 2022-08-29 21:00:43 +01:00
3 changed files with 235 additions and 90 deletions

View File

@@ -1,22 +1,72 @@
# PCM Icon Bundle
# WIP DON'T USE YET
Use icons inside of Twig templates with ease! Project must be using Tailwind for the colours to work properly.
```php
{{ icon({ icon: 'person' }) }}
```
## Config
Example config:
```yml
```yaml
# config/packages/pcm_icon.yaml
pcm_icon:
directories:
- '%kernel.project_dir%/public/icons'
palletes:
primary:
stroke: 'stroke-primary'
fill: 'fill-primary'
white:
stroke: 'stroke-white'
fill: 'fill-white'
fill: 'fill-primary'
stroke: 'stroke-primary'
fill-hover: 'hover:fill-primary'
stroke-hover: 'hover:stroke-primary'
fill-group-hover: 'group-hover:fill-primary'
stroke-group-hover: 'group-hover:stroke-primary'
red:
stroke: 'stroke-red-400'
fill: 'fill-red-400'
fill: 'fill-red-800'
stroke: 'stroke-red-800'
fill-hover: 'hover:fill-red-800'
stroke-hover: 'hover:stroke-red-800'
fill-group-hover: 'group-hover:fill-red-800'
stroke-group-hover: 'group-hover:stroke-red-800'
```
Remember to add './config/packages/*.yaml' as a value in your Tailwind config content array.
`directories` - Which directories to look in for icons.
`palletes` - Custom colour palletes to use when colouring icons. Because this extension relies on Tailwind classes for colouring we must specify all the classes. This is annoyingly verbose, but you can just copy this template:
```yaml
PALLETE_NAME:
fill: 'fill-COLOUR'
stroke: 'stroke-COLOUR'
fill-hover: 'hover:fill-COLOUR'
stroke-hover: 'hover:stroke-COLOUR'
fill-group-hover: 'group-hover:fill-COLOUR'
stroke-group-hover: 'group-hover:stroke-COLOUR'
```
## Options
`icon (string)` **(REQUIRED)** Icon to use, without the `.svg` extension
`title (string)` Optional text to show on hover
`size (int)` Size of the icon in pixels
`colour (string)` Name of the main colour pallete to use. Defaults to `"primary"`, which is a pallete that uses the project's primary colour. If however you are not using `primary` to set a Tailwind primary colour in your project, you can instead set the default colour of the icon by changing what colour classes the primary pallete uses. EG:
```yaml
primary:
fill: 'fill-purple-700'
stroke: 'stroke-purple-700'
fill-hover: 'hover:fill-purple-700'
stroke-hover: 'hover:stroke-purple-700'
fill-group-hover: 'group-hover:fill-purple-700'
stroke-group-hover: 'group-hover:stroke-purple-700'
```
`hover (string)` Name of the colour pallete to use when the icon is hovered over
`classes (string[])` Additional classes to add to the icon. This can cause Tailwind class collisions, so use with caution if at all.
## 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.

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Pcm\IconBundle\Twig\Functions;
use InvalidArgumentException;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
@@ -13,17 +12,18 @@ final class IconExtension extends AbstractExtension
public const DEFAULT_SIZE = 32;
private const DEFAULT_OPTIONS = [
'icon' => null,
'title' => null,
'size' => self::DEFAULT_SIZE,
'colour' => 'primary',
'hover' => null
'icon' => null,
'title' => null,
'size' => self::DEFAULT_SIZE,
'colour' => 'primary',
'hover' => null,
'classes' => [],
];
public function __construct(private array $directories, private array $palletes)
{
if (empty($this->directories))
throw new InvalidArgumentException('Directories array must contain at least one path!');
throw new \InvalidArgumentException('Directories array must contain at least one path!');
$dirsContainNonString = array_reduce($this->directories,
fn($notString, $path) => $notString || !is_string($path));
@@ -32,16 +32,23 @@ final class IconExtension extends AbstractExtension
throw new \TypeError('Directories array must only contain strings!');
if (empty($this->palletes))
throw new InvalidArgumentException('Palletes array must contain at least one pallet!');
throw new \InvalidArgumentException('Palletes array must contain at least one pallet!');
$pelletesContainNonarray = array_reduce($this->palletes,
$palletesContainNonarray = array_reduce($this->palletes,
fn($notArray, $path) => $notArray || !is_array($path));
if ($pelletesContainNonarray)
if ($palletesContainNonarray)
throw new \TypeError('Palletes array must only contain arrays!');
foreach ($this->palletes as $pallete) {
if (!(array_key_exists('stroke', $pallete) && array_key_exists('fill', $pallete))) {
if (!(
array_key_exists('stroke', $pallete) &&
array_key_exists('fill', $pallete) &&
array_key_exists('fill-hover', $pallete) &&
array_key_exists('stroke-hover', $pallete) &&
array_key_exists('fill-group-hover', $pallete) &&
array_key_exists('stroke-group-hover', $pallete))
) {
throw new \Exception('Palletes must contain a "stroke" and "fill" key!');
}
}
@@ -63,50 +70,41 @@ final class IconExtension extends AbstractExtension
* @param array $options
* ```
* $options = [
* 'icon' => (string) Which icon to use
* 'title' => (?string) Text to appear on mouse hover
* 'size' => (int) Height and width in px
* 'colour' => (string) Main colour pallete
* 'hover' => (?string) Hover colour pallete
* 'icon' => (string) REQUIRED Which icon to use
* 'title' => (?string) Text to appear on mouse hover
* 'size' => (int) Height and width in px
* 'colour' => (string) Main colour pallete
* 'hover' => (?string) Hover colour pallete
* 'classes' => (array) Additional classes to add to the icon.
* Use with caution as this can potentially
* cause Tailwind class conflicts!
* ]
* ```
*/
public function renderIcon(array $userOptions): string
{
$options = $this->mergeUserOptionsWithDefaults($userOptions);
$options = $this->getMergedOptions($userOptions);
$svg = $this->getSanitisedIconSvg($options['icon']);
$iconFilepath = $this->findSvgFilepath($options['icon']);
$rawSvgMarkup = $this->getSvgMarkup($iconFilepath);
$cleanSvgMarkup = $this->cleanSvgMarkup($rawSvgMarkup);
$colourClasses = $this->getColourClasses($options['colour'], $options['hover']);
$extraClasses = $this->getExtraClasses($options['classes']);
$mainPallete = $this->getPallete($options['colour']);
$svg = $this->addClassesToSvg($svg, trim($colourClasses.' '.$extraClasses));
$svg = $this->addTitleToSvgIfNotNull($svg, $options['title']);
$svg = $this->setSvgHeightAndWidth($svg, $options['size']);
return $svg;
}
$colourClasses = "{$mainPallete['stroke']} {$mainPallete['fill']}";
private function getExtraClasses(array $extraClasses): string
{
return implode(' ', $extraClasses);
}
if (null !== $options['hover']) {
$hoverPallete = $this->getPallete($options['hover']);
$colourClasses .= " cursor-pointer hover:{$hoverPallete['stroke']} hover:{$hoverPallete['fill']} group-hover:{$hoverPallete['stroke']} group-hover:{$hoverPallete['fill']}";
}
$xml = new \SimpleXMLElement($cleanSvgMarkup);
$xml = $this->addAttributeToXmlElement($xml, 'class', $colourClasses);
$markup = $this->removeXMLDeclaration($xml->saveXML());
if ($this->isNonEmptyString($options['title']))
$markup = $this->addTitleToMarkup($markup, $options['title']);
if ($options['size'] < 0)
throw new \InvalidArgumentException('Size must not be negative');
if (!is_int($options['size']))
throw new \TypeError('Size value must be an integer');
$markup = $this->setSize($markup, $options['size']);
$markup = $this->removeBlackStrokeAttributes($markup);
$markup = $this->removeBlackFillAttributes($markup);
return $markup;
private function getMergedOptions(array $userOptions): array
{
$this->throwIfUnrecognisedOptionExists($userOptions);
return $this->mergeUserOptionsWithDefaults($userOptions);
}
private function mergeUserOptionsWithDefaults(array $userOptions): array
@@ -114,6 +112,23 @@ final class IconExtension extends AbstractExtension
return array_merge(self::DEFAULT_OPTIONS, $userOptions);
}
private function throwIfUnrecognisedOptionExists(array $options): void
{
foreach (array_keys($options) as $key) {
if (!key_exists($key, self::DEFAULT_OPTIONS))
throw new \InvalidArgumentException("Unrecognised option '{$key}'!");
}
}
private function getSanitisedIconSvg(string $iconName): string
{
$iconFilepath = $this->findSvgFilepath($iconName);
$rawSvgMarkup = $this->getSvgMarkup($iconFilepath);
$cleanSvgMarkup = $this->removeExistingTitleElement($rawSvgMarkup);
$cleanSvgMarkup = $this->removeBlackStrokeAttributes($cleanSvgMarkup);
return $this->removeBlackFillAttributes($cleanSvgMarkup);
}
private function findSvgFilepath(string $iconName): string
{
foreach ($this->directories as $directory) {
@@ -131,9 +146,32 @@ final class IconExtension extends AbstractExtension
return file_get_contents($filepath);
}
private function cleanSvgMarkup(string $markup): string
private function removeExistingTitleElement(string $svg): string
{
return preg_replace('/<title>.*<\/title>/', '', $markup);
return preg_replace('/<title>.*<\/title>/', '', $svg);
}
private function removeBlackStrokeAttributes(string $content): string
{
return preg_replace('/stroke(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $content);
}
private function removeBlackFillAttributes(string $content): string
{
return preg_replace('/fill(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $content);
}
private function getColourClasses(string $primaryColour, ?string $hoverColour): string
{
$mainPallete = $this->getPallete($primaryColour);
$colourClasses = "{$mainPallete['stroke']} {$mainPallete['fill']}";
if (null !== $hoverColour) {
$hoverPallete = $this->getPallete($hoverColour);
$colourClasses .= " cursor-pointer {$hoverPallete['stroke-hover']} {$hoverPallete['fill-hover']} {$hoverPallete['stroke-group-hover']} {$hoverPallete['fill-group-hover']}";
}
return $colourClasses;
}
private function getPallete(string $palleteName): array
@@ -144,36 +182,19 @@ final class IconExtension extends AbstractExtension
throw new PalleteNotFound("The pallete '$palleteName' was not found!");
}
private function isNonEmptyString(mixed $title): bool
private function addClassesToSvg(string $svg, string $classes): string
{
if (!is_string($title) && null !== $title)
throw new \TypeError('Title must be a string!');
if ('' === $title)
throw new \InvalidArgumentException('Title string must not be empty!');
return true;
$xml = new \SimpleXMLElement($svg);
$xml = $this->addAttributeToXmlElement($xml, 'class', $classes);
return $this->removeXMLDeclaration($xml->saveXML());
}
private function addTitleToMarkup(string $markup, ?string $title): string
private function addTitleToSvgIfNotNull(string $svg, ?string $title): string
{
if (null === $title)
return $markup;
if (null === $title) return $svg;
$this->throwIfTitleIsEmpty($title);
return preg_replace('/(<svg(.|\n)*?>\n?)/', "$1<title>$title</title>", $markup);
}
private function setSize(string $content, int $size): string
{
$svgAsXmlElement = new \SimpleXMLElement($content);
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'width', $size);
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'height', $size);
return $this->removeXMLDeclaration($svgAsXmlElement->saveXML());
}
private function removeXMLDeclaration(string $content): string
{
return trim(preg_replace('/<\?xml.*\?>/', '', $content));
return preg_replace('/(<svg(.|\n)*?>\n?)/', "$1<title>$title</title>", $svg);
}
private function addAttributeToXmlElement(\SimpleXMLElement $xml, string $attrName, mixed $attrValue): \SimpleXMLElement
@@ -187,14 +208,30 @@ final class IconExtension extends AbstractExtension
return $xml;
}
private function removeBlackStrokeAttributes(string $content): string
private function removeXMLDeclaration(string $content): string
{
return preg_replace('/stroke(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $content);
return trim(preg_replace('/<\?xml.*\?>/', '', $content));
}
private function removeBlackFillAttributes(string $content): string
private function throwIfTitleIsEmpty(string $title): void
{
return preg_replace('/fill(=|:)"?\s*(#0{6}|#000|rgb\(\s*0,\s*0,\s*0\s*\)|black)\s*"?/', '', $content);
if ('' === $title)
throw new \InvalidArgumentException('Title string must not be empty!');
}
private function setSvgHeightAndWidth(string $content, int $size): string
{
$this->throwIfSizeIsNegative($size);
$svgAsXmlElement = new \SimpleXMLElement($content);
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'width', $size);
$svgAsXmlElement = $this->addAttributeToXmlElement($svgAsXmlElement, 'height', $size);
return $this->removeXMLDeclaration($svgAsXmlElement->saveXML());
}
private function throwIfSizeIsNegative(int $size): void
{
if ($size < 0)
throw new \InvalidArgumentException('Size must not be negative');
}
}

View File

@@ -15,12 +15,20 @@ class IconExtensionTest extends TestCase
private const PALLETES = [
'primary' => [
'fill' => 'fill-primary',
'stroke' => 'stroke-primary',
'fill' => 'fill-primary'
'fill-hover' => 'hover:fill-primary',
'stroke-hover' => 'hover:stroke-primary',
'fill-group-hover' => 'group-hover:fill-primary',
'stroke-group-hover' => 'group-hover:stroke-primary',
],
'white' => [
'fill' => 'fill-white',
'stroke' => 'stroke-white',
'fill' => 'fill-white'
'fill-hover' => 'hover:fill-white',
'stroke-hover' => 'hover:stroke-white',
'fill-group-hover' => 'group-hover:fill-white',
'stroke-group-hover' => 'group-hover:stroke-white',
]
];
@@ -182,13 +190,37 @@ class IconExtensionTest extends TestCase
public function testThrowsIfChildArrayDoesntContainStrokeKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '']]);
new IconExtension(['/'], [['fill' => '', 'fill-hover' => '', 'stroke-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainFillKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['stroke' => '']]);
new IconExtension(['/'], [['stroke' => '', 'fill-hover' => '', 'stroke-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainFillHoverKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'stroke-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainStrokeHoverKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'fill-hover' => '', 'fill-group-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainFillGroupHoverKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'fill-hover' => '', 'stroke-hover' => '', 'stroke-group-hover' => '']]);
}
public function testThrowsIfChildArrayDoesntContainStrokeGroupHoverKey(): void
{
$this->expectException(\Exception::class);
new IconExtension(['/'], [['fill' => '', 'stroke' => '', 'fill-hover' => '', 'stroke-hover' => '', 'fill-group-hover' => '']]);
}
public function testBlackStrokeAttributeValuesAreRemoved(): void
@@ -267,4 +299,30 @@ class IconExtensionTest extends TestCase
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:fill-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents);
}
public function testExtraClassesThrowsIfNotAnArray(): void
{
$this->expectException(\TypeError::class);
$this->icon->renderIcon(['icon' => self::ICON, 'classes' => 'string_value']);
}
public function testExtraClassesGetAdded(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => ['abc', 'def']]);
$this->assertMatchesRegularExpression('/<svg.+class=".*abc.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*def.*".*>/', $contents);
}
public function testAddingExtraClassesDoesntStripAwayColourClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'classes' => ['abc']]);
$this->assertMatchesRegularExpression('/<svg.+class=".*abc.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*fill-primary.*".*>/', $contents);
}
public function testThrowsIfInvalidOptionPassed(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->icon->renderIcon(['icon' => self::ICON, 'fake-key-should-throw' => null]);
}
}