diff --git a/extra/html-extra/HtmlAttributes.php b/extra/html-extra/HtmlAttributes.php new file mode 100644 index 0000000000..5ad7b40360 --- /dev/null +++ b/extra/html-extra/HtmlAttributes.php @@ -0,0 +1,162 @@ + '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. + * style attributes are split into key, value pairs. + * + * `HtmlAttributes::merge(['style' => 'color: red'], ['style' => 'background-color: blue'])` becomes + * `['style' => ['color' => 'red', 'background-color' => 'blue']]`. + * + * 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', 'display' => 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]`. + * + * @see ./Tests/HtmlAttributesTest.php for usage examples + * + * @param ...$attributeGroup + * @return array + * @throws RuntimeError + */ + 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; + } + } 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] = $v ? $sValue : $v; + } + } else { + $sKey = trim($k); + $sValue = trim($v); + $result['style'][$sKey] = $sValue; + } + } + continue; + } + + $result[$key] = $value; + } + } + + return $result; + } +} \ 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..6dfe46d78a --- /dev/null +++ b/extra/html-extra/Tests/HtmlAttributesTest.php @@ -0,0 +1,230 @@ +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 + ], + ]; + + /** + * 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, + ]], + ]; + + /** + * style attributes are merged into an array so they can be concatenated in later processing. + * style strings are split into key, value pairs eg. 'color: red' becomes ['color' => 'red'] + * style attributes which are arrays with false and null values are also processed + * false and null values override string values eg. ['display: block' => false] becomes ['display' => false] + */ + 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', + 'c' => 'd', + 'e' => 'f', + 'g' => 'h', + 'i' => 'j', + 'k' => 'l', + 'm' => false, + 'o' => null, + ]], + ]; + + /** + * `data` arrays are expanded into `data-*` attributes before further processing. + */ + yield 'merging data-* attributes' => [ + [ + ['data-a' => 'a'], + ['data-b' => 'b'], + ['data-c' => true], + ['data-d' => false], + ['data-e' => null], + ['data-f' => ['a' => 'b']], + ['data' => ['g' => 'g', 'h' => true]], + ['data-h' => false], + ['data-h' => 'h'], + ], + [ + 'data-a' => 'a', + 'data-b' => 'b', + 'data-c' => true, + 'data-d' => false, + 'data-e' => null, + 'data-f' => ['a' => 'b'], + 'data-g' => 'g', + 'data-h' => 'h', + ], + ]; + + /** + * `aria` arrays are expanded into `aria-*` attributes before further processing. + */ + yield 'merging aria-* attributes' => [ + [ + ['aria-a' => 'a'], + ['aria-b' => 'b'], + ['aria-c' => true], + ['aria-d' => false], + ['aria-e' => null], + ['aria-f' => ['a' => 'b']], + ['aria' => ['g' => 'g', 'h' => true]], + ['aria-h' => false], + ['aria-h' => 'h'], + ], + [ + 'aria-a' => 'a', + 'aria-b' => 'b', + 'aria-c' => true, + 'aria-d' => false, + 'aria-e' => null, + 'aria-f' => ['a' => 'b'], + 'aria-g' => 'g', + 'aria-h' => 'h', + ], + ]; + + yield 'merging data-controller attributes' => [ + [ + ['data' => ['controller' => 'c1 c2']], + ['data-controller' => 'c3'], + ['data-controller' => ['c4' => true]], + ['data-controller' => ['c5' => false]], + ], + [ + 'data-controller' => [ + 'c1' => true, + 'c2' => true, + 'c3' => true, + 'c4' => true, + 'c5' => false + ], + ], + ]; + + + } +}