Skip to content

Conversation

@leevigraham
Copy link

@leevigraham leevigraham commented Oct 22, 2024

An alternative implementation to #3930

Addresses:

This WIP pr demonstrates a html attribute merging strategy for html attributes, aria-attributes with special handling for class, style data and aria attributes.

I've currently focussed on the HtmlAttributes::merge function which returns the merged attributes array.

Examples

See ./Tests/HtmlAttributesTest.php for usage examples

// Basic attribute merging
HtmlAttributes::merge(
    ['id' => 'a', 'disabled' => true], 
    ['hidden' => true]
) 
=== 
['id' => 'a', 'disabled' => true, 'hidden' => true]
// Attribute overriding
HtmlAttributes::merge(
    ['id' => 'a'], 
    ['id' => 'b']
) 
===
['id' => 'b']
// class merging
// class attributes produce an array of key / values where the value is true, false, null.
// multiple classnames are split on spaces
HtmlAttributes::merge(
    ['class' => 'a'], 
    ['class' => 'b'], 
    ['class' => 'c'],
    ['class' => 'd e']
) 
===
['class' => ['a' => true, 'b' => true, 'c' => true, 'd' => true, 'e' => true]]
// Style merging
// values provided as string, array of strings or key / value array
// styles are split on ":" and converted to key / value pairs
HtmlAttributes::merge(
    ['style' => 'color: red'], 
    ['style' => ['color: green']], 
    ['style' => ['background-color' => 'blue']],
    ['style' => ['display: block' => false]]
) 
===
['style' => ['color: green;' => 'true', 'background-color: blue;' => 'true', 'display: block;' => false]]
// data expansion:
HtmlAttributes::merge(
    [data' => ['count' => '1']]
) 
===
['data-count' => '1']
// aria expansion:
HtmlAttributes::merge(
    ['aria' => ['hidden' => true]]
) 
===
['aria-hidden' => true]

The return value of HtmlAttributes::merge should also be able to be used as an input for HtmlAttributes::merge as demonstrated here:

https://github.com/twigphp/Twig/pull/4405/files#diff-210291f849102679e6c0a1050d5bcd3076cf55b3cc9677c20fab26dcd15f543cR27-R75

Rendering Attributes

The HtmlAttributes::renderAttributes method that takes the output of HtmlAttributes::merge and creates the attribute string.

This method:

  1. skips null attribute values
  2. filters and implodes class, style and data-controller attribute values
  3. coerces aria-* boolean attribute values to 'true' 'false' strings.
  4. json encodes data-* array values
  5. skips remaining false values (see aria-* coercion above)
  6. returns attribute name for true boolean values
  7. returns attribute name and encoded value for everything else

./Tests/HtmlAttributesTest.php demonstrate the return value of HtmlAttributes::merge and HtmlAttributes::renderAttributes

There was some consideration made to coerce data-* boolean values to string 'true' and 'false' but this was decided against. StimulusJs uses the following conditional to determine if a data-*-value attribute is true or false when internally coercing to a javascript Boolean.

// https://stimulus.hotwired.dev/reference/values#types
!(value == "0" || value == "false")

Illustrated here: https://codepen.io/leevigraham/pen/MWNOyLr

Challenges

The challenge with a html_attributes like function is that the merging strategy is arbitrary.

class and style attributes are usually merged together. Other attributes override the previous values.

Symfony UX twig components recommend Stimulus for interaction. Stimulus uses data-controller attributes for functionality. The data-controller value can be a space delimited list of strings. In this case should the multiple data-controller values be merged or overridden? For StimulusJs also applies to data-target and data-action

Alternative implementation ideas

Merge strategy options

Given the challenges with data-controller above maybe the method should take 2 arguments:

  1. The attributes to be merged
  2. An array which defines the merging strategy
HtmlAttributes::merge(
    attributes: ['style' => ['background-color' => 'blue', 'color' => 'red']],
    options: ['merge' => ['class', 'data-controller']]
)

In the example above the $options argument would be used to determine which values to merge. Other values would replace.

Given the return value of HtmlAttributes::merge can also be used as the $attributes argument of HtmlAttributes::merge the developer could call HtmlAttributes::merge multiple times which would be the equivalent of multiple attribute arrays / argument unpacking.

References in other platforms / frameworks:

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.

TODO

  • add twig filter function
  • cleanup code
  • write docs

@leevigraham leevigraham force-pushed the 3.x branch 2 times, most recently from 35668a6 to bcba2bc Compare October 22, 2024 23:39
@leevigraham leevigraham changed the title RFC / WIP - html_attributes function DRAFT: RFC / WIP - html_attributes function Oct 22, 2024
@leevigraham leevigraham marked this pull request as draft October 22, 2024 23:40
@leevigraham leevigraham changed the title DRAFT: RFC / WIP - html_attributes function RFC / WIP - html_attributes function Oct 22, 2024
@leevigraham leevigraham marked this pull request as ready for review October 22, 2024 23:40
@leevigraham leevigraham marked this pull request as draft October 22, 2024 23:40
@leevigraham leevigraham force-pushed the 3.x branch 2 times, most recently from ab96d70 to 93fadaa Compare October 23, 2024 00:12
@leevigraham leevigraham changed the title RFC / WIP - html_attributes function RFC / WIP - html_attributes function Oct 23, 2024
@leevigraham leevigraham changed the title RFC / WIP - html_attributes function RFC / WIP - html_attributes function Oct 23, 2024
@leevigraham
Copy link
Author

@fabpot
Copy link
Contributor

fabpot commented Oct 26, 2024

I suppose that the implementation of html_classes would use this new function? https://github.com/twigphp/Twig/blob/3.x/extra/html-extra/HtmlExtension.php#L91-L113

@leevigraham
Copy link
Author

leevigraham commented Oct 26, 2024

@fabpot Yep it could do.

I still haven't fully considered the twig methods yet. Given the existing static methods the htmlClasses method could look like:

public static function htmlClasses(...$args): string
{
    $attributes = HtmlAttributes::merge(['class' => $args]);
    return HtmlAttributes::renderAttributes($attributes);
}

I'm also interested in your (and @stof) opinion on merging vs replacing on some attributes. class and style use merging (seems to be standard across other frameworks) but there are use cases for merging data-controller, data-action, some aria-* properties.

I thought using the CVA pattern here might work. ie… add a HtmlAttributes::__construct() method which takes a config argument (or null), and add a apply function which takes the object or attributes and returns the attribute string.

image

if (is_int($k)) {
$styles = array_filter(explode(';', $v));
foreach ($styles as $style) {
$style = explode(':', $style);
Copy link
Member

Choose a reason for hiding this comment

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

this is broken in case CSS values contains : (which can happen, for instance in the if function or in strings)

$value = (array)$value;
foreach ($value as $k => $v) {
if (is_int($k)) {
$styles = array_filter(explode(';', $v));
Copy link
Member

Choose a reason for hiding this comment

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

this is broken if a CSS string contains a ;. You cannot perform CSS parsing based on naive explode calls. The CSS syntax is not meant to allow that.

// data and aria arrays are expanded into data-* and aria-* attributes
$expanded = [];
foreach ($attributes as $key => $value) {
if (in_array($key, ['data', 'aria'])) {
Copy link
Member

Choose a reason for hiding this comment

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

should we really add such support ? Right now, those data and aria keys will be merged with the explicit aria-* and data-* keys, which can be a confusing behavior. I'm not sure this special case is worth it.

// Handle class, style, data-controller value coercion
// array[] -> concatenate string
if (in_array($key, ['class', 'style', 'data-controller'])) {
$value = array_filter($value);
Copy link
Member

Choose a reason for hiding this comment

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

what if the value is not an array there ?

} elseif ($value === false) {
$value = 'false';
} elseif(is_array($value)) {
$value = join(" ", array_filter($value));
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
$value = join(" ", array_filter($value));
$value = implode(' ', array_filter($value));

}

// Everything else gets added as an encoded value
$return[] = $key . '="' . htmlspecialchars($value) . '"';
Copy link
Member

Choose a reason for hiding this comment

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

keys must be escaped as well (and with the html_attr strategy)

* @see ./Tests/HtmlAttributesTest.php for usage examples
*
*/
public static function merge(...$attributeGroup): array
Copy link
Member

Choose a reason for hiding this comment

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

this signature does not correspond to the PR description.

@stof
Copy link
Member

stof commented Nov 18, 2025

I still haven't fully considered the twig methods yet. Given the existing static methods the htmlClasses method could look like:

no it cannot. html_classes returns only the value of the class attribute. It does not render the attribute name (which is totally different both in term of usage and of auto-escaping)

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.

3 participants