diff --git a/CHANGELOG b/CHANGELOG
index b105964dd4f..2d9ccd490c6 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,6 +1,6 @@
-# 3.22.2 (2026-XX-XX)
+# 3.23.0 (2026-XX-XX)
- * n/a
+ * Add the `html_attr` function and `html_attr_merge` as well as `html_attr_type` filters
# 3.22.2 (2025-12-14)
diff --git a/doc/filters/html_attr_merge.rst b/doc/filters/html_attr_merge.rst
new file mode 100644
index 00000000000..84af0f0be60
--- /dev/null
+++ b/doc/filters/html_attr_merge.rst
@@ -0,0 +1,141 @@
+``html_attr_merge``
+===================
+
+.. _html_attr_merge:
+
+.. versionadded:: 3.23
+
+ The ``html_attr_merge`` filter was added in Twig 3.23.
+
+The ``html_attr_merge`` filter merges multiple mappings that represent
+HTML attribute values. Such mappings contain the names of the HTML attributes
+as keys, and the corresponding values represent the attributes' values.
+
+It is primarily designed for working with arrays that are passed to the
+:ref:`html_attr` function. It closely resembles the :doc:`merge <../filters/merge>`
+filter, but has different merge behavior for values that are iterables
+themselves, as it will merge such values in turn.
+
+The filter returns a new merged array:
+
+.. code-block:: twig
+
+ {% set base = {class: ['btn'], type: 'button'} %}
+ {% set variant = {class: ['btn-primary'], disabled: true} %}
+
+ {% set merged = base|html_attr_merge(variant) %}
+
+ {# merged is now: {
+ class: ['btn', 'btn-primary'],
+ type: 'button',
+ disabled: true
+ } #}
+
+The filter accepts multiple arrays as arguments and merges them from left to right:
+
+.. code-block:: twig
+
+ {% set merged = base|html_attr_merge(variant1, variant2, variant3) %}
+
+A common use case is to build attribute mappings conditionally by merging multiple
+parts based on conditions. To make this conditional merging more convenient, filter
+arguments that are ``false``, ``null`` or empty arrays are ignored:
+
+.. code-block:: twig
+
+ {% set button_attrs = {
+ type: 'button',
+ class: ['btn']
+ }|html_attr_merge(
+ variant == 'primary' ? { class: ['btn-primary'] },
+ variant == 'secondary' ? { class: ['btn-secondary'] },
+ size == 'large' ? { class: ['btn-lg'] },
+ size == 'small' ? { class: ['btn-sm'] },
+ disabled ? { disabled: true, class: ['btn-disabled'] },
+ loading ? { 'aria-busy': 'true', class: ['btn-loading'] },
+ ) %}
+
+ {# Example with variant='primary', size='large', disabled=false, loading=true:
+
+ The false values (secondary variant, small size, disabled state) are ignored.
+
+ button_attrs is:
+ {
+ type: 'button',
+ class: ['btn', 'btn-primary', 'btn-lg', 'btn-loading'],
+ 'aria-busy': 'true'
+ }
+ #}
+
+Merging Rules
+-------------
+
+The filter follows these rules when merging attribute values:
+
+**Scalar values**: Later values override earlier ones.
+
+.. code-block:: twig
+
+ {% set result = {id: 'old'}|html_attr_merge({id: 'new'}) %}
+ {# result: {id: 'new'} #}
+
+**Array values**: Arrays are merged like in PHP's ``array_merge`` function - numeric keys are
+appended, non-numeric keys replace.
+
+.. code-block:: twig
+
+ {# Numeric keys (appended): #}
+ {% set result = {class: ['btn']}|html_attr_merge({class: ['btn-primary']}) %}
+ {# result: {class: ['btn', 'btn-primary']} #}
+
+ {# Non-numeric keys (replaced): #}
+ {% set result = {class: {base: 'btn', size: 'small'}}|html_attr_merge({class: {variant: 'primary', size: 'large'}}) %}
+ {# result: {class: {base: 'btn', size: 'large', variant: 'primary'}} #}
+
+.. note::
+
+ Remember, attribute mappings passed to or returned from this filter are regular
+ Twig mappings after all. If you want to completely replace an attribute value
+ that is an iterable with another value, you can use the :doc:`merge <../filters/merge>`
+ filter to do that.
+
+**``MergeableInterface`` implementations**: For advanced use cases, attribute values can be objects
+that implement the ``MergeableInterface``. These objects can define their own, custom merge
+behavior that takes precedence over the default rules. See the docblocks in that interface
+for details.
+
+.. note::
+
+ The ``html_attr_merge`` filter is part of the ``HtmlExtension`` which is not
+ installed by default. Install it first:
+
+ .. code-block:: bash
+
+ $ composer require twig/html-extra
+
+ Then, on Symfony projects, install the ``twig/extra-bundle``:
+
+ .. code-block:: bash
+
+ $ composer require twig/extra-bundle
+
+ Otherwise, add the extension explicitly on the Twig environment::
+
+ use Twig\Extra\Html\HtmlExtension;
+
+ $twig = new \Twig\Environment(...);
+ $twig->addExtension(new HtmlExtension());
+
+Arguments
+---------
+
+The filter accepts a variadic list of arguments to merge. Each argument can be:
+
+* A map of attributes
+* ``false`` or ``null`` (ignored, useful for conditional merging)
+* An empty string ``''`` (ignored, to support implicit else in ternary operators)
+
+.. seealso::
+
+ :ref:`html_attr`,
+ :doc:`html_attr_type`
diff --git a/doc/filters/html_attr_type.rst b/doc/filters/html_attr_type.rst
new file mode 100644
index 00000000000..af5dfa4c72d
--- /dev/null
+++ b/doc/filters/html_attr_type.rst
@@ -0,0 +1,123 @@
+``html_attr_type``
+==================
+
+.. _html_attr_type:
+
+.. versionadded:: 3.23
+
+ The ``html_attr_type`` filter was added in Twig 3.23.
+
+The ``html_attr_type`` filter converts arrays into specialized attribute value
+objects that implement custom rendering logic. It is designed for use
+with the :ref:`html_attr` function for attributes where
+the attribute value follows special formatting rules.
+
+.. code-block:: html+twig
+
+
+
+ {# Output: #}
+
+Available Types
+---------------
+
+Space-Separated Token List (``sst``)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Used for attributes that expect space-separated values, like ``class`` or
+``aria-labelledby``:
+
+.. code-block:: html+twig
+
+ {% set classes = ['btn', 'btn-primary']|html_attr_type('sst') %}
+
+
+
+ {# Output: #}
+
+This is the default type used when the :ref:`html_attr` function encounters an
+array value (except for ``style`` attributes).
+
+Comma-Separated Token List (``cst``)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Used for attributes that expect comma-separated values, like ``srcset`` or
+``sizes``:
+
+.. code-block:: html+twig
+
+
+
+ {# Output: #}
+
+Inline Style (``style``)
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+Used for style attributes. Handles both maps (property - value pairs) and sequences (CSS declarations):
+
+.. code-block:: html+twig
+
+ {# Associative array #}
+ {% set styles = {color: 'red', 'font-size': '14px'}|html_attr_type('style') %}
+
+
#}
+
+The ``style`` type is automatically applied by the :ref:`html_attr` function when
+it encounters an array value for the ``style`` attribute.
+
+.. note::
+
+ The ``html_attr_type`` filter is part of the ``HtmlExtension`` which is not
+ installed by default. Install it first:
+
+ .. code-block:: bash
+
+ $ composer require twig/html-extra
+
+ Then, on Symfony projects, install the ``twig/extra-bundle``:
+
+ .. code-block:: bash
+
+ $ composer require twig/extra-bundle
+
+ Otherwise, add the extension explicitly on the Twig environment::
+
+ use Twig\Extra\Html\HtmlExtension;
+
+ $twig = new \Twig\Environment(...);
+ $twig->addExtension(new HtmlExtension());
+
+Arguments
+---------
+
+* ``value``: The sequence of attributes to convert
+* ``type``: The attribute type. One of:
+
+ * ``sst`` (default): Space-separated token list
+ * ``cst``: Comma-separated token list
+ * ``style``: Inline CSS styles
+
+.. seealso::
+
+ :ref:`html_attr`,
+ :ref:`html_attr_merge`
diff --git a/doc/filters/index.rst b/doc/filters/index.rst
index 98a5d62dd6b..9827cdd5a7b 100644
--- a/doc/filters/index.rst
+++ b/doc/filters/index.rst
@@ -26,6 +26,8 @@ Filters
format_datetime
format_number
format_time
+ html_attr_merge
+ html_attr_type
html_to_markdown
inline_css
inky_to_html
diff --git a/doc/functions/html_attr.rst b/doc/functions/html_attr.rst
new file mode 100644
index 00000000000..5dcd7b369a2
--- /dev/null
+++ b/doc/functions/html_attr.rst
@@ -0,0 +1,198 @@
+``html_attr``
+=============
+
+.. _html_attr:
+
+.. versionadded:: 3.23
+
+ The ``html_attr`` function was added in Twig 3.23.
+
+The ``html_attr`` function renders HTML attributes from one or more mappings,
+taking care of proper escaping. The mappings contain the names of HTML
+attributes as keys, and the corresponding values represent the attributes'
+values.
+
+.. code-block:: html+twig
+
+
+ Content
+
+
+ {# Output:
Content
#}
+
+The function accepts multiple attribute maps. Internally, it uses
+:ref:`html_attr_merge` to combine the arguments:
+
+.. code-block:: html+twig
+
+ {% set base_attrs = {class: ['btn']} %}
+ {% set variant_attrs = {class: ['btn-primary'], disabled: true} %}
+
+
+
+ {# Output: #}
+
+.. note::
+
+ To make best use of the special merge behavior of ``html_attr_merge`` and
+ to avoid confusion, you should consistently use iterables (mappings or sequences)
+ for attributes that can take multiple values, like ``class``, ``srcset`` or ``aria-describedby``.
+
+ Use non-iterable values for attributes that contain a single value only, like
+ ``id`` or ``href``.
+
+Shorthand notation for mappings can be particularly helpful:
+
+.. code-block:: html+twig
+
+ {% set id = 'user-123' %}
+ {% set href = '/profile' %}
+
+ Profile
+
+ {# Output: Profile #}
+
+``null`` and Boolean Attribute Values
+-------------------------------------
+
+``null`` values always omit printing an attribute altogether.
+
+The boolean ``false`` value also omits the attribute altogether, with an
+exception for ``aria-*`` attribute names, see below.
+
+.. code-block:: html+twig
+
+ {# null omits the attribute entirely, and so does false for non-"aria-*" #}
+
+ {# Output: #}
+
+``true`` will print the attribute with the empty value ``""``. This is XHTML compatible,
+and in HTML 5 equivalent to using the short attribute notation without a value. An exception
+is made for ``data-*`` and ``aria-*`` attributes, see below.
+
+.. code-block:: html+twig
+
+ {# true becomes an empty string value #}
+
+ {# Output: , which is equivalent to #}
+
+Array Values
+------------
+
+Attribute values that are iterables are automatically converted to space-separated
+token lists of the values. Exceptions apply for ``data-*`` and ``style`` attributes,
+described further below.
+
+.. code-block:: html+twig
+
+
+ Button
+
+
+ {# Output:
Button
#}
+
+.. note::
+
+ This is not bound to the ``class`` attribute name, but works for any attribute.
+
+You can use the :ref:`html_attr_type` filter to specify a different strategy for
+concatenating values (e.g., comma-separated for ``srcset`` attributes). This would
+also override the special behavior for ``data-*`` and ``style``.
+
+WAI-ARIA Attributes
+-------------------
+
+To make it more convenient to work with the `WAI-ARIA type mapping for HTML
+_`, boolean values for ``aria-*``
+attributes are converted to strings ``"true"`` and ``"false"``.
+
+.. code-block:: html+twig
+
+
+
+ {# Output: #}
+
+Data Attributes
+---------------
+
+For ``data-*`` attributes, boolean ``true`` values will be converted to ``"true"``.
+Values that are not scalars are automatically JSON-encoded.
+
+.. code-block:: html+twig
+
+
+ Content
+
+
+ {# Output:
Content
#}
+
+Style Attribute
+----------------
+
+The ``style`` attribute name has special handling when its value is iterable:
+
+.. code-block:: html+twig
+
+ {# Non-numeric keys will be used as CSS properties and printed #}
+
+ Styled text
+
+
+ {# Output:
Styled text
#}
+
+ {# Numeric keys will be assumed to have values that are individual CSS declarations #}
+
+ Styled text
+
+
+ {# Output:
Styled text
#}
+
+ {# Merging style attributes #}
+
+ Styled text
+
+
+ {# Output:
Styled text
#}
+
+.. warning::
+
+ No additional escaping specific to CSS is applied to key or values from this array.
+ Do not use it to pass untrusted, user-provided data, neither as key nor as value.
+
+``AttributeValueInterface`` Implementations
+-------------------------------------------
+
+For advanced use cases, attribute values can be objects that implement the ``AttributeValueInterface``.
+These objects can define their own conversion logic for the ``html_attr`` function that will take
+precedence over all rules described here. See the docblocks in that interface for details.
+
+.. note::
+
+ The ``html_attr`` function is part of the ``HtmlExtension`` which is not
+ installed by default. Install it first:
+
+ .. code-block:: bash
+
+ $ composer require twig/html-extra
+
+ Then, on Symfony projects, install the ``twig/extra-bundle``:
+
+ .. code-block:: bash
+
+ $ composer require twig/extra-bundle
+
+ Otherwise, add the extension explicitly on the Twig environment::
+
+ use Twig\Extra\Html\HtmlExtension;
+
+ $twig = new \Twig\Environment(...);
+ $twig->addExtension(new HtmlExtension());
+
+.. seealso::
+
+ :ref:`html_attr_merge`,
+ :ref:`html_attr_type`
diff --git a/doc/functions/index.rst b/doc/functions/index.rst
index 557f6938a30..c0de384bb6c 100644
--- a/doc/functions/index.rst
+++ b/doc/functions/index.rst
@@ -12,6 +12,7 @@ Functions
dump
enum
enum_cases
+ html_attr
html_classes
html_cva
include
diff --git a/extra/html-extra/HtmlAttr/AttributeValueInterface.php b/extra/html-extra/HtmlAttr/AttributeValueInterface.php
new file mode 100644
index 00000000000..590b0f3a8cf
--- /dev/null
+++ b/extra/html-extra/HtmlAttr/AttributeValueInterface.php
@@ -0,0 +1,31 @@
+
+ */
+interface AttributeValueInterface
+{
+ /**
+ * Returns the string representation of the attribute value. The returned value
+ * will automatically be escaped for the HTML attribute context.
+ *
+ * @return string|null the attribute value as a string, or null to omit the attribute
+ */
+ public function getValue(): ?string;
+}
diff --git a/extra/html-extra/HtmlAttr/InlineStyle.php b/extra/html-extra/HtmlAttr/InlineStyle.php
new file mode 100644
index 00000000000..8aa36b6462f
--- /dev/null
+++ b/extra/html-extra/HtmlAttr/InlineStyle.php
@@ -0,0 +1,70 @@
+
+ */
+final class InlineStyle implements MergeableInterface, AttributeValueInterface
+{
+ private readonly array $value;
+
+ public function __construct(mixed $value)
+ {
+ if (!is_iterable($value)) {
+ throw new RuntimeError('InlineStyle can only be created from iterable values.');
+ }
+
+ $this->value = [...$value];
+ }
+
+ public function mergeInto(mixed $previous): mixed
+ {
+ if ($previous instanceof self) {
+ return new self([...$previous->value, ...$this->value]);
+ }
+
+ if (is_iterable($previous)) {
+ return new self([...$previous, ...$this->value]);
+ }
+
+ throw new RuntimeError('Attributes using InlineStyle can only be merged with iterables or other InlineStyle instances.');
+ }
+
+ public function appendFrom(mixed $newValue): mixed
+ {
+ if (!is_iterable($newValue)) {
+ throw new RuntimeError('Only iterable values can be appended to InlineStyle.');
+ }
+
+ return new self([...$this->value, ...$newValue]);
+ }
+
+ public function getValue(): ?string
+ {
+ $style = '';
+ foreach ($this->value as $name => $value) {
+ if (empty($value) || true === $value) {
+ continue;
+ }
+ if (is_numeric($name)) {
+ $style .= trim($value, '; ').'; ';
+ } else {
+ $style .= $name.': '.$value.'; ';
+ }
+ }
+
+ return trim($style) ?: null;
+ }
+}
diff --git a/extra/html-extra/HtmlAttr/MergeableInterface.php b/extra/html-extra/HtmlAttr/MergeableInterface.php
new file mode 100644
index 00000000000..e32693fa2d4
--- /dev/null
+++ b/extra/html-extra/HtmlAttr/MergeableInterface.php
@@ -0,0 +1,55 @@
+
+ */
+interface MergeableInterface
+{
+ /**
+ * Merge value from $this with another, previous value. In the merge arguments list, $this is on the right
+ * hand side of $previous. This is the preferred merge method, so that newer (right) values have control
+ * over the result.
+ *
+ * The `$previous` value is whatever value was present for a particular attribute before. Implementations
+ * are free to completely ignore this value, effectively implementing "override only" merge behavior.
+ * They can merge it with their own value, resulting in array-like merge behavior. Or they could throw
+ * an exception when only particular types can be merged.
+ *
+ * Returns the new, resulting value as a new instance. Does not modify either $this not $previous.
+ */
+ public function mergeInto(mixed $previous): mixed;
+
+ /**
+ * Merge value from $this with a new value. In the merge arguments list, $this is on the left hand side of
+ * $newValue, but $newValue does not implement this interface itself.
+ *
+ * The `$newValue` value is whatever was given in the right hand side merge argument. Implementations are
+ * free to return either the new value, implementing "override" merge behavior. They could also return
+ * a value that represents a merge result between their current and the new value. Or they could throw
+ * an exception when only particular types can be merged.
+ *
+ * Returns the new, resulting set as a new instance. Does not modify $this.
+ */
+ public function appendFrom(mixed $newValue): mixed;
+}
diff --git a/extra/html-extra/HtmlAttr/SeparatedTokenList.php b/extra/html-extra/HtmlAttr/SeparatedTokenList.php
new file mode 100644
index 00000000000..39e904ea5e2
--- /dev/null
+++ b/extra/html-extra/HtmlAttr/SeparatedTokenList.php
@@ -0,0 +1,68 @@
+
+ */
+final class SeparatedTokenList implements AttributeValueInterface, MergeableInterface
+{
+ private readonly array $value;
+
+ public function __construct(mixed $value, private readonly string $separator = ' ')
+ {
+ if (is_iterable($value)) {
+ $this->value = [...$value];
+ } elseif (\is_scalar($value)) {
+ $this->value = [$value];
+ } else {
+ throw new RuntimeError('SeparatedTokenList can only be constructed from iterable or scalar values.');
+ }
+ }
+
+ public function mergeInto(mixed $previous): mixed
+ {
+ if ($previous instanceof self && $previous->separator === $this->separator) {
+ return new self([...$previous->value, ...$this->value], $this->separator);
+ }
+
+ if (is_iterable($previous)) {
+ return new self([...$previous, ...$this->value], $this->separator);
+ }
+
+ throw new RuntimeError('SeparatedTokenList can only be merged with iterables or other SeparatedTokenList instances using the same separator.');
+ }
+
+ public function appendFrom(mixed $newValue): mixed
+ {
+ if (!is_iterable($newValue)) {
+ throw new RuntimeError('Only iterable values can be appended to SeparatedTokenList.');
+ }
+
+ return new self([...$this->value, ...$newValue], $this->separator);
+ }
+
+ public function getValue(): ?string
+ {
+ $filtered = array_filter($this->value, fn ($v) => null !== $v && false !== $v);
+
+ // Omit attribute if list contains only false or null values
+ if (!$filtered) {
+ return null;
+ }
+
+ // true values are not printed, but result in the attribute not being omitted
+ return trim(implode($this->separator, array_filter($filtered, fn ($v) => true !== $v)));
+ }
+}
diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php
index cba22427ba1..391a2efdfef 100644
--- a/extra/html-extra/HtmlExtension.php
+++ b/extra/html-extra/HtmlExtension.php
@@ -12,9 +12,15 @@
namespace Twig\Extra\Html;
use Symfony\Component\Mime\MimeTypes;
+use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Extension\AbstractExtension;
+use Twig\Extra\Html\HtmlAttr\AttributeValueInterface;
+use Twig\Extra\Html\HtmlAttr\InlineStyle;
+use Twig\Extra\Html\HtmlAttr\MergeableInterface;
+use Twig\Extra\Html\HtmlAttr\SeparatedTokenList;
use Twig\Markup;
+use Twig\Runtime\EscaperRuntime;
use Twig\TwigFilter;
use Twig\TwigFunction;
@@ -31,6 +37,8 @@ public function getFilters(): array
{
return [
new TwigFilter('data_uri', [$this, 'dataUri']),
+ new TwigFilter('html_attr_merge', [self::class, 'htmlAttrMerge']),
+ new TwigFilter('html_attr_type', [self::class, 'htmlAttrType']),
];
}
@@ -39,6 +47,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']]),
];
}
@@ -114,10 +123,10 @@ public static function htmlClasses(...$args): string
}
/**
- * @param string|list $base
+ * @param string|list $base
* @param array>> $variants
- * @param array>> $compoundVariants
- * @param array $defaultVariant
+ * @param array>> $compoundVariants
+ * @param array $defaultVariant
*
* @internal
*/
@@ -125,4 +134,123 @@ public static function htmlCva(array|string $base = [], array $variants = [], ar
{
return new Cva($base, $variants, $compoundVariants, $defaultVariant);
}
+
+ /** @internal */
+ public static function htmlAttrType(mixed $value, string $type = 'sst'): AttributeValueInterface
+ {
+ switch ($type) {
+ case 'sst':
+ return new SeparatedTokenList($value, ' ');
+ case 'cst':
+ return new SeparatedTokenList($value, ', ');
+ case 'style':
+ return new InlineStyle($value);
+ default:
+ throw new RuntimeError(\sprintf('Unknown attribute type "%s" The only supported types are "sst", "cst" and "style".', $type));
+ }
+ }
+
+ public static function htmlAttrMerge(iterable|string|false|null ...$arrays): array
+ {
+ $result = [];
+
+ foreach ($arrays as $array) {
+ if (!$array) {
+ continue;
+ }
+
+ if (\is_string($array)) {
+ throw new RuntimeError('Only empty strings may be passed as string arguments to html_attr_merge. This is to support the implicit else clause for ternary operators.');
+ }
+
+ foreach ($array as $key => $value) {
+ if (!isset($result[$key])) {
+ $result[$key] = $value;
+
+ continue;
+ }
+
+ $existing = $result[$key];
+
+ switch (true) {
+ case $value instanceof MergeableInterface:
+ $result[$key] = $value->mergeInto($existing);
+ break;
+ case $existing instanceof MergeableInterface:
+ $result[$key] = $existing->appendFrom($value);
+ break;
+ case is_iterable($existing) && is_iterable($value):
+ $result[$key] = [...$existing, ...$value];
+ break;
+ case (\is_scalar($existing) || \is_object($existing)) && (\is_scalar($value) || \is_object($value)):
+ $result[$key] = $value;
+ break;
+ default:
+ throw new RuntimeError(\sprintf('Cannot merge incompatible values for key "%s".', $key));
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ public static function htmlAttr(Environment $env, iterable|string|false|null ...$args): string
+ {
+ $attr = self::htmlAttrMerge(...$args);
+
+ $result = '';
+ $runtime = $env->getRuntime(EscaperRuntime::class);
+
+ foreach ($attr as $name => $value) {
+ if (str_starts_with($name, 'aria-')) {
+ // For aria-*, convert booleans to "true" and "false" strings
+ if (true === $value) {
+ $value = 'true';
+ } elseif (false === $value) {
+ $value = 'false';
+ }
+ }
+
+ if (str_starts_with($name, 'data-')) {
+ if (!$value instanceof AttributeValueInterface && null !== $value && !\is_scalar($value)) {
+ // ... encode non-null non-scalars as JSON
+ $value = json_encode($value);
+ } elseif (true === $value) {
+ // ... and convert boolean true to a 'true' string.
+ $value = 'true';
+ }
+ }
+
+ // Convert iterable values to token lists
+ if (!$value instanceof AttributeValueInterface && is_iterable($value)) {
+ if ('style' === $name) {
+ $value = new InlineStyle($value);
+ } else {
+ $value = new SeparatedTokenList($value);
+ }
+ }
+
+ if ($value instanceof AttributeValueInterface) {
+ $value = $value->getValue();
+ }
+
+ // In general, ...
+ if (true === $value) {
+ // ... use attribute="" for boolean true,
+ // which is XHTML compliant and indicates the "empty value default", see
+ // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 and
+ // https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes
+ $value = '';
+ }
+
+ if (null === $value || false === $value) {
+ // omit null-valued and false attributes completely (note aria-* has been processed before)
+ continue;
+ }
+
+ $result .= $runtime->escape($name, 'html_attr').'="'.$runtime->escape($value).'" ';
+ }
+
+ return trim($result);
+ }
}
diff --git a/extra/html-extra/Tests/Fixtures/html_attr.test b/extra/html-extra/Tests/Fixtures/html_attr.test
new file mode 100644
index 00000000000..ca2728670de
--- /dev/null
+++ b/extra/html-extra/Tests/Fixtures/html_attr.test
@@ -0,0 +1,38 @@
+--TEST--
+"html_attr" function
+--TEMPLATE--
+Simple attributes:
+Appropriate escaping: &\'"' }) }}/>
+Empty attribute list:
+Using a short ternary:
+boolean true attribute:
+boolean false attribute:
+null attribute value:
+empty string attribute value:
+ARIA attribute boolean conversion:
+data-* attribute handling :
+array value concatenation ex. 1:
+array value concatenation ex. 2:
+inline styles:
+style with a plain value:
+using a "comma separated token list" attribute:
+merging a "comma separated token list" value with more array values:
+--DATA--
+return []
+--EXPECT--
+Simple attributes:
+Appropriate escaping:
+Empty attribute list:
+Using a short ternary:
+boolean true attribute:
+boolean false attribute:
+null attribute value:
+empty string attribute value:
+ARIA attribute boolean conversion:
+data-* attribute handling :
+array value concatenation ex. 1:
+array value concatenation ex. 2:
+inline styles:
+style with a plain value:
+using a "comma separated token list" attribute:
+merging a "comma separated token list" value with more array values:
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..03d36905fe2
--- /dev/null
+++ b/extra/html-extra/Tests/Fixtures/html_attr_merge.test
@@ -0,0 +1,10 @@
+--TEST--
+"html_attr_merge" filter
+--TEMPLATE--
+{% autoescape false %}
+{{ { foo: 'bar' } | html_attr_merge({ bar: 'baz', foo: 'qux' }, { foo: 'quux' }) | json_encode }}
+{% endautoescape %}
+--DATA--
+return []
+--EXPECT--
+{"foo":"quux","bar":"baz"}
diff --git a/extra/html-extra/Tests/HtmlAttrMergeTest.php b/extra/html-extra/Tests/HtmlAttrMergeTest.php
new file mode 100644
index 00000000000..d129a5adf43
--- /dev/null
+++ b/extra/html-extra/Tests/HtmlAttrMergeTest.php
@@ -0,0 +1,223 @@
+ [
+ ['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' => [
+ ['key' => 'other'],
+ [
+ ['key' => 'this'],
+ ['key' => 'that'],
+ ['key' => 'other'],
+ ],
+ ];
+
+ yield 'later keys override previous ones - as before, but there is no magic in attribute names like "id" or "class"' => [
+ ['class' => 'other'],
+ [
+ ['class' => 'this'],
+ ['class' => 'that'],
+ ['class' => 'other'],
+ ],
+ ];
+
+ yield 'in "merge array" mode, array_merge semantics will override non-numerical keys, but combine numerical ones' => [
+ ['something' => ['first' => 'baz', 'second' => 'bar', 0 => 'other', 1 => 'more']],
+ [
+ ['something' => ['first' => 'foo']],
+ ['something' => ['second' => 'bar']],
+ ['something' => ['first' => 'baz']],
+ ['something' => ['other']],
+ ['something' => ['more']],
+ ],
+ ];
+
+ yield 'ignore empty arrays, null or false values passed as arguments' => [
+ ['something' => 'foo'],
+ [
+ ['something' => 'foo'],
+ [],
+ null,
+ false,
+ ],
+ ];
+
+ yield 'there is no special handling for scalars like true, false or null' => [
+ ['this' => true, 'that' => false, 'other' => null],
+ [
+ ['this' => true],
+ ['that' => false],
+ ['other' => null],
+ ],
+ ];
+
+ 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']],
+ ],
+ ];
+
+ // MergeableInterface
+ yield 'MergeableInterface mergeInto is called when new value implements interface' => [
+ ['class' => 'merged: old + new'],
+ [
+ ['class' => 'old'],
+ ['class' => new MergeableStub('new')],
+ ],
+ ];
+
+ yield 'MergeableInterface appendFrom is called when existing value implements interface' => [
+ ['class' => 'appended: old + new'],
+ [
+ ['class' => new MergeableStub('old')],
+ ['class' => 'new'],
+ ],
+ ];
+
+ yield 'MergeableInterface mergeInto is called when both implement interface' => [
+ ['class' => 'merged: value1 + value2'],
+ [
+ ['class' => new MergeableStub('value1')],
+ ['class' => new MergeableStub('value2')],
+ ],
+ ];
+
+ yield 'MergeableInterface with array value' => [
+ ['class' => 'appended: base + extra1, extra2'],
+ [
+ ['class' => new MergeableStub('base')],
+ ['class' => ['extra1', 'extra2']],
+ ],
+ ];
+
+ // Scalar and object merging
+ yield 'string replaces object' => [
+ ['value' => 'new-string'],
+ [
+ ['value' => new \stdClass()],
+ ['value' => 'new-string'],
+ ],
+ ];
+
+ yield 'object replaces string' => [
+ ['value' => new \stdClass()],
+ [
+ ['value' => 'old-string'],
+ ['value' => new \stdClass()],
+ ],
+ ];
+ }
+
+ public function testIncompatibleValuesMergeThrowsException()
+ {
+ $this->expectException(RuntimeError::class);
+ $this->expectExceptionMessage('Cannot merge incompatible values for key "test"');
+
+ HtmlExtension::htmlAttrMerge(
+ ['test' => ['array']],
+ ['test' => 'scalar']
+ );
+ }
+}
+
+class MergeableStub implements MergeableInterface
+{
+ public function __construct(private readonly mixed $value)
+ {
+ }
+
+ public function mergeInto(mixed $previous): mixed
+ {
+ $previousValue = $previous instanceof self ? $previous->value : $previous;
+
+ return new self("merged: {$previousValue} + {$this->value}");
+ }
+
+ public function appendFrom(mixed $newValue): mixed
+ {
+ if (\is_array($newValue)) {
+ $newValue = implode(', ', $newValue);
+ } elseif ($newValue instanceof self) {
+ $newValue = $newValue->value;
+ }
+
+ return new self("appended: {$this->value} + {$newValue}");
+ }
+
+ public function __toString(): string
+ {
+ return (string) $this->value;
+ }
+}
diff --git a/extra/html-extra/Tests/HtmlAttrTest.php b/extra/html-extra/Tests/HtmlAttrTest.php
new file mode 100644
index 00000000000..5c23ebe7c1e
--- /dev/null
+++ b/extra/html-extra/Tests/HtmlAttrTest.php
@@ -0,0 +1,296 @@
+ [
+ 'id="some-id" label="some-label" role="main"',
+ [
+ ['id' => 'some-id'],
+ null,
+ '',
+ false,
+ ['label' => 'some-label'],
+ ['role' => 'main'],
+ ],
+ ];
+
+ // Boolean attribute handling
+ yield 'boolean true renders as empty string, except for aria-* and data-* it uses "true"' => [
+ 'required="" aria-disabled="true" data-yes="true"',
+ [
+ ['required' => true, 'aria-disabled' => true, 'data-yes' => true],
+ ],
+ ];
+
+ yield 'boolean false omits attribute, except for aria-* it uses "false"' => [
+ 'aria-disabled="false"',
+ [
+ ['disabled' => false, 'aria-disabled' => false, 'data-gone' => false],
+ ],
+ ];
+
+ yield 'null value omits attribute, also for special cases' => [
+ '',
+ [
+ ['title' => null, 'style' => null, 'aria-gone' => null, 'data-nil' => null],
+ ],
+ ];
+
+ yield 'empty string renders as empty attribute value' => [
+ 'title=""',
+ [
+ ['title' => ''],
+ ],
+ ];
+
+ // Data attributes
+ yield 'data attribute with array is JSON encoded' => [
+ 'data-config="{"theme":"dark"}"',
+ [
+ ['data-config' => ['theme' => 'dark']],
+ ],
+ ];
+
+ // In general, array values are printed as space-separated token lists
+ yield 'array value renders as space-separated token list' => [
+ 'class="btn btn-primary btn-lg"',
+ [
+ ['class' => ['btn', 'btn-primary', 'btn-lg']],
+ ],
+ ];
+
+ yield 'arrays with just an empty string produce the empty attribute' => [
+ 'foo=""',
+ [
+ ['foo' => ['']],
+ ],
+ ];
+
+ yield 'arrays with just a true value produce the empty attribute' => [
+ 'foo=""',
+ [
+ ['foo' => [true]],
+ ],
+ ];
+
+ yield 'arrays with just a null value are not printed' => [
+ '',
+ [
+ ['foo' => [null]],
+ ],
+ ];
+
+ // Style attributes
+ yield 'style with plain string value' => [
+ 'style="color: red;"',
+ [
+ ['style' => 'color: red;'],
+ ],
+ ];
+
+ yield 'style with associative array' => [
+ 'style="color: red; font-size: 16px;"',
+ [
+ ['style' => ['color' => 'red', 'font-size' => '16px']],
+ ],
+ ];
+
+ yield 'style with numeric array' => [
+ 'style="color: red; font-size: 16px;"',
+ [
+ ['style' => ['color: red', 'font-size: 16px']],
+ ],
+ ];
+
+ yield 'merging style attributes overrides by key' => [
+ 'style="color: blue; font-size: 14px;"',
+ [
+ ['style' => ['color' => 'red', 'font-size' => '14px']],
+ ['style' => ['color' => 'blue']],
+ ],
+ ];
+
+ // Escaping
+ yield 'attribute name is escaped' => [
+ 'data-user id="123"',
+ [
+ ['data-user id' => '123'],
+ ],
+ ];
+
+ yield 'attribute value is escaped' => [
+ 'title="<script>alert("xss")</script>"',
+ [
+ ['title' => ''],
+ ],
+ ];
+
+ // Variadic merging scenarios
+ yield 'scalar value overrides from left to right' => [
+ 'id="final"',
+ [
+ ['id' => 'first'],
+ ['id' => 'second'],
+ ['id' => 'final'],
+ ],
+ ];
+
+ yield 'variadic with mixed false and null values' => [
+ 'id="test"',
+ [
+ ['id' => 'test'],
+ null,
+ false,
+ null,
+ ],
+ ];
+
+ yield 'variadic with empty arrays' => [
+ 'id="test"',
+ [
+ [],
+ ['id' => 'test'],
+ [],
+ ],
+ ];
+
+ yield 'variadic with empty string values' => [
+ 'id="test"',
+ [
+ '',
+ ['id' => 'test'],
+ '',
+ ],
+ ];
+
+ // AttributeValueInterface
+ yield 'AttributeValueInterface with string value' => [
+ 'custom="custom-value"',
+ [
+ ['custom' => new AttributeValueStub('custom-value')],
+ ],
+ ];
+
+ yield 'AttributeValueInterface with null value omits attribute' => [
+ '',
+ [
+ ['custom' => new AttributeValueStub(null)],
+ ],
+ ];
+
+ yield 'AttributeValueInterface wins over special case handling for style and data-*' => [
+ 'style="some style" data-custom="not JSON"',
+ [
+ ['style' => new AttributeValueStub('some style'), 'data-custom' => new AttributeValueStub('not JSON')],
+ ],
+ ];
+
+ // Edge cases
+ yield 'numeric attribute value' => [
+ 'tabindex="0"',
+ [
+ ['tabindex' => 0],
+ ],
+ ];
+
+ yield 'zero is not treated as falsy' => [
+ 'data-count="0"',
+ [
+ ['data-count' => 0],
+ ],
+ ];
+
+ // Scalar and object merging in rendering
+ yield 'string replaces object in rendering' => [
+ 'value="new-string"',
+ [
+ ['value' => new \stdClass()],
+ ['value' => 'new-string'],
+ ],
+ ];
+
+ yield 'object replaces string in rendering uses __toString if available' => [
+ 'value="stringable-object"',
+ [
+ ['value' => 'old-string'],
+ ['value' => new StringableStub('stringable-object')],
+ ],
+ ];
+ }
+
+ public function testIterableObjectCastedToArray()
+ {
+ /*
+ This test case demonstrates how objects could e. g. implement helper logic
+ to construct more complex attribute combinations and sets, and be passed as
+ one argument to html_attr as well.
+ */
+ $object = new class implements \IteratorAggregate {
+ public function getIterator(): \Traversable
+ {
+ return new \ArrayIterator([
+ 'data-controller' => new SeparatedTokenList(['dropdown', 'tooltip']),
+ 'data-action' => new SeparatedTokenList(['click->dropdown#toggle', 'mouseover->tooltip#show']),
+ ]);
+ }
+ };
+
+ $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), $object);
+
+ self::assertSame('data-controller="dropdown tooltip" data-action="click->dropdown#toggle mouseover->tooltip#show"', $result);
+ }
+}
+
+class StringableStub implements \Stringable
+{
+ public function __construct(private readonly string $value)
+ {
+ }
+
+ public function __toString(): string
+ {
+ return $this->value;
+ }
+}
+
+class AttributeValueStub implements AttributeValueInterface
+{
+ public function __construct(private readonly ?string $value)
+ {
+ }
+
+ public function getValue(): ?string
+ {
+ return $this->value;
+ }
+}