-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
RFC / WIP - html_attributes function #4405
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
base: 3.x
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||||||
| { | ||||||
| $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'])) { | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we really add such support ? Right now, those |
||||||
| $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)); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is broken if a CSS string contains a |
||||||
| foreach ($styles as $style) { | ||||||
| $style = explode(':', $style); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is broken in case CSS values contains |
||||||
| $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); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| } | ||||||
|
|
||||||
| // 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) . '"'; | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. keys must be escaped as well (and with the |
||||||
| } | ||||||
|
|
||||||
| return implode(' ', $return); | ||||||
| } | ||||||
| } | ||||||
There was a problem hiding this comment.
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.