Twig icon indicating copy to clipboard operation
Twig copied to clipboard

RFC / WIP - html_attributes function

Open leevigraham opened this issue 1 year ago • 4 comments

An alternative implementation to #3930

Addresses:

  • https://github.com/twigphp/Twig/issues/3907
  • https://github.com/twigphp/html-extra/pull/4
  • https://github.com/twigphp/Twig/pull/3760

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 avatar Oct 22 '24 23:10 leevigraham

  • Attribute rendering added to the class and tests: https://github.com/twigphp/Twig/pull/4405/files#diff-210291f849102679e6c0a1050d5bcd3076cf55b3cc9677c20fab26dcd15f543cR100
  • Changed style merging so that the array keys contain the case attribute and value
  • Updated the original PR description.

leevigraham avatar Oct 25 '24 23:10 leevigraham

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

fabpot avatar Oct 26 '24 06:10 fabpot

@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

leevigraham avatar Oct 26 '24 19:10 leevigraham

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)

stof avatar Nov 18 '25 10:11 stof