Skip to content

Commit ab96d70

Browse files
committed
WIP - html_attributes function
1 parent af40907 commit ab96d70

File tree

2 files changed

+354
-0
lines changed

2 files changed

+354
-0
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php
2+
3+
namespace Twig\Extra\Html;
4+
5+
use Twig\Error\RuntimeError;
6+
7+
final class HtmlAttributes
8+
{
9+
/**
10+
* Merges multiple attribute group arrays into a single array.
11+
*
12+
* `HtmlAttributes::merge(['id' => 'a', 'disabled' => true], ['hidden' => true])` becomes
13+
* `['id' => 'a', 'disabled' => true, 'hidden' => true]`
14+
*
15+
* attributes override each other in the order they are provided.
16+
*
17+
* `HtmlAttributes::merge(['id' => 'a'], ['id' => 'b'])` becomes `['id' => 'b']`.
18+
*
19+
* However, `class` and `style` attributes are merged into an array so they can be concatenated in later processing.
20+
*
21+
* `HtmlAttributes::merge(['class' => 'a'], ['class' => 'b'], ['class' => 'c'])` becomes
22+
* `['class' => ['a' => true, 'b' => true, 'c' => true]]`.
23+
*
24+
* style attributes are also merged into an array so they can be concatenated in later processing.
25+
* style attributes are split into key, value pairs.
26+
*
27+
* `HtmlAttributes::merge(['style' => 'color: red'], ['style' => 'background-color: blue'])` becomes
28+
* `['style' => ['color' => 'red', 'background-color' => 'blue']]`.
29+
*
30+
* style attributes which are arrays with false and null values are also processed
31+
*
32+
* `HtmlAttributes::merge(['style' => ['color: red' => true]], ['style' => ['display: block' => false]]) becomes
33+
* `['style' => ['color' => 'red', 'display' => false]]`.
34+
*
35+
* attributes can be provided as an array of key, value where the value can be true, false or null.
36+
*
37+
* Example:
38+
* `HtmlAttributes::merge(['class' => ['a' => true, 'b' => false], ['class' => ['c' => null']])` becomes
39+
* `['class' => ['a' => true, 'b' => false, 'c' => null]]`.
40+
*
41+
* `aria` and `data` arrays are expanded into `aria-*` and `data-*` attributes before further processing.
42+
*
43+
* Example:
44+
*
45+
* `HtmlAttributes::merge([data' => ['count' => '1']])` becomes `['data-count' => '1']`.
46+
* `HtmlAttributes::merge(['aria' => ['hidden' => true]])` becomes `['aria-hidden' => true]`.
47+
*
48+
* @see ./Tests/HtmlAttributesTest.php for usage examples
49+
*
50+
* @param ...$attributeGroup
51+
* @return array
52+
* @throws RuntimeError
53+
*/
54+
public static function merge(...$attributeGroup): array
55+
{
56+
$result = [];
57+
58+
$attributeGroupCount = 0;
59+
60+
foreach ($attributeGroup as $attributes) {
61+
62+
$attributeGroupCount++;
63+
64+
// Skip empty attributes
65+
// Return early if no attributes are provided
66+
// This could be false or null when using the twig ternary operator
67+
if(!$attributes) {
68+
continue;
69+
}
70+
71+
if (!is_iterable($attributes)) {
72+
throw new RuntimeError(sprintf('"%s" only works with mappings or "Traversable", got "%s" for argument %d.', self::class, \gettype($attributes), $attributeGroupCount));
73+
}
74+
75+
// Alternative to is_iterable check above, cast the attributes to an array
76+
// This would produce weird results but would not throw an error
77+
// $attributes = (array)$attributes;
78+
79+
// data and aria arrays are expanded into data-* and aria-* attributes
80+
$expanded = [];
81+
foreach ($attributes as $key => $value) {
82+
if (in_array($key, ['data', 'aria'])) {
83+
$value = (array)$value;
84+
foreach ($value as $k => $v) {
85+
$k = $key . '-' . $k;
86+
$expanded[$k] = $v;
87+
}
88+
continue;
89+
}
90+
$expanded[$key] = $value;
91+
}
92+
93+
// Reset the attributes array to the flattened version
94+
$attributes = $expanded;
95+
96+
foreach ($attributes as $key => $value) {
97+
98+
// Treat class and data-controller attributes as arrays
99+
if (in_array($key, [
100+
'class',
101+
'data-controller',
102+
'data-action',
103+
'data-targets',
104+
])) {
105+
if (!array_key_exists($key, $result)) {
106+
$result[$key] = [];
107+
}
108+
$value = (array)$value;
109+
foreach ($value as $k => $v) {
110+
if (is_int($k)) {
111+
$classes = explode(' ', $v);
112+
foreach ($classes as $class) {
113+
$result[$key][$class] = true;
114+
}
115+
} else {
116+
$classes = explode(' ', $k);
117+
foreach ($classes as $class) {
118+
$result[$key][$class] = $v;
119+
}
120+
}
121+
}
122+
continue;
123+
}
124+
125+
if ($key === 'style') {
126+
if (!array_key_exists('style', $result)) {
127+
$result['style'] = [];
128+
}
129+
$value = (array)$value;
130+
foreach ($value as $k => $v) {
131+
if (is_int($k)) {
132+
$styles = array_filter(explode(';', $v));
133+
foreach ($styles as $style) {
134+
$style = explode(':', $style);
135+
$sKey = trim($style[0]);
136+
$sValue = trim($style[1]);
137+
$result['style'][$sKey] = $sValue;
138+
}
139+
} elseif (is_bool($v) || is_null($v)) {
140+
$styles = array_filter(explode(';', $k));
141+
foreach ($styles as $style) {
142+
$style = explode(':', $style);
143+
$sKey = trim($style[0]);
144+
$sValue = trim($style[1]);
145+
$result['style'][$sKey] = $v ? $sValue : $v;
146+
}
147+
} else {
148+
$sKey = trim($k);
149+
$sValue = trim($v);
150+
$result['style'][$sKey] = $sValue;
151+
}
152+
}
153+
continue;
154+
}
155+
156+
$result[$key] = $value;
157+
}
158+
}
159+
160+
return $result;
161+
}
162+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<?php
2+
3+
namespace Twig\Extra\Html\Tests;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Twig\Error\RuntimeError;
7+
use Twig\Extra\Html\HtmlAttributes;
8+
9+
class HtmlAttributesTest extends TestCase
10+
{
11+
/**
12+
* @dataProvider htmlAttrProvider
13+
* @throws RuntimeError
14+
*/
15+
public function testMerge(array $input, array $expected)
16+
{
17+
$result = HtmlAttributes::merge(...$input);
18+
self::assertSame($expected, $result);
19+
}
20+
21+
public function testNonIterableAttributeValuesThrowException()
22+
{
23+
$this->expectException(\Twig\Error\RuntimeError::class);
24+
$result = HtmlAttributes::merge(['class' => 'a'], 'b');
25+
}
26+
27+
public function testMultipleMerge()
28+
{
29+
$result = HtmlAttributes::merge(['a' => 'b', 'c' => 'd']);
30+
$result = HtmlAttributes::merge($result, ['e' => 'f']);
31+
32+
self::assertSame([
33+
'a' => 'b',
34+
'c' => 'd',
35+
'e' => 'f',
36+
], $result);
37+
}
38+
39+
40+
public function htmlAttrProvider(): \Generator
41+
{
42+
yield 'merging basic attributes' => [
43+
[
44+
['a' => 'b', 'c' => 'd'],
45+
true ? ['e' => 'f'] : null,
46+
false ? ['g' => 'h'] : null,
47+
['i' => true],
48+
['j' => true],
49+
['j' => false],
50+
['k' => true],
51+
['k' => null],
52+
],
53+
[
54+
'a' => 'b',
55+
'c' => 'd',
56+
'e' => 'f',
57+
'i' => true,
58+
'j' => false,
59+
'k' => null
60+
],
61+
];
62+
63+
/**
64+
* class attributes are merged into an array so they can be concatenated in later processing.
65+
*/
66+
yield 'merging class attributes' => [
67+
[
68+
['class' => 'a b j'],
69+
['class' => ['c', 'd', 'e f']],
70+
['class' => ['g' => true, 'h' => false, 'i' => true]],
71+
['class' => ['h' => true]],
72+
['class' => ['i' => false]],
73+
['class' => ['j' => null]],
74+
],
75+
['class' => [
76+
'a' => true,
77+
'b' => true,
78+
'j' => null,
79+
'c' => true,
80+
'd' => true,
81+
'e' => true,
82+
'f' => true,
83+
'g' => true,
84+
'h' => true,
85+
'i' => false,
86+
]],
87+
];
88+
89+
/**
90+
* style attributes are merged into an array so they can be concatenated in later processing.
91+
* style strings are split into key, value pairs eg. 'color: red' becomes ['color' => 'red']
92+
* style attributes which are arrays with false and null values are also processed
93+
* false and null values override string values eg. ['display: block' => false] becomes ['display' => false]
94+
*/
95+
yield 'merging style attributes' => [
96+
[
97+
['style' => 'a: b;'],
98+
['style' => ['c' => 'd', 'e' => 'f']],
99+
['style' => ['g: h;']],
100+
['style' => [
101+
'i: j; k: l' => true,
102+
'm: n' => false,
103+
'o: p' => null
104+
]],
105+
],
106+
['style' => [
107+
'a' => 'b',
108+
'c' => 'd',
109+
'e' => 'f',
110+
'g' => 'h',
111+
'i' => 'j',
112+
'k' => 'l',
113+
'm' => false,
114+
'o' => null,
115+
]],
116+
];
117+
118+
/**
119+
* `data` arrays are expanded into `data-*` attributes before further processing.
120+
*/
121+
yield 'merging data-* attributes' => [
122+
[
123+
['data-a' => 'a'],
124+
['data-b' => 'b'],
125+
['data-c' => true],
126+
['data-d' => false],
127+
['data-e' => null],
128+
['data-f' => ['a' => 'b']],
129+
['data' => ['g' => 'g', 'h' => true]],
130+
['data-h' => false],
131+
['data-h' => 'h'],
132+
],
133+
[
134+
'data-a' => 'a',
135+
'data-b' => 'b',
136+
'data-c' => true,
137+
'data-d' => false,
138+
'data-e' => null,
139+
'data-f' => ['a' => 'b'],
140+
'data-g' => 'g',
141+
'data-h' => 'h',
142+
],
143+
];
144+
145+
/**
146+
* `aria` arrays are expanded into `aria-*` attributes before further processing.
147+
*/
148+
yield 'merging aria-* attributes' => [
149+
[
150+
['aria-a' => 'a'],
151+
['aria-b' => 'b'],
152+
['aria-c' => true],
153+
['aria-d' => false],
154+
['aria-e' => null],
155+
['aria-f' => ['a' => 'b']],
156+
['aria' => ['g' => 'g', 'h' => true]],
157+
['aria-h' => false],
158+
['aria-h' => 'h'],
159+
],
160+
[
161+
'aria-a' => 'a',
162+
'aria-b' => 'b',
163+
'aria-c' => true,
164+
'aria-d' => false,
165+
'aria-e' => null,
166+
'aria-f' => ['a' => 'b'],
167+
'aria-g' => 'g',
168+
'aria-h' => 'h',
169+
],
170+
];
171+
172+
yield 'merging data-controller attributes' => [
173+
[
174+
['data' => ['controller' => 'c1 c2']],
175+
['data-controller' => 'c3'],
176+
['data-controller' => ['c4' => true]],
177+
['data-controller' => ['c5' => false]],
178+
],
179+
[
180+
'data-controller' => [
181+
'c1' => true,
182+
'c2' => true,
183+
'c3' => true,
184+
'c4' => true,
185+
'c5' => false
186+
],
187+
],
188+
];
189+
190+
191+
}
192+
}

0 commit comments

Comments
 (0)