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+ *
26+ * `HtmlAttributes::merge(['style' => 'color: red'], ['style' => ['background-color' => 'blue']])` becomes
27+ * `['style' => ['color: red;' => true, 'background-color: blue;' => true]]`.
28+ *
29+ * style attributes which are arrays with false and null values are also processed
30+ *
31+ * `HtmlAttributes::merge(['style' => ['color: red' => true]], ['style' => ['display: block' => false]]) becomes
32+ * `['style' => ['color: red;' => true, 'display: block;' => false]]`.
33+ *
34+ * attributes can be provided as an array of key, value where the value can be true, false or null.
35+ *
36+ * Example:
37+ * `HtmlAttributes::merge(['class' => ['a' => true, 'b' => false], ['class' => ['c' => null']])` becomes
38+ * `['class' => ['a' => true, 'b' => false, 'c' => null]]`.
39+ *
40+ * `aria` and `data` arrays are expanded into `aria-*` and `data-*` attributes before further processing.
41+ *
42+ * Example:
43+ *
44+ * `HtmlAttributes::merge([data' => ['count' => '1']])` becomes `['data-count' => '1']`.
45+ * `HtmlAttributes::merge(['aria' => ['hidden' => true]])` becomes `['aria-hidden' => true]`.
46+ *
47+ * @param ...$attributeGroup
48+ * @return array
49+ * @throws RuntimeError
50+ * @see ./Tests/HtmlAttributesTest.php for usage examples
51+ *
52+ */
53+ public static function merge (...$ attributeGroup ): array
54+ {
55+ $ result = [];
56+
57+ $ attributeGroupCount = 0 ;
58+
59+ foreach ($ attributeGroup as $ attributes ) {
60+
61+ $ attributeGroupCount ++;
62+
63+ // Skip empty attributes
64+ // Return early if no attributes are provided
65+ // This could be false or null when using the twig ternary operator
66+ if (!$ attributes ) {
67+ continue ;
68+ }
69+
70+ if (!is_iterable ($ attributes )) {
71+ throw new RuntimeError (sprintf ('"%s" only works with mappings or "Traversable", got "%s" for argument %d. ' , self ::class, \gettype ($ attributes ), $ attributeGroupCount ));
72+ }
73+
74+ // Alternative to is_iterable check above, cast the attributes to an array
75+ // This would produce weird results but would not throw an error
76+ // $attributes = (array)$attributes;
77+
78+ // data and aria arrays are expanded into data-* and aria-* attributes
79+ $ expanded = [];
80+ foreach ($ attributes as $ key => $ value ) {
81+ if (in_array ($ key , ['data ' , 'aria ' ])) {
82+ $ value = (array )$ value ;
83+ foreach ($ value as $ k => $ v ) {
84+ $ k = $ key . '- ' . $ k ;
85+ $ expanded [$ k ] = $ v ;
86+ }
87+ continue ;
88+ }
89+ $ expanded [$ key ] = $ value ;
90+ }
91+
92+ // Reset the attributes array to the flattened version
93+ $ attributes = $ expanded ;
94+
95+ foreach ($ attributes as $ key => $ value ) {
96+
97+ // Treat class and data-controller attributes as arrays
98+ if (in_array ($ key , [
99+ 'class ' ,
100+ 'data-controller ' ,
101+ 'data-action ' ,
102+ 'data-targets ' ,
103+ ])) {
104+ if (!array_key_exists ($ key , $ result )) {
105+ $ result [$ key ] = [];
106+ }
107+ $ value = (array )$ value ;
108+ foreach ($ value as $ k => $ v ) {
109+ if (is_int ($ k )) {
110+ $ classes = explode (' ' , $ v );
111+ foreach ($ classes as $ class ) {
112+ $ result [$ key ][$ class ] = true ;
113+ }
114+ } else {
115+ $ classes = explode (' ' , $ k );
116+ foreach ($ classes as $ class ) {
117+ $ result [$ key ][$ class ] = $ v ;
118+ }
119+ }
120+ }
121+ continue ;
122+ }
123+
124+ if ($ key === 'style ' ) {
125+ if (!array_key_exists ('style ' , $ result )) {
126+ $ result ['style ' ] = [];
127+ }
128+ $ value = (array )$ value ;
129+ foreach ($ value as $ k => $ v ) {
130+ if (is_int ($ k )) {
131+ $ styles = array_filter (explode ('; ' , $ v ));
132+ foreach ($ styles as $ style ) {
133+ $ style = explode (': ' , $ style );
134+ $ sKey = trim ($ style [0 ]);
135+ $ sValue = trim ($ style [1 ]);
136+ $ result ['style ' ]["$ sKey: $ sValue; " ] = true ;
137+ }
138+ } elseif (is_bool ($ v ) || is_null ($ v )) {
139+ $ styles = array_filter (explode ('; ' , $ k ));
140+ foreach ($ styles as $ style ) {
141+ $ style = explode (': ' , $ style );
142+ $ sKey = trim ($ style [0 ]);
143+ $ sValue = trim ($ style [1 ]);
144+ $ result ['style ' ]["$ sKey: $ sValue; " ] = $ v ;
145+ }
146+ } else {
147+ $ sKey = trim ($ k );
148+ $ sValue = trim ($ v );
149+ $ result ['style ' ]["$ sKey: $ sValue; " ] = true ;
150+ }
151+ }
152+ continue ;
153+ }
154+
155+ $ result [$ key ] = $ value ;
156+ }
157+ }
158+
159+ return $ result ;
160+ }
161+
162+ public static function renderAttributes ($ attributes ): string
163+ {
164+ $ return = [];
165+
166+ foreach ($ attributes as $ key => $ value ) {
167+
168+ // Skip null values regardless of attribute key
169+ if ($ value === null ) {
170+ continue ;
171+ }
172+
173+ // Handle class, style, data-controller value coercion
174+ // array[] -> concatenate string
175+ if (in_array ($ key , ['class ' , 'style ' , 'data-controller ' ])) {
176+ $ value = array_filter ($ value );
177+ $ value = array_keys ($ value );
178+ $ value = implode (' ' , $ value );
179+ }
180+
181+ // Handle aria-* value coercion
182+ // true -> 'true'
183+ // false -> 'false,
184+ // array[] -> concatenate string
185+ if (str_starts_with ($ key , 'aria- ' )) {
186+ if ($ value === true ) {
187+ $ value = 'true ' ;
188+ } elseif ($ value === false ) {
189+ $ value = 'false ' ;
190+ } elseif (is_array ($ value )) {
191+ $ value = join (" " , array_filter ($ value ));
192+ }
193+
194+ }
195+
196+ // Handle data-* value coercion
197+ // array[] -> json
198+ if (str_starts_with ($ key , 'data- ' )) {
199+ if (is_array ($ value )) {
200+ $ value = json_encode ($ value );
201+ }
202+ }
203+
204+ // Skip false values
205+ if ($ value === false ) {
206+ continue ;
207+ }
208+
209+ // Boolean attribute doesn't have a value
210+ if ($ value === true ) {
211+ $ return [] = $ key ;
212+ continue ;
213+ }
214+
215+ // Everything else gets added as an encoded value
216+ $ return [] = $ key . '=" ' . htmlspecialchars ($ value ) . '" ' ;
217+ }
218+
219+ return implode (' ' , $ return );
220+ }
221+ }
0 commit comments