21 Commits
0.0.0 ... 0.0.8

Author SHA1 Message Date
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
Brabli
a4344687d4 Add cursor pointer class on hover 2022-08-29 20:29:04 +01:00
Brabli
4ec6224b25 Remove space 2022-08-29 20:27:48 +01:00
Brabli
7a3366c2da Add group hover 2022-08-29 20:12:55 +01:00
Brabli
78a321137e Get hover colours added 2022-08-29 20:08:27 +01:00
Brabli
5e3756773e Get tests passing 2022-08-29 20:01:39 +01:00
Brabli
6553d0d32f Change method name 2022-08-29 19:43:18 +01:00
Brabli
92eee8fa04 Add info 2022-08-29 19:41:57 +01:00
Brabli
e12eceed26 Adjust comment spacing 2022-08-29 19:41:54 +01:00
Brabli
27dfa0af2e Test for black fill and stroke style removal 2022-08-29 19:31:27 +01:00
Brabli
3e4b948569 Add example config 2022-08-29 19:21:08 +01:00
Brabli
e72ce28af4 Remove args from services.yaml 2022-08-29 19:18:47 +01:00
Brabli
4329780c39 See if moving args down works 2022-08-29 18:38:48 +01:00
Brabli
f7efd93cec Add dollar sign prefix 2022-08-29 18:32:35 +01:00
Brabli
bac02c285f Change key 2022-08-29 18:31:35 +01:00
Brabli
e60cfd7279 Add config to config file 2022-08-29 18:23:38 +01:00
4 changed files with 246 additions and 80 deletions

View File

