diff --git a/extra/html-extra/HtmlAttributes.php b/extra/html-extra/HtmlAttributes.php new file mode 100644 index 0000000000..a23eceda24 --- /dev/null +++ b/extra/html-extra/HtmlAttributes.php @@ -0,0 +1,221 @@ + '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. + * + * `HtmlAttributes::merge(['style' => 'color: red'], ['style' => ['background-color' => 'blue']])` becomes + * `['style' => ['color: red;' => true, 'background-color: blue;' => true]]`. + * + * 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;' => true, 'display: block;' => 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]`. + * + * @param ...$attributeGroup + * @return array + * @throws RuntimeError + * @see ./Tests/HtmlAttributesTest.php for usage examples + * + */ + 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;"] = true; + } + } 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: $sValue;"] = $v; + } + } else { + $sKey = trim($k); + $sValue = trim($v); + $result['style']["$sKey: $sValue;"] = true; + } + } + continue; + } + + $result[$key] = $value; + } + } + + return $result; + } + + public static function renderAttributes($attributes): string + { + $return = []; + + foreach ($attributes as $key => $value) { + + // Skip null values regardless of attribute key + if ($value === null) { + continue; + } + + // Handle class, style, data-controller value coercion + // array[] -> concatenate string + if (in_array($key, ['class', 'style', 'data-controller'])) { + $value = array_filter($value); + $value = array_keys($value); + $value = implode(' ', $value); + } + + // Handle aria-* value coercion + // true -> 'true' + // false -> 'false, + // array[] -> concatenate string + if (str_starts_with($key, 'aria-')) { + if ($value === true) { + $value = 'true'; + } elseif ($value === false) { + $value = 'false'; + } elseif(is_array($value)) { + $value = join(" ", array_filter($value)); + } + + } + + // Handle data-* value coercion + // array[] -> json + if (str_starts_with($key, 'data-')) { + if(is_array($value)) { + $value = json_encode($value); + } + } + + // Skip false values + if ($value === false) { + continue; + } + + // Boolean attribute doesn't have a value + if ($value === true) { + $return[] = $key; + continue; + } + + // Everything else gets added as an encoded value + $return[] = $key . '="' . htmlspecialchars($value) . '"'; + } + + return implode(' ', $return); + } +} \ No newline at end of file diff --git a/extra/html-extra/Tests/HtmlAttributesTest.php b/extra/html-extra/Tests/HtmlAttributesTest.php new file mode 100644 index 0000000000..5c8130bca0 --- /dev/null +++ b/extra/html-extra/Tests/HtmlAttributesTest.php @@ -0,0 +1,242 @@ +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 + ], + 'a="b" c="d" e="f" i', + ]; + + /** + * 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, + ]], + 'class="a b c d e f g h"', + ]; + + /** + * style attributes are merged into an array so they can be concatenated in later processing. + * // Strings are true by default. + * `HtmlAttributes::merge(['color: red']) === ['color: red' => true]` + * // Arrays have a boolean / null value + * `HtmlAttributes::merge(['color: red' => true ]) === ['color: red' => true]` + * `HtmlAttributes::merge(['color: red' => false ]) === ['color: red' => false]` + * String values are split into key value pairs and then processed + * `HtmlAttributes::merge(['color: red; background: blue']) === ['color: red' => true, 'background: blue' => true]` + * `HtmlAttributes::merge(['color: red; background: blue' => true]) === ['color: red' => true, 'background: blue' => true]` + */ + 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;' => true, + 'c: d;' => true, + 'e: f;' => true, + 'g: h;' => true, + 'i: j;' => true, + 'k: l;' => true, + 'm: n;' => false, + 'o: p;' => null, + ]], + 'style="a: b; c: d; e: f; g: h; i: j; k: l;"', + ]; + + /** + * `data` arrays are expanded into `data-*` attributes before further processing. + */ + yield 'merging data-* attributes' => [ + [ + ['data-string' => 'a'], + ['data-int' => 100], + ['data-bool-true' => true], + ['data-bool-false' => false], + ['data-null' => null], + ['data-array' => ['a' => 'b']], + ['data' => ['expanded-0' => true, 'expanded-1' => false, 'expanded-2' => null]], + ], + [ + 'data-string' => 'a', + 'data-int' => 100, + 'data-bool-true' => true, + 'data-bool-false' => false, + 'data-null' => null, + 'data-array' => ['a' => 'b'], + 'data-expanded-0' => true, + 'data-expanded-1' => false, + 'data-expanded-2' => null, + ], + 'data-string="a" data-int="100" data-bool-true data-array="{"a":"b"}" data-expanded-0' + ]; + + /** + * `aria` arrays are expanded into `aria-*` attributes before further processing. + */ + yield 'merging aria-* attributes' => [ + [ + ['aria-string' => 'a'], + ['aria-int' => 100], + ['aria-bool-true' => true], + ['aria-bool-false' => false], + ['aria-null' => null], + ['aria-array' => ['a', 'b']], + ['aria' => ['expanded-0' => true, 'expanded-1' => false, 'expanded-2' => null]], + ], + [ + 'aria-string' => 'a', + 'aria-int' => 100, + 'aria-bool-true' => true, + 'aria-bool-false' => false, + 'aria-null' => null, + 'aria-array' => ['a', 'b'], + 'aria-expanded-0' => true, + 'aria-expanded-1' => false, + 'aria-expanded-2' => null, + ], + 'aria-string="a" aria-int="100" aria-bool-true="true" aria-bool-false="false" aria-array="a b" aria-expanded-0="true" aria-expanded-1="false"' + ]; + + yield 'merging data-controller attributes' => [ + [ + ['data' => ['controller' => 'c1 c2']], + ['data-controller' => 'c3'], + ['data-controller' => ['c4' => true]], + ['data-controller' => ['c5' => false]], + ['data-controller' => ['c6' => null]], + ], + [ + 'data-controller' => [ + 'c1' => true, + 'c2' => true, + 'c3' => true, + 'c4' => true, + 'c5' => false, + 'c6' => null + ], + ], + 'data-controller="c1 c2 c3 c4"' + ]; + + + } +}