Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an attr function to make outputting HTML attributes easier #3930

Draft
wants to merge 8 commits into
base: 3.x
Choose a base branch
from

Conversation

mpdude
Copy link
Contributor

@mpdude mpdude commented Dec 6, 2023

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:

  • Get initial feedback
  • Add tests
  • Add documentation
  • Add docblocks

Copy link
Contributor

@fabpot fabpot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it a lot. Let's continue the work. Tell me of you need help @mpdude

extra/html-extra/HtmlExtension.php Outdated Show resolved Hide resolved
extra/html-extra/HtmlExtension.php Outdated Show resolved Hide resolved
extra/html-extra/HtmlExtension.php Outdated Show resolved Hide resolved
extra/html-extra/HtmlExtension.php Outdated Show resolved Hide resolved
extra/html-extra/HtmlExtension.php Outdated Show resolved Hide resolved
extra/html-extra/HtmlExtension.php Outdated Show resolved Hide resolved

$result = '';
foreach ($attr as $name => $value) {
$result .= twig_escape_filter($env, $name, 'html_attr').'="'.htmlspecialchars($value).'" ';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's consider false as a way to disable the attribute? And true would not generate the value part?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that makes sense. I'd have false omit the attribute output altogether. For true, I'd use something like name="name" for backwards compat with (X)HTML.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ "true" must be written "true" for some enumerated aria attributes.

Almost everywhere else, indeed, false should remove/hide the attribute.

"" should not happen, i guess, but should be dealt with attention, because the following have all the same meaning:

  • disabled=""
  • disabled
  • disabled="false"
  • disabled="disabled"

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the aria cases, if you want, there is some classic cases + docs here

https://github.com/symfony/ux/blob/647532ab688f79acfbcb1c895e88a8b2f1a502f6/src/Icons/src/Icon.php#L139-L162

with some test cases if you need too https://github.com/symfony/ux/blob/647532ab688f79acfbcb1c895e88a8b2f1a502f6/src/Icons/tests/Unit/IconTest.php#L226

(other similar in the TwigComponent / ComponentAttributes)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fortunately, Twig and PHP have separate types for strings and booleans. Passing the string true or false will not trigger the special logic for boolean values.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stof I guess what I was trying to say is that yes you could pass 'true' or 'false' as parameters but this would require string coercion when setting the value.

The previous comment stated that:

Almost everywhere else, indeed, false should remove/hide the attribute.

I was pointing out that aria-* attributes do accept a string value of 'false' and in those cases a coercion of bool false to string 'false' might be warranted. This could also apply to data-* attributes.

IMO:

  • null value always removes the attribute from the final string rendering.
  • true and false values for aria-* attributes should coerce boolean to their string values 'true' and 'false'
  • true and false values for all other attributes should act as boolean html attributes. ie true renders the attribute, false removes the attribute (as per @fabpot comment).

@mpdude
Copy link
Contributor Author

mpdude commented Sep 30, 2024

@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.

@fabpot
Copy link
Contributor

fabpot commented Oct 4, 2024

@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.

@@ -124,4 +130,85 @@ public static function htmlCva(array|string $base = [], array $variants = [], ar
{
return new Cva($base, $variants, $compoundVariants, $defaultVariant);
}

public static function htmlAttrMerge(...$arrays): array
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can maybe have a @param to better describe $arrays?

extra/html-extra/HtmlExtension.php Outdated Show resolved Hide resolved
return $result;
}

public static function htmlAttr(Environment $env, ...$args): string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a @param for $args?

extra/html-extra/HtmlExtension.php Show resolved Hide resolved
{
$result = [];

foreach ($arrays as $argNumber => $array) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not guaranteed that we have a number here.


$value = CoreExtension::toArray($value);

$result[$deepMergeKey] = array_merge($result[$deepMergeKey] ?? [], $value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use [... ] when we can ? As it's slightly more performant than array_merge

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smnandre Like this? 717f872

Comment on lines +181 to +185
if (is_numeric($name)) {
$style .= $value.'; ';
} else {
$style .= $name.': '.$value.'; ';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this specific case for style.. i may have missed the reason earlier in the discussion ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case style is something like ['color: red', 'font-weight: bold'].

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have two other questions then (sorry)

What if "aria" is something like ['aria-disabled', 'aria-current'] ?

Does that mean i can do something like this ?

{{ html_attr(
   { style: ['color: red;'] },
   { style: {'color': 'blue'} },
   { style: {'color': 'green'} },
   { style: ['', 'color: black;'] },
) }}

I'm shared between "i understand each of these usages" and "this could be very un-predictable when/if args are assembled from different layers / templates :|

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, definetly many edge cases I haven't thought through. Make sure you don't miss the inital idea in #3907, fwiw.

I think we should aim for behaviour similar to array_merge and not try to do any kind of parsing/interpretation. For example:

  • html_attr_merge( { style: {'color': 'blue'} }, { style: {'color': 'green'} } } ) }} would be { style: {'color': 'green'} }
  • html_attr_merge( { style: ['color: blue'] }, { style: { ['color: green']} } } ) }} would be { style: ['color: blue', 'color: green'] } – if you want "real" overrides, use semantic keys
  • html_attr_merge( { aria: { 'aria-label': 'that' }, {'label': 'this'} } ) would be { 'aria-label': 'this' } – I'd rewrite keys from the aria and data sub-array to the "flat" values first and then merge those.

I hope I can come up with reasonable test cases for all those situations so we can discuss them before merge.

@mpdude
Copy link
Contributor Author

mpdude commented Oct 7, 2024

Added a first load of tests for the html_attr_merge function

@leevigraham
Copy link

leevigraham commented Oct 21, 2024

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.

}

if (!is_iterable($array)) {
throw new RuntimeError(sprintf('The "attr_merge" filter only works with mappings or "Traversable", got "%s" for argument %d.', \gettype($array), $argNumber + 1));
Copy link

@leevigraham leevigraham Oct 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be " The html_attr_merge filter only…" . What if the exception is thrown when using the html_attr function.

@leevigraham
Copy link

leevigraham commented Oct 22, 2024

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
Copy link

@mpdude @fabpot @smnandre I've combined my comments above into a POC here: #4405

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Helper function to make dealing with HTML attributes easier
6 participants