Skip to content

Commit 93fadaa

Browse files
committed
WIP - html_attributes function
1 parent af40907 commit 93fadaa

File tree

2 files changed

+392
-0
lines changed

2 files changed

+392
-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: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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+
/**
28+
* Tests output of HtmlAttributes::merge() method can be used as an array of attributes.
29+
* @return void
30+
* @throws RuntimeError
31+
*/
32+
public function testMultipleMerge()
33+
{
34+
$result1 = HtmlAttributes::merge(['a' => 'b', 'c' => 'd'],
35+
true ? ['e' => 'f'] : null,
36+
false ? ['g' => 'h'] : null,
37+
['i' => true],
38+
['j' => true],
39+
['j' => false],
40+
['k' => true],
41+
['k' => null]
42+
);
43+
44+
$result2 = HtmlAttributes::merge(
45+
['class' => 'a b j'],
46+
['class' => ['c', 'd', 'e f']],
47+
['class' => ['g' => true, 'h' => false, 'i' => true]],
48+
['class' => ['h' => true]],
49+
['class' => ['i' => false]],
50+
['class' => ['j' => null]],
51+
);
52+
53+
$result = HtmlAttributes::merge($result1, $result2);
54+
55+
self::assertSame([
56+
'a' => 'b',
57+
'c' => 'd',
58+
'e' => 'f',
59+
'i' => true,
60+
'j' => false,
61+
'k' => null,
62+
'class' => [
63+
'a' => true,
64+
'b' => true,
65+
'j' => null,
66+
'c' => true,
67+
'd' => true,
68+
'e' => true,
69+
'f' => true,
70+
'g' => true,
71+
'h' => true,
72+
'i' => false,
73+
]
74+
], $result);
75+
}
76+
77+
78+
public function htmlAttrProvider(): \Generator
79+
{
80+
yield 'merging basic attributes' => [
81+
[
82+
['a' => 'b', 'c' => 'd'],
83+
true ? ['e' => 'f'] : null,
84+
false ? ['g' => 'h'] : null,
85+
['i' => true],
86+
['j' => true],
87+
['j' => false],
88+
['k' => true],
89+
['k' => null],
90+
],
91+
[
92+
'a' => 'b',
93+
'c' => 'd',
94+
'e' => 'f',
95+
'i' => true,
96+
'j' => false,
97+
'k' => null
98+
],
99+
];
100+
101+
/**
102+
* class attributes are merged into an array so they can be concatenated in later processing.
103+
*/
104+
yield 'merging class attributes' => [
105+
[
106+
['class' => 'a b j'],
107+
['class' => ['c', 'd', 'e f']],
108+
['class' => ['g' => true, 'h' => false, 'i' => true]],
109+
['class' => ['h' => true]],
110+
['class' => ['i' => false]],
111+
['class' => ['j' => null]],
112+
],
113+
['class' => [
114+
'a' => true,
115+
'b' => true,
116+
'j' => null,
117+
'c' => true,
118+
'd' => true,
119+
'e' => true,
120+
'f' => true,
121+
'g' => true,
122+
'h' => true,
123+
'i' => false,
124+
]],
125+
];
126+
127+
/**
128+
* style attributes are merged into an array so they can be concatenated in later processing.
129+
* style strings are split into key, value pairs eg. 'color: red' becomes ['color' => 'red']
130+
* style attributes which are arrays with false and null values are also processed
131+
* false and null values override string values eg. ['display: block' => false] becomes ['display' => false]
132+
*/
133+
yield 'merging style attributes' => [
134+
[
135+
['style' => 'a: b;'],
136+
['style' => ['c' => 'd', 'e' => 'f']],
137+
['style' => ['g: h;']],
138+
['style' => [
139+
'i: j; k: l' => true,
140+
'm: n' => false,
141+
'o: p' => null
142+
]],
143+
],
144+
['style' => [
145+
'a' => 'b',
146+
'c' => 'd',
147+
'e' => 'f',
148+
'g' => 'h',
149+
'i' => 'j',
150+
'k' => 'l',
151+
'm' => false,
152+
'o' => null,
153+
]],
154+
];
155+
156+
/**
157+
* `data` arrays are expanded into `data-*` attributes before further processing.
158+
*/
159+
yield 'merging data-* attributes' => [
160+
[
161+
['data-a' => 'a'],
162+
['data-b' => 'b'],
163+
['data-c' => true],
164+
['data-d' => false],
165+
['data-e' => null],
166+
['data-f' => ['a' => 'b']],
167+
['data' => ['g' => 'g', 'h' => true]],
168+
['data-h' => false],
169+
['data-h' => 'h'],
170+
],
171+
[
172+
'data-a' => 'a',
173+
'data-b' => 'b',
174+
'data-c' => true,
175+
'data-d' => false,
176+
'data-e' => null,
177+
'data-f' => ['a' => 'b'],
178+
'data-g' => 'g',
179+
'data-h' => 'h',
180+
],
181+
];
182+
183+
/**
184+
* `aria` arrays are expanded into `aria-*` attributes before further processing.
185+
*/
186+
yield 'merging aria-* attributes' => [
187+
[
188+
['aria-a' => 'a'],
189+
['aria-b' => 'b'],
190+
['aria-c' => true],
191+
['aria-d' => false],
192+
['aria-e' => null],
193+
['aria-f' => ['a' => 'b']],
194+
['aria' => ['g' => 'g', 'h' => true]],
195+
['aria-h' => false],
196+
['aria-h' => 'h'],
197+
],
198+
[
199+
'aria-a' => 'a',
200+
'aria-b' => 'b',
201+
'aria-c' => true,
202+
'aria-d' => false,
203+
'aria-e' => null,
204+
'aria-f' => ['a' => 'b'],
205+
'aria-g' => 'g',
206+
'aria-h' => 'h',
207+
],
208+
];
209+
210+
yield 'merging data-controller attributes' => [
211+
[
212+
['data' => ['controller' => 'c1 c2']],
213+
['data-controller' => 'c3'],
214+
['data-controller' => ['c4' => true]],
215+
['data-controller' => ['c5' => false]],
216+
],
217+
[
218+
'data-controller' => [
219+
'c1' => true,
220+
'c2' => true,
221+
'c3' => true,
222+
'c4' => true,
223+
'c5' => false
224+
],
225+
],
226+
];
227+
228+
229+
}
230+
}

0 commit comments

Comments
 (0)