@@ -1,3 +1,60 @@
# PCM Icon Bundle
# WIP DON'T USE YET
Use icons inside of Twig templates with ease!
```php
{{ icon({ icon: 'person' }) }}
```
## Config
Example config:
```yaml
# config/packages/pcm_icon.yaml
pcm_icon:
directories:
- '%kernel.project_dir%/public/icons'
palletes:
primary:
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:
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'
```
`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"`, assuming that a pallete with the project's primary colour will exist
`hover (string)` Name of the colour pallete to use when the icon is hovered over
## Reminders
Remember to add `./config/packages/*.yaml` as a value in your Tailwind config content array if it does not already exist.

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Pcm\IconBundle\Twig\Functions;
use InvalidArgumentException;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
@@ -16,13 +15,14 @@ final class IconExtension extends AbstractExtension
'icon' => null,
'title' => null,
'size' => self::DEFAULT_SIZE,
'colour' => 'primary'
'colour' => 'primary',
'hover' => null
];
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));
@@ -31,7 +31,7 @@ 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,
fn($notArray, $path) => $notArray || !is_array($path));
@@ -40,7 +40,14 @@ final class IconExtension extends AbstractExtension
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!');
}
}
@@ -62,41 +69,30 @@ final class IconExtension extends AbstractExtension
* @param array $options
* ```
* $options = [
* 'icon' => (string) Which icon to use
* '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
* 'size' => (int) Height and width in px
* 'colour' => (string) Main colour pallete
* 'hover' => (?string) Hover colour pallete
* ]
* ```
*/
public function renderIcon(array $userOptions): string
{
$options = $this->mergeUserOptionsWithDefaults($userOptions);
$options = $this->getMergedOptions($userOptions);
$svg = $this->getSanitisedIconSvg($options['icon']);
$colourClasses = $this->getColourClasses($options['colour'], $options['hover']);
$svg = $this->addClassesToSvg($svg, $colourClasses);
$svg = $this->addTitleToSvgIfNotNull($svg, $options['title']);
$svg = $this->setSvgHeightAndWidth($svg, $options['size']);
$iconFilepath = $this->findSvgFilepath($options['icon']);
$rawSvgMarkup = $this->getSvgMarkup($iconFilepath);
$cleanSvgMarkup = $this->cleanSvgMarkup($rawSvgMarkup);
return $svg;
}
$mainPallete = $this->getMainPallete($options['colour']);
$xml = new \SimpleXMLElement($cleanSvgMarkup);
$xml = $this->addAttributeToXmlElement($xml, 'class', $mainPallete['stroke'] . ' ' . $mainPallete['fill']);
$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
@@ -104,6 +100,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) {
@@ -121,12 +134,35 @@ 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 getMainPallete(string $palleteName): array
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
{
if (array_key_exists($palleteName, $this->palletes))
return $this->palletes[$palleteName];
@@ -134,36 +170,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
@@ -177,27 +196,33 @@ 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');
}
}
class IconNotFound extends \Exception {};
class PalleteNotFound extends \Exception {};
// class="stroke-neutral-400 fill-neutral-400"
// hover:stroke-ses-highlight
// group-hover:stroke-ses-highlight
// hover:fill-ses-highlight
// group-hover:fill-ses-highlight
// cursor-pointer

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
@@ -200,6 +232,15 @@ class IconExtensionTest extends TestCase
$this->assertDoesNotMatchRegularExpression('/stroke="\s*rgb\(0,\s*0,\s*0\)\s*"/', $content);
}
public function testBlackStrokeStylesAreRemoved(): void
{
$content = $this->icon->renderIcon(['icon' => self::ICON]);
$this->assertDoesNotMatchRegularExpression('/stroke:\s*#000\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/stroke:\s*#000000\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/stroke:\s*black\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/stroke:\s*rgb\(0,\s*0,\s*0\)\s*/', $content);
}
public function testBlackFillAttributeValuesAreRemoved(): void
{
$content = $this->icon->renderIcon(['icon' => self::ICON]);
@@ -209,7 +250,16 @@ class IconExtensionTest extends TestCase
$this->assertDoesNotMatchRegularExpression('/fill="\s*rgb\(0,\s*0,\s*0\)\s*"/', $content);
}
public function testThrowsIfPalleteIsNotFound(): void
public function testBlackFillStylesAreRemoved(): void
{
$content = $this->icon->renderIcon(['icon' => self::ICON]);
$this->assertDoesNotMatchRegularExpression('/fill:\s*#000\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/fill:\s*#000000\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/fill:\s*black\s*/', $content);
$this->assertDoesNotMatchRegularExpression('/fill:\s*rgb\(0,\s*0,\s*0\)\s*/', $content);
}
public function testThrowsIfColourPalleteIsNotFound(): void
{
$this->expectException(PalleteNotFound::class);
$this->icon->renderIcon(['icon' => self::ICON, 'colour' => 'red']);
@@ -222,5 +272,38 @@ class IconExtensionTest extends TestCase
$this->assertMatchesRegularExpression('/<svg.*?class=".*?stroke-white?.*>/', $contents);
}
public function testThrowsIfHoverPalleteIsNotFound(): void
{
$this->expectException(PalleteNotFound::class);
$this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'red']);
}
public function testSvgClassContainsHoverPalleteClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'white']);
$this->assertMatchesRegularExpression('/<svg.+class=".*cursor-pointer.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*hover:fill-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*hover:stroke-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:fill-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents);
}
public function testSvgClassContainsHoverAndColourPalleteClasses(): void
{
$contents = $this->icon->renderIcon(['icon' => self::ICON, 'hover' => 'white', 'colour' => 'primary']);
$this->assertMatchesRegularExpression('/<svg.+class=".*fill-primary.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*stroke-primary.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*cursor-pointer.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*hover:fill-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*hover:stroke-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:fill-white.*".*>/', $contents);
$this->assertMatchesRegularExpression('/<svg.+class=".*group-hover:stroke-white.*".*>/', $contents);
}
public function testThrowsIfInvalidOptionPassed(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->icon->renderIcon(['icon' => self::ICON, 'fake-key-should-throw' => null]);
}
}

View File

@@ -6,4 +6,5 @@
<line 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"/>
</svg>

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 860 B