diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php
index d8a6c0036d1..b01cc49e56a 100644
--- a/extra/html-extra/HtmlExtension.php
+++ b/extra/html-extra/HtmlExtension.php
@@ -12,8 +12,10 @@
namespace Twig\Extra\Html;
use Symfony\Component\Mime\MimeTypes;
+use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Extension\AbstractExtension;
+use Twig\Runtime\EscaperRuntime;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -30,6 +32,7 @@ public function getFilters(): array
{
return [
new TwigFilter('data_uri', [$this, 'dataUri']),
+ new TwigFilter('html_attr_merge', [self::class, 'htmlAttrMerge']),
];
}
@@ -38,6 +41,7 @@ public function getFunctions(): array
return [
new TwigFunction('html_classes', [self::class, 'htmlClasses']),
new TwigFunction('html_cva', [self::class, 'htmlCva']),
+ new TwigFunction('html_attr', [self::class, 'htmlAttr'], ['needs_environment' => true, 'is_safe' => ['html']]),
];
}
@@ -124,4 +128,96 @@ public static function htmlCva(array|string $base = [], array $variants = [], ar
{
return new Cva($base, $variants, $compoundVariants, $defaultVariant);
}
+
+ public static function htmlAttrMerge(...$arrays): array
+ {
+ $result = [];
+
+ foreach ($arrays as $argNumber => $array) {
+ if (!$array) {
+ continue;
+ }
+
+ if (!is_iterable($array)) {
+ throw new RuntimeError(sprintf('The "attr_merge" filter only works with mappings or "Traversable", got "%s" for argument %d.', \gettype($array), $argNumber + 1));
+ }
+
+ $array = (array) ($array);
+
+ foreach (['data', 'aria'] as $flatOutKey) {
+ if (!isset($array[$flatOutKey])) {
+ continue;
+ }
+ $values = (array) $array[$flatOutKey];
+ foreach ($values as $key => $value) {
+ $result[$flatOutKey.'-'.$key] = $value;
+ }
+ unset($array[$flatOutKey]);
+ }
+
+ foreach (['class', 'style'] as $deepMergeKey) {
+ if (!isset($array[$deepMergeKey])) {
+ continue;
+ }
+
+ $value = $array[$deepMergeKey];
+ unset($array[$deepMergeKey]);
+
+ if (!is_iterable($value)) {
+ $value = (array) $value;
+ }
+
+ $result[$deepMergeKey] = [...$result[$deepMergeKey] ?? [], ...$value];
+ }
+
+ $result = [...$result, ...$array];
+ }
+
+ return $result;
+ }
+
+ public static function htmlAttr(Environment $env, ...$args): string
+ {
+ $attr = self::htmlAttrMerge(...$args);
+
+ if (isset($attr['class'])) {
+ $attr['class'] = trim(implode(' ', $attr['class']));
+ }
+
+ if (isset($attr['style'])) {
+ $style = '';
+ foreach ($attr['style'] as $name => $value) {
+ if (is_numeric($name)) {
+ $style .= $value.'; ';
+ } else {
+ $style .= $name.': '.$value.'; ';
+ }
+ }
+ $attr['style'] = trim($style);
+ }
+
+ if (isset($attr['data'])) {
+ foreach ($attr['data'] as $name => $value) {
+ $attr['data-'.$name] = $value;
+ }
+ unset($attr['data']);
+ }
+
+ $result = '';
+ $runtime = $env->getRuntime(EscaperRuntime::class);
+
+ foreach ($attr as $name => $value) {
+ if ($value === false) {
+ continue;
+ }
+
+ if ($value === true) {
+ $value = $name;
+ }
+
+ $result .= $runtime->escape($name, 'html_attr').'="'.$runtime->escape($value).'" ';
+ }
+
+ return trim($result);
+ }
}
diff --git a/extra/html-extra/Tests/Fixtures/html_attr_merge.test b/extra/html-extra/Tests/Fixtures/html_attr_merge.test
new file mode 100644
index 00000000000..108f216ac6b
--- /dev/null
+++ b/extra/html-extra/Tests/Fixtures/html_attr_merge.test
@@ -0,0 +1,19 @@
+--TEST--
+"html_attr_merge" function
+--TEMPLATE--
+{% autoescape false %}
+#1 - {{ { foo: 'bar' } | html_attr_merge({ bar: 'baz' }) | json_encode }}
+#2 - {{ { class: 'foo' } | html_attr_merge({ class: 'bar' }) | json_encode }}
+#3 - {{ { class: 'foo' } | html_attr_merge({ class: ['bar', 'baz'] }) | json_encode }}
+#4 - {{ { class: { special: 'foo' } }
+ | html_attr_merge({ class: ['bar', 'baz'] })
+ | html_attr_merge({ class: { special: 'qux' } })
+ | json_encode }}
+{% endautoescape %}
+--DATA--
+return []
+--EXPECT--
+#1 - {"foo":"bar","bar":"baz"}
+#2 - {"class":["foo","bar"]}
+#3 - {"class":["foo","bar","baz"]}
+#4 - {"class":{"special":"qux","0":"bar","1":"baz"}}
diff --git a/extra/html-extra/Tests/HtmlAttrMergeTest.php b/extra/html-extra/Tests/HtmlAttrMergeTest.php
new file mode 100644
index 00000000000..28839c448f5
--- /dev/null
+++ b/extra/html-extra/Tests/HtmlAttrMergeTest.php
@@ -0,0 +1,184 @@
+ [
+ ['id' => 'some-id', 'label' => 'some-label'],
+ [
+ ['id' => 'some-id'],
+ ['label' => 'some-label'],
+ ]
+ ];
+
+ yield 'merging different attributes from three arrays' => [
+ ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'],
+ [
+ ['id' => 'some-id'],
+ ['label' => 'some-label'],
+ ['role' => 'main'],
+ ]
+ ];
+
+ yield 'merging different attributes from Traversables' => [
+ ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'],
+ [
+ new \ArrayIterator(['id' => 'some-id']),
+ new \ArrayIterator(['label' => 'some-label']),
+ new \ArrayIterator(['role' => 'main']),
+ ]
+ ];
+
+ yield 'later keys override previous ones' => [
+ ['id' => 'other'],
+ [
+ ['id' => 'this'],
+ ['id' => 'that'],
+ ['id' => 'other'],
+ ]
+ ];
+
+ yield 'ignore empty strings or arrays passed as arguments' => [
+ ['some' => 'attribute'],
+ [
+ ['some' => 'attribute'],
+ [], // empty array
+ '', // empty string
+ ]
+ ];
+
+ yield 'keep "true" and "false" boolean values' => [
+ ['disabled' => true, 'enabled' => false],
+ [
+ ['disabled' => true],
+ ['enabled' => false],
+ ]
+ ];
+
+ yield 'consolidate values for the "class" key' => [
+ ['class' => ['foo', 'bar', 'baz']],
+ [
+ ['class' => ['foo']],
+ ['class' => 'bar'], // string, not array
+ ['class' => ['baz']],
+ ]
+ ];
+
+ yield 'class values can be overridden when they use names (array keys)' => [
+ ['class' => ['foo', 'bar', 'importance' => 'high']],
+ [
+ ['class' => 'foo'],
+ ['class' => ['bar', 'importance' => 'low']],
+ ['class' => ['importance' => 'high']],
+ ]
+ ];
+
+ yield 'inline style values with numerical keys are merely collected' => [
+ ['style' => ['font-weight: light', 'color: green', 'font-weight: bold']],
+ [
+ ['style' => ['font-weight: light']],
+ ['style' => ['color: green', 'font-weight: bold']],
+ ]
+ ];
+
+ yield 'inline style values can be overridden when they use names (array keys)' => [
+ ['style' => ['font-weight' => 'bold', 'color' => 'red']],
+ [
+ ['style' => ['font-weight' => 'light']],
+ ['style' => ['color' => 'green', 'font-weight' => 'bold']],
+ ['style' => ['color' => 'red']],
+ ]
+ ];
+
+ yield 'no merging happens when mixing numerically indexed inline styles with named ones' => [
+ ['style' => ['color: green', 'color' => 'red']],
+ [
+ ['style' => ['color: green']],
+ ['style' => ['color' => 'red']],
+ ]
+ ];
+
+ yield 'turning aria attributes from array to flat keys' => [
+ ['aria-role' => 'banner'],
+ [
+ ['aria' => ['role' => 'main']],
+ ['aria' => ['role' => 'banner']],
+ ]
+ ];
+
+ yield 'using aria attributes from a sub-array' => [
+ ['aria-role' => 'main', 'aria-label' => 'none'],
+ [
+ ['aria' => ['role' => 'main', 'label' => 'none']],
+ ]
+ ];
+
+ yield 'merging aria attributes, where the array values overrides the flat one' => [
+ ['aria-role' => 'navigation'],
+ [
+ ['aria-role' => 'main'],
+ ['aria' => ['role' => 'banner']],
+ ['aria' => ['role' => 'navigation']],
+ ]
+ ];
+
+ yield 'merging aria attributes, where the flat ones overrides the array' => [
+ ['aria-role' => 'navigation'],
+ [
+ ['aria' => ['role' => 'main']],
+ ['aria-role' => 'banner'],
+ ['aria-role' => 'navigation'],
+ ]
+ ];
+
+ yield 'using data attributes in a sub-array' => [
+ ['data-foo' => 'this', 'data-bar' => 'that'],
+ [
+ ['data' => ['foo' => 'this']],
+ ['data' => ['bar' => 'that']],
+ ]
+ ];
+
+ yield 'turning data attributes from array to flat keys' => [
+ ['data-test' => 'bar'],
+ [
+ ['data' => ['test' => 'foo']],
+ ['data' => ['test' => 'bar']],
+ ]
+ ];
+
+ yield 'merging data attributes, where the array values overrides the flat one' => [
+ ['data-test' => 'baz'],
+ [
+ ['data-test' => 'foo'],
+ ['data' => ['test' => 'bar']],
+ ['data' => ['test' => 'baz']],
+ ]
+ ];
+
+ yield 'merging data attributes, where the flat ones overrides the array' => [
+ ['data-test' => 'baz'],
+ [
+ ['data' => ['test' => 'foo']],
+ ['data-test' => 'bar'],
+ ['data-test' => 'baz'],
+ ]
+ ];
+ }
+}