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
+ ],
+ ],
+ ];
+
+
+ }
+}