Twig icon indicating copy to clipboard operation
Twig copied to clipboard

Add an `attr` function to make outputting HTML attributes easier

Open mpdude opened this issue 2 years ago • 6 comments

Closes #3907.

First, it adds a attr_merge filter. This filter is intended to be used with arrays that represent HTML attribute name-value pairs. Basically, it works like |merge, but for the special key names class, style and data it performs merging on the value level.

This is intended for the use case where you'd like to add to the attributes for an HTML element based on conditions, e. g. multiple subsequent {% if ... %} blocks.

Example:

  • { class: 'foo' }|attr_merge({ class: 'bar' }) will be { class: ['foo', 'bar'] }
  • { class: 'foo' }|attr_merge({ class: ['bar', 'baz'] }) will be { class: ['foo', 'bar', 'baz'] }
  • { class: { special: 'foo' } }|attr_merge({ class: ['bar', 'baz'] })|attr_merge({ class: {special: 'qux' } }) will be { class: { special: 'qux', 0: 'bar', 1: 'baz' } }

or in Twig code:

{% set attr = attr ?? {} %}
{% if condition1 %}
  {% set attr = attr|attr_merge({ class: 'foo', data: { condition1: 'met' }) %}
{% endif %}
{% if condition2 %}
  {% set attr = attr|attr_merge({ class: 'bar', data: { condition2: 'met' }) %}
{% endif %}

Second, it adds a attr function. This function takes one or multiple attr arrays like above, and print them as a series of HTML attribute markup. All values from class will be concatenated with spaces. Key/value pairs from style will be treated as CSS property/value pairs. For data, keys will be used to construct data-{keyname} attributes.

Example:


    {% set id = 'id value' %}
    {% set href = 'href value' %}
    {% set disabled = true %}

    <div {{ attr(
        { id, href },
        disabled ? { 'aria-disabled': 'true' },
        not disabled ? { 'aria-enabled' : true },
        { class: ['zero', 'first'] },
        { class: 'second' },
        true ? { class: 'third' },
        { style: { color: 'red' } },
        { style: { 'background-color': 'green' } },
        { style: { color: 'blue' } },
        { 'data-test': 'some value' },
        { data: { test: 'other value', bar: 'baz' }},
        { 'dangerous=yes foo' : 'xss' },
        { style: 'font-weight: bold' },
        { style: ['text-decoration: underline'] },
    ) }}></div>

will generate HTML markup:

<div id="id value" href="href%20value" aria-disabled="true" data-test="other value" data-bar="baz" dangerous&#x3D;yes&#x20;foo="xss" class="zero first second third" style="color: red; background-color: green; color: blue; font-weight: bold; text-decoration: underline;"></div>

TODO:

  • [x] Get initial feedback
  • [ ] Add tests
  • [ ] Add documentation
  • [ ] Add docblocks

mpdude avatar Dec 06 '23 22:12 mpdude

@fabpot Regarding tests: I suppose the fixture-based tests (*.test) are necessary to do integration testing, e. g. including the escaping mechanism? Should I go for such tests or write plain PHPUnit tests instead?

What's the best way to cover lots of scenarios in the fixture-style tests? It's easy to get lost when there are lots of cases but just one --TEMPLATE-- and one --EXPECT-- section.

mpdude avatar Sep 30 '24 21:09 mpdude

@fabpot Regarding tests: I suppose the fixture-based tests (*.test) are necessary to do integration testing, e. g. including the escaping mechanism? Should I go for such tests or write plain PHPUnit tests instead?

You can write both, but .test tests are easier to write and read.

What's the best way to cover lots of scenarios in the fixture-style tests? It's easy to get lost when there are lots of cases but just one --TEMPLATE-- and one --EXPECT-- section.

That's indeed a current limitation.

fabpot avatar Oct 04 '24 06:10 fabpot

Added a first load of tests for the html_attr_merge function

mpdude avatar Oct 07 '24 17:10 mpdude

For reference

Yii2 has a similar function: https://github.com/yiisoft/yii2/blob/master/framework/helpers/BaseHtml.php#L1966-L2046

The renderTagAttributes method has the following rules:

  • Attributes whose values are of boolean type will be treated as boolean attributes.
  • Attributes whose values are null will not be rendered.
  • aria and data attributes get special handling when they are set to an array value. In these cases, the array will be "expanded" and a list of ARIA/data attributes will be rendered. For example, 'aria' => ['role' => 'checkbox', 'value' => 'true'] would be rendered as aria-role="checkbox" aria-value="true".
  • If a nested data value is set to an array, it will be JSON-encoded. For example, 'data' => ['params' => ['id' => 1, 'name' => 'yii']] would be rendered as data-params='{"id":1,"name":"yii"}'.

CraftCMS uses twig and provides an attr() twig method that implements renderTagAttributes. I've used this helper many times and the rules above are great. Especially the "Attributes whose values are null will not be rendered".

Vuejs v2 -> v3 also went through some changes for false values https://v3-migration.vuejs.org/breaking-changes/attribute-coercion.html. This aligns with "Attributes whose values are null will not be rendered." above.

leevigraham avatar Oct 21 '24 23:10 leevigraham

How will merging of attributes that support multiple values work? Eg aria-labelledby value is an ID reference list.

Example:

<div {{ attr(
        {'aria-labelledby': 'id1 id2'},
        {'aria-labelledby': 'id3'},
    ) }}></div>

Should the result be:

  • First value set: {'aria-labelledby': 'id1 id2'}
  • Last value set: {'aria-labelledby': 'id3'}
  • Concatenated (like class): {'aria-labelledby': 'id1 id2 id3'}

This could also apply to data-controller which is used by stimulusjs and Symfony ux components.

leevigraham avatar Oct 22 '24 08:10 leevigraham

@mpdude @fabpot @smnandre I've combined my comments above into a POC here: https://github.com/twigphp/Twig/pull/4405

leevigraham avatar Oct 23 '24 00:10 leevigraham