mirror of
https://github.com/twigphp/Twig.git
synced 2026-09-01 04:57:23 +00:00
Add an html_attr function to make outputting HTML attributes easier
This commit is contained in:
committed by
Fabien Potencier
parent
0319c822d1
commit
42c12fa720
@@ -3,6 +3,7 @@
|
||||
* Add support for renaming variables in object destructuring (`{name: userName} = user`)
|
||||
* Add `html_attr_relaxed` escaping strategy that preserves :, @, [, and ] for front-end framework attribute names
|
||||
* Add support for short-circuiting in null-safe operator chains
|
||||
* Add the `html_attr` function and `html_attr_merge` as well as `html_attr_type` filters
|
||||
|
||||
# 3.23.0 (2026-01-23)
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
``html_attr_merge``
|
||||
===================
|
||||
|
||||
.. _html_attr_merge:
|
||||
|
||||
.. versionadded:: 3.24
|
||||
|
||||
The ``html_attr_merge`` filter was added in Twig 3.24.
|
||||
|
||||
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`
|
||||
@@ -0,0 +1,123 @@
|
||||
``html_attr_type``
|
||||
==================
|
||||
|
||||
.. _html_attr_type:
|
||||
|
||||
.. versionadded:: 3.24
|
||||
|
||||
The ``html_attr_type`` filter was added in Twig 3.24.
|
||||
|
||||
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
|
||||
|
||||
<img {{ html_attr({
|
||||
srcset: ['small.jpg 480w', 'large.jpg 1200w']|html_attr_type('cst')
|
||||
}) }}>
|
||||
|
||||
{# Output: <img srcset="small.jpg 480w, large.jpg 1200w"> #}
|
||||
|
||||
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') %}
|
||||
|
||||
<button {{ html_attr({class: classes}) }}>
|
||||
Click me
|
||||
</button>
|
||||
|
||||
{# Output: <button class="btn btn-primary">Click me</button> #}
|
||||
|
||||
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
|
||||
|
||||
<img {{ html_attr({
|
||||
srcset: ['image-1x.jpg 1x', 'image-2x.jpg 2x', 'image-3x.jpg 3x']|html_attr_type('cst'),
|
||||
sizes: ['(max-width: 600px) 100vw', '50vw']|html_attr_type('cst')
|
||||
}) }}>
|
||||
|
||||
{# Output: <img srcset="image-1x.jpg 1x, image-2x.jpg 2x, image-3x.jpg 3x" sizes="(max-width: 600px) 100vw, 50vw"> #}
|
||||
|
||||
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') %}
|
||||
|
||||
<div {{ html_attr({style: styles}) }}>
|
||||
Styled content
|
||||
</div>
|
||||
|
||||
{# Output: <div style="color: red; font-size: 14px;">Styled content</div> #}
|
||||
|
||||
{# Numeric array #}
|
||||
{% set styles = ['color: red', 'font-size: 14px']|html_attr_type('style') %}
|
||||
|
||||
<div {{ html_attr({style: styles}) }}>
|
||||
Styled content
|
||||
</div>
|
||||
|
||||
{# Output: <div style="color: red; font-size: 14px;">Styled content</div> #}
|
||||
|
||||
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`
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
``html_attr``
|
||||
=============
|
||||
|
||||
.. _html_attr:
|
||||
|
||||
.. versionadded:: 3.23
|
||||
|
||||
The ``html_attr`` function was added in Twig 3.24.
|
||||
|
||||
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.
|
||||
|
||||
.. note::
|
||||
|
||||
Attribute names are escaped using the ``html_attr_relaxed`` strategy.
|
||||
|
||||
.. code-block:: html+twig
|
||||
|
||||
<div {{ html_attr({class: ['foo', 'bar'], id: 'main'}) }}>
|
||||
Content
|
||||
</div>
|
||||
|
||||
{# Output: <div class="foo bar" id="main">Content</div> #}
|
||||
|
||||
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} %}
|
||||
|
||||
<button {{ html_attr(base_attrs, variant_attrs) }}>
|
||||
Click me
|
||||
</button>
|
||||
|
||||
{# Output: <button class="btn btn-primary" disabled="">Click me</button> #}
|
||||
|
||||
.. 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' %}
|
||||
|
||||
<a {{ html_attr({id, href}) }}>Profile</a>
|
||||
|
||||
{# Output: <a id="user-123" href="/profile">Profile</a> #}
|
||||
|
||||
``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-*" #}
|
||||
<input {{ html_attr({disabled: false, title: null}) }}>
|
||||
{# Output: <input> #}
|
||||
|
||||
``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 #}
|
||||
<input {{ html_attr({required: true}) }}>
|
||||
{# Output: <input required="">, which is equivalent to <input required> #}
|
||||
|
||||
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
|
||||
|
||||
<div {{ html_attr({class: ['btn', 'btn-primary', 'btn-lg']}) }}>
|
||||
Button
|
||||
</div>
|
||||
|
||||
{# Output: <div class="btn btn-primary btn-lg">Button</div> #}
|
||||
|
||||
.. 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
|
||||
<https://www.w3.org/TR/wai-aria-1.2/#typemapping>_`, boolean values for ``aria-*``
|
||||
attributes are converted to strings ``"true"`` and ``"false"``.
|
||||
|
||||
.. code-block:: html+twig
|
||||
|
||||
<button {{ html_attr({'aria-pressed': true, 'aria-hidden': false}) }}>
|
||||
Toggle
|
||||
</button>
|
||||
|
||||
{# Output: <button aria-pressed="true" aria-hidden="false">Toggle</button> #}
|
||||
|
||||
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
|
||||
|
||||
<div {{ html_attr({'data-config': {theme: 'dark', size: 'large'}, 'data-bool': true, 'data-false': false}) }}>
|
||||
Content
|
||||
</div>
|
||||
|
||||
{# Output: <div data-config="{"theme":"dark","size":"large"}" data-bool="true">Content</div> #}
|
||||
|
||||
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 #}
|
||||
<div {{ html_attr({style: {color: 'red', 'font-size': '16px'}}) }}>
|
||||
Styled text
|
||||
</div>
|
||||
|
||||
{# Output: <div style="color: red; font-size: 16px;">Styled text</div> #}
|
||||
|
||||
{# Numeric keys will be assumed to have values that are individual CSS declarations #}
|
||||
<div {{ html_attr({style: ['color: red', 'font-size: 16px']}) }}>
|
||||
Styled text
|
||||
</div>
|
||||
|
||||
{# Output: <div style="color: red; font-size: 16px;">Styled text</div> #}
|
||||
|
||||
{# Merging style attributes #}
|
||||
<div {{ html_attr({style: {color: 'red'}}, {style: {color: 'blue', background: 'white'}}) }}>
|
||||
Styled text
|
||||
</div>
|
||||
|
||||
{# Output: <div style="color: blue; background: white;">Styled text</div> #}
|
||||
|
||||
.. 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`
|
||||
@@ -12,6 +12,7 @@ Functions
|
||||
dump
|
||||
enum
|
||||
enum_cases
|
||||
html_attr
|
||||
html_classes
|
||||
html_cva
|
||||
include
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extra\Html\HtmlAttr;
|
||||
|
||||
/**
|
||||
* Interface for custom attribute value objects.
|
||||
*
|
||||
* Implement this interface to create custom conversion logic when printing out objects as attribute
|
||||
* values in the `html_attr` function.
|
||||
*
|
||||
* @author Matthias Pigulla <mp@webfactory.de>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extra\Html\HtmlAttr;
|
||||
|
||||
use Twig\Error\RuntimeError;
|
||||
|
||||
/**
|
||||
* @author Matthias Pigulla <mp@webfactory.de>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extra\Html\HtmlAttr;
|
||||
|
||||
/**
|
||||
* Interface for attribute values that support custom merge behavior.
|
||||
*
|
||||
* Implement this interface to define how attribute values should be merged when multiple
|
||||
* attribute arrays are combined using `html_attr_merge`. This allows for
|
||||
* sophisticated merging logic beyond simple array merging.
|
||||
*
|
||||
* Objects implementing this interface probably want to implement either {@see AttributeValueInterface}
|
||||
* or `\Stringable` as well, in order to provide a reasonable to-string-conversion when being
|
||||
* the last value after a merge.
|
||||
*
|
||||
* @author Matthias Pigulla <mp@webfactory.de>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extra\Html\HtmlAttr;
|
||||
|
||||
use Twig\Error\RuntimeError;
|
||||
|
||||
/**
|
||||
* @author Matthias Pigulla <mp@webfactory.de>
|
||||
*/
|
||||
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, static 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, static fn ($v) => true !== $v)));
|
||||
}
|
||||
}
|
||||
@@ -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 @@ final class HtmlExtension extends AbstractExtension
|
||||
{
|
||||
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 @@ final class HtmlExtension extends AbstractExtension
|
||||
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']]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -125,4 +134,129 @@ final class HtmlExtension extends AbstractExtension
|
||||
{
|
||||
return new Cva($base, $variants, $compoundVariants, $defaultVariant);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
public static function htmlAttrType(mixed $value, string $type = 'sst'): AttributeValueInterface
|
||||
{
|
||||
return match ($type) {
|
||||
'sst' => new SeparatedTokenList($value, ' '),
|
||||
'cst' => new SeparatedTokenList($value, ', '),
|
||||
'style' => new InlineStyle($value),
|
||||
default => throw new RuntimeError(\sprintf('Unknown attribute type "%s" The only supported types are "sst", "cst" and "style".', $type)),
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
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;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
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
|
||||
try {
|
||||
$value = json_encode($value, \JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
throw new RuntimeError(\sprintf('The "%s" attribute value cannot be JSON encoded.', $name), previous: $e);
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
|
||||
if (\is_object($value) && !$value instanceof \Stringable) {
|
||||
throw new RuntimeError(\sprintf('The "%s" attribute value should be a scalar, an iterable, or an object implementing "%s", got "%s".', $name, \Stringable::class, get_debug_type($value)));
|
||||
}
|
||||
|
||||
$result .= $runtime->escape($name, 'html_attr_relaxed').'="'.$runtime->escape((string) $value).'" ';
|
||||
}
|
||||
|
||||
return trim($result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
--TEST--
|
||||
"html_attr" function
|
||||
--TEMPLATE--
|
||||
Simple attributes: <tag {{ html_attr({ foo: 'bar' }, { bar: 'baz' }) }}/>
|
||||
Relaxed attribute name escaping: <tag {{ html_attr({ 'v-bind:href': 'url', ':[key]': 'url', '@click': 'doSomething' }) }} />
|
||||
Appropriate escaping: <tag {{ html_attr({ 'untrusted name': '<>&\'"' }) }}/>
|
||||
Empty attribute list: <tag {{ html_attr([], {}, null) }}/>
|
||||
Using a short ternary: <tag {{ html_attr({foo: 'bar'}, false ? {bar: 'baz'}) }}/>
|
||||
boolean true attribute: <tag {{ html_attr({ checked: true}) }}/>
|
||||
boolean false attribute: <tag {{ html_attr({ checked: false}) }}/>
|
||||
null attribute value: <tag {{ html_attr({ something: null}) }}/>
|
||||
empty string attribute value: <tag {{ html_attr({ title: '' }) }}/>
|
||||
ARIA attribute boolean conversion: <tag {{ html_attr({ 'aria-yes': true, 'aria-no': false, 'aria-nada': null, 'aria-empty': '' }) }}/>
|
||||
data-* attribute handling : <tag {{ html_attr({ 'data-array': { foo: 'bar' }, 'data-true': true, 'data-false': false, 'data-null': null }) }}/>
|
||||
array value concatenation ex. 1: <tag {{ html_attr({ class: ['foo', 'bar'] }, { class: [''] }, { class: [] }) }}/>
|
||||
array value concatenation ex. 2: <tag {{ html_attr({ 'aria-describedby': ['id1'] }, { 'aria-describedby': ['id2'] }) }}/>
|
||||
inline styles: <tag {{ html_attr({ style: { color: 'red' }}, { style: ['background-color: blue'] }, { style: { color: 'green', 'font-family': 'Arial' } }) }}/>
|
||||
style with a plain value: <tag {{ html_attr({ style: 'color: red;' }) }}/>
|
||||
using a "comma separated token list" attribute: <img {{ html_attr({ srcset: ['small.jpg 480w', 'medium.jpg 800w', 'large.jpg 1200w']|html_attr_type('cst') }) }} />
|
||||
merging a "comma separated token list" value with more array values: <img {{ html_attr({ srcset: ['small.jpg 480w']|html_attr_type('cst') }, { srcset: ['medium.jpg 800w', 'large.jpg 1200w'] }) }} />
|
||||
--DATA--
|
||||
return []
|
||||
--EXPECT--
|
||||
Simple attributes: <tag foo="bar" bar="baz"/>
|
||||
Relaxed attribute name escaping: <tag v-bind:href="url" :[key]="url" @click="doSomething" />
|
||||
Appropriate escaping: <tag untrusted name="<>&'""/>
|
||||
Empty attribute list: <tag />
|
||||
Using a short ternary: <tag foo="bar"/>
|
||||
boolean true attribute: <tag checked=""/>
|
||||
boolean false attribute: <tag />
|
||||
null attribute value: <tag />
|
||||
empty string attribute value: <tag title=""/>
|
||||
ARIA attribute boolean conversion: <tag aria-yes="true" aria-no="false" aria-empty=""/>
|
||||
data-* attribute handling : <tag data-array="{"foo":"bar"}" data-true="true"/>
|
||||
array value concatenation ex. 1: <tag class="foo bar"/>
|
||||
array value concatenation ex. 2: <tag aria-describedby="id1 id2"/>
|
||||
inline styles: <tag style="color: green; background-color: blue; font-family: Arial;"/>
|
||||
style with a plain value: <tag style="color: red;"/>
|
||||
using a "comma separated token list" attribute: <img srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w" />
|
||||
merging a "comma separated token list" value with more array values: <img srcset="small.jpg 480w, medium.jpg 800w, large.jpg 1200w" />
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extra\Html\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\Error\RuntimeError;
|
||||
use Twig\Extra\Html\HtmlAttr\MergeableInterface;
|
||||
use Twig\Extra\Html\HtmlExtension;
|
||||
|
||||
class HtmlAttrMergeTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider htmlAttrProvider
|
||||
*/
|
||||
public function testMerge(array $expected, array $inputs)
|
||||
{
|
||||
$result = HtmlExtension::htmlAttrMerge(...$inputs);
|
||||
|
||||
self::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public static function htmlAttrProvider(): \Generator
|
||||
{
|
||||
yield 'merging different attributes from two arrays' => [
|
||||
['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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of Twig.
|
||||
*
|
||||
* (c) Fabien Potencier
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Twig\Extra\Html\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\Environment;
|
||||
use Twig\Extra\Html\HtmlAttr\AttributeValueInterface;
|
||||
use Twig\Extra\Html\HtmlAttr\SeparatedTokenList;
|
||||
use Twig\Extra\Html\HtmlExtension;
|
||||
use Twig\Loader\ArrayLoader;
|
||||
|
||||
class HtmlAttrTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @dataProvider htmlAttrProvider
|
||||
*/
|
||||
public function testPrintingAttributes(string $expected, array $inputs)
|
||||
{
|
||||
$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), ...$inputs);
|
||||
|
||||
self::assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
public static function htmlAttrProvider(): \Generator
|
||||
{
|
||||
yield 'merging from variadic arguments; ignoring null, false and empty string values' => [
|
||||
'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' => '<script>alert("xss")</script>'],
|
||||
],
|
||||
];
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user