Skip to content

Commit bcba2bc

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

File tree

2 files changed

+341
-0
lines changed

2 files changed

+341
-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: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
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+
public function testNonIterableAttributeValuesThrowException()
12+
{
13+
$this->expectException(\Twig\Error\RuntimeError::class);
14+
$result = HtmlAttributes::merge(['class' => 'a'], 'b');
15+
}
16+
17+
/**
18+
* @dataProvider htmlAttrProvider
19+
* @throws RuntimeError
20+
*/
21+
public function testMerge(array $input, array $expected)
22+
{
23+
$result = HtmlAttributes::merge(...$input);
24+
self::assertSame($expected, $result);
25+
}
26+
27+
public function htmlAttrProvider(): \Generator
28+
{
29+
yield 'merging basic attributes' => [
30+
[
31+
['a' => 'b', 'c' => 'd'],
32+
true ? ['e' => 'f'] : null,
33+
false ? ['g' => 'h'] : null,
34+
['i' => true],
35+
['j' => true],
36+
['j' => false],
37+
['k' => true],
38+
['k' => null],
39+
],
40+
[
41+
'a' => 'b',
42+
'c' => 'd',
43+
'e' => 'f',
44+
'i' => true,
45+
'j' => false,
46+
'k' => null
47+
],
48+
];
49+
50+
/**
51+
* class attributes are merged into an array so they can be concatenated in later processing.
52+
*/
53+
yield 'merging class attributes' => [
54+
[
55+
['class' => 'a b j'],
56+
['class' => ['c', 'd', 'e f']],
57+
['class' => ['g' => true, 'h' => false, 'i' => true]],
58+
['class' => ['h' => true]],
59+
['class' => ['i' => false]],
60+
['class' => ['j' => null]],
61+
],
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+
];
75+
76+
/**
77+
* style attributes are merged into an array so they can be concatenated in later processing.
78+
* style strings are split into key, value pairs eg. 'color: red' becomes ['color' => 'red']
79+
* style attributes which are arrays with false and null values are also processed
80+
* false and null values override string values eg. ['display: block' => false] becomes ['display' => false]
81+
*/
82+
yield 'merging style attributes' => [
83+
[
84+
['style' => 'a: b;'],
85+
['style' => ['c' => 'd', 'e' => 'f']],
86+
['style' => ['g: h;']],
87+
['style' => [
88+
'i: j; k: l' => true,
89+
'm: n' => false,
90+
'o: p' => null
91+
]],
92+
],
93+
['style' => [
94+
'a' => 'b',
95+
'c' => 'd',
96+
'e' => 'f',
97+
'g' => 'h',
98+
'i' => 'j',
99+
'k' => 'l',
100+
'm' => false,
101+
'o' => null,
102+
]],
103+
];
104+
105+
/**
106+
* `data` arrays are expanded into `data-*` attributes before further processing.
107+
*/
108+
yield 'merging data-* attributes' => [
109+
[
110+
['data-a' => 'a'],
111+
['data-b' => 'b'],
112+
['data-c' => true],
113+
['data-d' => false],
114+
['data-e' => null],
115+
['data-f' => ['a' => 'b']],
116+
['data' => ['g' => 'g', 'h' => true]],
117+
['data-h' => false],
118+
['data-h' => 'h'],
119+
],
120+
[
121+
'data-a' => 'a',
122+
'data-b' => 'b',
123+
'data-c' => true,
124+
'data-d' => false,
125+
'data-e' => null,
126+
'data-f' => ['a' => 'b'],
127+
'data-g' => 'g',
128+
'data-h' => 'h',
129+
],
130+
];
131+
132+
/**
133+
* `aria` arrays are expanded into `aria-*` attributes before further processing.
134+
*/
135+
yield 'merging aria-* attributes' => [
136+
[
137+
['aria-a' => 'a'],
138+
['aria-b' => 'b'],
139+
['aria-c' => true],
140+
['aria-d' => false],
141+
['aria-e' => null],
142+
['aria-f' => ['a' => 'b']],
143+
['aria' => ['g' => 'g', 'h' => true]],
144+
['aria-h' => false],
145+
['aria-h' => 'h'],
146+
],
147+
[
148+
'aria-a' => 'a',
149+
'aria-b' => 'b',
150+
'aria-c' => true,
151+
'aria-d' => false,
152+
'aria-e' => null,
153+
'aria-f' => ['a' => 'b'],
154+
'aria-g' => 'g',
155+
'aria-h' => 'h',
156+
],
157+
];
158+
159+
yield 'merging data-controller attributes' => [
160+
[
161+
['data' => ['controller' => 'c1 c2']],
162+
['data-controller' => 'c3'],
163+
['data-controller' => ['c4' => true]],
164+
['data-controller' => ['c5' => false]],
165+
],
166+
[
167+
'data-controller' => [
168+
'c1' => true,
169+
'c2' => true,
170+
'c3' => true,
171+
'c4' => true,
172+
'c5' => false
173+
],
174+
],
175+
];
176+
177+
178+
}
179+
}

0 commit comments

Comments
 (0)