-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
af40907
commit 93fadaa
Showing
2 changed files
with
392 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
], | ||
], | ||
]; | ||
|
||
|
||
} | ||
} |