Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions extra/html-extra/HtmlAttributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<?php

namespace Twig\Extra\Html;

use Twig\Error\RuntimeError;

final class HtmlAttributes
{
/**
* Merges multiple attribute group arrays into a single array.
*
* `HtmlAttributes::merge(['id' => 'a', 'disabled' => true], ['hidden' => true])` becomes
* `['id' => 'a', 'disabled' => true, 'hidden' => true]`
*
* attributes override each other in the order they are provided.
*
* `HtmlAttributes::merge(['id' => 'a'], ['id' => 'b'])` becomes `['id' => 'b']`.
*
* However, `class` and `style` attributes are merged into an array so they can be concatenated in later processing.
*
* `HtmlAttributes::merge(['class' => 'a'], ['class' => 'b'], ['class' => 'c'])` becomes
* `['class' => ['a' => true, 'b' => true, 'c' => true]]`.
*
* style attributes are also merged into an array so they can be concatenated in later processing.
*
* `HtmlAttributes::merge(['style' => 'color: red'], ['style' => ['background-color' => 'blue']])` becomes
* `['style' => ['color: red;' => true, 'background-color: blue;' => true]]`.
*
* style attributes which are arrays with false and null values are also processed
*
* `HtmlAttributes::merge(['style' => ['color: red' => true]], ['style' => ['display: block' => false]]) becomes
* `['style' => ['color: red;' => true, 'display: block;' => false]]`.
*
* attributes can be provided as an array of key, value where the value can be true, false or null.
*
* Example:
* `HtmlAttributes::merge(['class' => ['a' => true, 'b' => false], ['class' => ['c' => null']])` becomes
* `['class' => ['a' => true, 'b' => false, 'c' => null]]`.
*
* `aria` and `data` arrays are expanded into `aria-*` and `data-*` attributes before further processing.
*
* Example:
*
* `HtmlAttributes::merge([data' => ['count' => '1']])` becomes `['data-count' => '1']`.
* `HtmlAttributes::merge(['aria' => ['hidden' => true]])` becomes `['aria-hidden' => true]`.
*
* @param ...$attributeGroup
* @return array
* @throws RuntimeError
* @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.

{
$result = [];

$attributeGroupCount = 0;

foreach ($attributeGroup as $attributes) {

$attributeGroupCount++;

// Skip empty attributes
// Return early if no attributes are provided
// This could be false or null when using the twig ternary operator
if (!$attributes) {
continue;
}

if (!is_iterable($attributes)) {
throw new RuntimeError(sprintf('"%s" only works with mappings or "Traversable", got "%s" for argument %d.', self::class, \gettype($attributes), $attributeGroupCount));
}

// Alternative to is_iterable check above, cast the attributes to an array
// This would produce weird results but would not throw an error
// $attributes = (array)$attributes;

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

$value = (array)$value;
foreach ($value as $k => $v) {
$k = $key . '-' . $k;
$expanded[$k] = $v;
}
continue;
}
$expanded[$key] = $value;
}

// Reset the attributes array to the flattened version
$attributes = $expanded;

foreach ($attributes as $key => $value) {

// Treat class and data-controller attributes as arrays
if (in_array($key, [
'class',
'data-controller',
'data-action',
'data-targets',
])) {
if (!array_key_exists($key, $result)) {
$result[$key] = [];
}
$value = (array)$value;
foreach ($value as $k => $v) {
if (is_int($k)) {
$classes = explode(' ', $v);
foreach ($classes as $class) {
$result[$key][$class] = true;
}
} else {
$classes = explode(' ', $k);
foreach ($classes as $class) {
$result[$key][$class] = $v;
}
}
}
continue;
}

if ($key === 'style') {
if (!array_key_exists('style', $result)) {
$result['style'] = [];
}
$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.

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)

$sKey = trim($style[0]);
$sValue = trim($style[1]);
$result['style']["$sKey: $sValue;"] = true;
}
} elseif (is_bool($v) || is_null($v)) {
$styles = array_filter(explode(';', $k));
foreach ($styles as $style) {
$style = explode(':', $style);
$sKey = trim($style[0]);
$sValue = trim($style[1]);
$result['style']["$sKey: $sValue;"] = $v;
}
} else {
$sKey = trim($k);
$sValue = trim($v);
$result['style']["$sKey: $sValue;"] = true;
}
}
continue;
}

$result[$key] = $value;
}
}

return $result;
}

public static function renderAttributes($attributes): string
{
$return = [];

foreach ($attributes as $key => $value) {

// Skip null values regardless of attribute key
if ($value === null) {
continue;
}

// 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 ?

$value = array_keys($value);
$value = implode(' ', $value);
}

// Handle aria-* value coercion
// true -> 'true'
// false -> 'false,
// array[] -> concatenate string
if (str_starts_with($key, 'aria-')) {
if ($value === true) {
$value = 'true';
} 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));

}

}

// Handle data-* value coercion
// array[] -> json
if (str_starts_with($key, 'data-')) {
if(is_array($value)) {
$value = json_encode($value);
}
}

// Skip false values
if ($value === false) {
continue;
}

// Boolean attribute doesn't have a value
if ($value === true) {
$return[] = $key;
continue;
}

// 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)

}

return implode(' ', $return);
}
}
Loading
Loading