Skip to content

Commit

Permalink
WIP - html_attributes function
Browse files Browse the repository at this point in the history
  • Loading branch information
leevigraham committed Oct 23, 2024
1 parent af40907 commit 93fadaa
Show file tree
Hide file tree
Showing 2 changed files with 392 additions and 0 deletions.
162 changes: 162 additions & 0 deletions extra/html-extra/HtmlAttributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

namespace Twig\Extra\Html;

use Twig\Error\RuntimeError;

final class HtmlAttributes
{
/**
* Merges multiple attribute group arrays into a single array.
*
* `HtmlAttributes::merge(['id' => 'a', 'disabled' => true], ['hidden' => true])` becomes
* `['id' => 'a', 'disabled' => true, 'hidden' => true]`
*
* attributes override each other in the order they are provided.
*
* `HtmlAttributes::merge(['id' => 'a'], ['id' => 'b'])` becomes `['id' => 'b']`.
*
* However, `class` and `style` attributes are merged into an array so they can be concatenated in later processing.
*
* `HtmlAttributes::merge(['class' => 'a'], ['class' => 'b'], ['class' => 'c'])` becomes
* `['class' => ['a' => true, 'b' => true, 'c' => true]]`.
*
* style attributes are also merged into an array so they can be concatenated in later processing.
* style attributes are split into key, value pairs.
*
* `HtmlAttributes::merge(['style' => 'color: red'], ['style' => 'background-color: blue'])` becomes
* `['style' => ['color' => 'red', 'background-color' => 'blue']]`.
*
* style attributes which are arrays with false and null values are also processed
*
* `HtmlAttributes::merge(['style' => ['color: red' => true]], ['style' => ['display: block' => false]]) becomes
* `['style' => ['color' => 'red', 'display' => false]]`.
*
* attributes can be provided as an array of key, value where the value can be true, false or null.
*
* Example:
* `HtmlAttributes::merge(['class' => ['a' => true, 'b' => false], ['class' => ['c' => null']])` becomes
* `['class' => ['a' => true, 'b' => false, 'c' => null]]`.
*
* `aria` and `data` arrays are expanded into `aria-*` and `data-*` attributes before further processing.
*
* Example:
*
* `HtmlAttributes::merge([data' => ['count' => '1']])` becomes `['data-count' => '1']`.
* `HtmlAttributes::merge(['aria' => ['hidden' => true]])` becomes `['aria-hidden' => true]`.
*
* @see ./Tests/HtmlAttributesTest.php for usage examples
*
* @param ...$attributeGroup
* @return array
* @throws RuntimeError
*/
public static function merge(...$attributeGroup): array
{
$result = [];

$attributeGroupCount = 0;

foreach ($attributeGroup as $attributes) {

$attributeGroupCount++;

// Skip empty attributes
// Return early if no attributes are provided
// This could be false or null when using the twig ternary operator
if(!$attributes) {
continue;
}

if (!is_iterable($attributes)) {
throw new RuntimeError(sprintf('"%s" only works with mappings or "Traversable", got "%s" for argument %d.', self::class, \gettype($attributes), $attributeGroupCount));
}

// Alternative to is_iterable check above, cast the attributes to an array
// This would produce weird results but would not throw an error
// $attributes = (array)$attributes;

// data and aria arrays are expanded into data-* and aria-* attributes
$expanded = [];
foreach ($attributes as $key => $value) {
if (in_array($key, ['data', 'aria'])) {
$value = (array)$value;
foreach ($value as $k => $v) {
$k = $key . '-' . $k;
$expanded[$k] = $v;
}
continue;
}
$expanded[$key] = $value;
}

// Reset the attributes array to the flattened version
$attributes = $expanded;

foreach ($attributes as $key => $value) {

// Treat class and data-controller attributes as arrays
if (in_array($key, [
'class',
'data-controller',
'data-action',
'data-targets',
])) {
if (!array_key_exists($key, $result)) {
$result[$key] = [];
}
$value = (array)$value;
foreach ($value as $k => $v) {
if (is_int($k)) {
$classes = explode(' ', $v);
foreach ($classes as $class) {
$result[$key][$class] = true;
}
} else {
$classes = explode(' ', $k);
foreach ($classes as $class) {
$result[$key][$class] = $v;
}
}
}
continue;
}

if ($key === 'style') {
if (!array_key_exists('style', $result)) {
$result['style'] = [];
}
$value = (array)$value;
foreach ($value as $k => $v) {
if (is_int($k)) {
$styles = array_filter(explode(';', $v));
foreach ($styles as $style) {
$style = explode(':', $style);
$sKey = trim($style[0]);
$sValue = trim($style[1]);
$result['style'][$sKey] = $sValue;
}
} elseif (is_bool($v) || is_null($v)) {
$styles = array_filter(explode(';', $k));
foreach ($styles as $style) {
$style = explode(':', $style);
$sKey = trim($style[0]);
$sValue = trim($style[1]);
$result['style'][$sKey] = $v ? $sValue : $v;
}
} else {
$sKey = trim($k);
$sValue = trim($v);
$result['style'][$sKey] = $sValue;
}
}
continue;
}

$result[$key] = $value;
}
}

return $result;
}
}
230 changes: 230 additions & 0 deletions extra/html-extra/Tests/HtmlAttributesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
<?php

namespace Twig\Extra\Html\Tests;

use PHPUnit\Framework\TestCase;
use Twig\Error\RuntimeError;
use Twig\Extra\Html\HtmlAttributes;

class HtmlAttributesTest extends TestCase
{
/**
* @dataProvider htmlAttrProvider
* @throws RuntimeError
*/
public function testMerge(array $input, array $expected)
{
$result = HtmlAttributes::merge(...$input);
self::assertSame($expected, $result);
}

public function testNonIterableAttributeValuesThrowException()
{
$this->expectException(\Twig\Error\RuntimeError::class);
$result = HtmlAttributes::merge(['class' => 'a'], 'b');
}

/**
* Tests output of HtmlAttributes::merge() method can be used as an array of attributes.
* @return void
* @throws RuntimeError
*/
public function testMultipleMerge()
{
$result1 = HtmlAttributes::merge(['a' => 'b', 'c' => 'd'],
true ? ['e' => 'f'] : null,
false ? ['g' => 'h'] : null,
['i' => true],
['j' => true],
['j' => false],
['k' => true],
['k' => null]
);

$result2 = HtmlAttributes::merge(
['class' => 'a b j'],
['class' => ['c', 'd', 'e f']],
['class' => ['g' => true, 'h' => false, 'i' => true]],
['class' => ['h' => true]],
['class' => ['i' => false]],
['class' => ['j' => null]],
);

$result = HtmlAttributes::merge($result1, $result2);

self::assertSame([
'a' => 'b',
'c' => 'd',
'e' => 'f',
'i' => true,
'j' => false,
'k' => null,
'class' => [
'a' => true,
'b' => true,
'j' => null,
'c' => true,
'd' => true,
'e' => true,
'f' => true,
'g' => true,
'h' => true,
'i' => false,
]
], $result);
}


public function htmlAttrProvider(): \Generator
{
yield 'merging basic attributes' => [
[
['a' => 'b', 'c' => 'd'],
true ? ['e' => 'f'] : null,
false ? ['g' => 'h'] : null,
['i' => true],
['j' => true],
['j' => false],
['k' => true],
['k' => null],
],
[
'a' => 'b',
'c' => 'd',
'e' => 'f',
'i' => true,
'j' => false,
'k' => null
],
];

/**
* class attributes are merged into an array so they can be concatenated in later processing.
*/
yield 'merging class attributes' => [
[
['class' => 'a b j'],
['class' => ['c', 'd', 'e f']],
['class' => ['g' => true, 'h' => false, 'i' => true]],
['class' => ['h' => true]],
['class' => ['i' => false]],
['class' => ['j' => null]],
],
['class' => [
'a' => true,
'b' => true,
'j' => null,
'c' => true,
'd' => true,
'e' => true,
'f' => true,
'g' => true,
'h' => true,
'i' => false,
]],
];

/**
* style attributes are merged into an array so they can be concatenated in later processing.
* style strings are split into key, value pairs eg. 'color: red' becomes ['color' => 'red']
* style attributes which are arrays with false and null values are also processed
* false and null values override string values eg. ['display: block' => false] becomes ['display' => false]
*/
yield 'merging style attributes' => [
[
['style' => 'a: b;'],
['style' => ['c' => 'd', 'e' => 'f']],
['style' => ['g: h;']],
['style' => [
'i: j; k: l' => true,
'm: n' => false,
'o: p' => null
]],
],
['style' => [
'a' => 'b',
'c' => 'd',
'e' => 'f',
'g' => 'h',
'i' => 'j',
'k' => 'l',
'm' => false,
'o' => null,
]],
];

/**
* `data` arrays are expanded into `data-*` attributes before further processing.
*/
yield 'merging data-* attributes' => [
[
['data-a' => 'a'],
['data-b' => 'b'],
['data-c' => true],
['data-d' => false],
['data-e' => null],
['data-f' => ['a' => 'b']],
['data' => ['g' => 'g', 'h' => true]],
['data-h' => false],
['data-h' => 'h'],
],
[
'data-a' => 'a',
'data-b' => 'b',
'data-c' => true,
'data-d' => false,
'data-e' => null,
'data-f' => ['a' => 'b'],
'data-g' => 'g',
'data-h' => 'h',
],
];

/**
* `aria` arrays are expanded into `aria-*` attributes before further processing.
*/
yield 'merging aria-* attributes' => [
[
['aria-a' => 'a'],
['aria-b' => 'b'],
['aria-c' => true],
['aria-d' => false],
['aria-e' => null],
['aria-f' => ['a' => 'b']],
['aria' => ['g' => 'g', 'h' => true]],
['aria-h' => false],
['aria-h' => 'h'],
],
[
'aria-a' => 'a',
'aria-b' => 'b',
'aria-c' => true,
'aria-d' => false,
'aria-e' => null,
'aria-f' => ['a' => 'b'],
'aria-g' => 'g',
'aria-h' => 'h',
],
];

yield 'merging data-controller attributes' => [
[
['data' => ['controller' => 'c1 c2']],
['data-controller' => 'c3'],
['data-controller' => ['c4' => true]],
['data-controller' => ['c5' => false]],
],
[
'data-controller' => [
'c1' => true,
'c2' => true,
'c3' => true,
'c4' => true,
'c5' => false
],
],
];


}
}

0 comments on commit 93fadaa

Please sign in to comment.