Skip to content
Closed
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
4 changes: 2 additions & 2 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 3.24.1 (2026-XX-XX)
# 3.25.0 (2026-XX-XX)

* n/a
* Make `html_attr()` return an `HtmlAttributes` object that is both `Stringable` and `IteratorAggregate`, allowing spreading onto Twig Components via `{{ ...html_attr(merged) }}`

# 3.24.0 (2026-03-17)

Expand Down
136 changes: 136 additions & 0 deletions extra/html-extra/HtmlAttributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Twig\Extra\Html;

use Twig\Error\RuntimeError;
use Twig\Extra\Html\HtmlAttr\AttributeValueInterface;
use Twig\Extra\Html\HtmlAttr\InlineStyle;
use Twig\Extra\Html\HtmlAttr\SeparatedTokenList;
use Twig\Runtime\EscaperRuntime;

/**
* Represents a set of HTML attributes that can be rendered as a string
* or iterated over for spreading onto Twig Components.
*
* Usage:
* {# On a native HTML element (renders as string) #}
* <button {{ html_attr(merged) }}>Click</button>
*
* {# On a Twig Component (spread as key-value pairs) #}
* <twig:Button {{ ...html_attr(merged) }}>Click</twig:Button>
*
* @implements \IteratorAggregate<string, string>
*/
final class HtmlAttributes implements \Stringable, \IteratorAggregate, \Countable
{
/**
* @param array<string, mixed> $attributes The raw merged attributes
*/
public function __construct(
private readonly array $attributes,
private readonly EscaperRuntime $escaper,
) {
}

public function __toString(): string
{
$result = '';

foreach ($this->resolveAttributes() as $name => $value) {
$result .= $this->escaper->escape($name, 'html_attr_relaxed').'="'.$this->escaper->escape($value).'" ';
}

return trim($result);
}

/**
* @return \Traversable<string, string>
*/
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->resolveAttributes());
}

public function count(): int
{
return \count($this->resolveAttributes());
}

/**
* Resolves the raw attributes into their final scalar values.
*
* This applies the same transformation logic as the original htmlAttr():
* - aria-*: booleans converted to "true"/"false" strings
* - data-*: non-scalar values JSON-encoded, true converted to "true"
* - Iterables converted to SeparatedTokenList or InlineStyle
* - AttributeValueInterface resolved via getValue()
* - true becomes empty string
* - null/false causes the attribute to be omitted
*
* @return array<string, string> The resolved attributes with scalar string values
*/
private function resolveAttributes(): array
{
$resolved = [];

foreach ($this->attributes as $name => $value) {
if (str_starts_with($name, 'aria-')) {
if (true === $value) {
$value = 'true';
} elseif (false === $value) {
$value = 'false';
}
}

if (str_starts_with($name, 'data-')) {
if (!$value instanceof AttributeValueInterface && null !== $value && !\is_scalar($value)) {
try {
$value = json_encode($value, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new RuntimeError(\sprintf('The "%s" attribute value cannot be JSON encoded.', $name), previous: $e);
}
} elseif (true === $value) {
$value = 'true';
}
}

if (!$value instanceof AttributeValueInterface && is_iterable($value)) {
if ('style' === $name) {
$value = new InlineStyle($value);
} else {
$value = new SeparatedTokenList($value);
}
}

if ($value instanceof AttributeValueInterface) {
$value = $value->getValue();
}

if (null === $value || false === $value) {
continue;
}

if (true === $value) {
$resolved[$name] = '';
continue;
}

if (\is_object($value) && !$value instanceof \Stringable) {
throw new RuntimeError(\sprintf('The "%s" attribute value should be a scalar, an iterable, or an object implementing "%s", got "%s".', $name, \Stringable::class, get_debug_type($value)));
}

$resolved[$name] = (string) $value;
}

return $resolved;
}
}
70 changes: 5 additions & 65 deletions extra/html-extra/HtmlExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -192,71 +192,11 @@ public static function htmlAttrMerge(iterable|string|false|null ...$arrays): arr
}

/** @internal */
public static function htmlAttr(Environment $env, iterable|string|false|null ...$args): string
public static function htmlAttr(Environment $env, iterable|string|false|null ...$args): HtmlAttributes
{
$attr = self::htmlAttrMerge(...$args);

$result = '';
$runtime = $env->getRuntime(EscaperRuntime::class);

foreach ($attr as $name => $value) {
if (str_starts_with($name, 'aria-')) {
// For aria-*, convert booleans to "true" and "false" strings
if (true === $value) {
$value = 'true';
} elseif (false === $value) {
$value = 'false';
}
}

if (str_starts_with($name, 'data-')) {
if (!$value instanceof AttributeValueInterface && null !== $value && !\is_scalar($value)) {
// ... encode non-null non-scalars as JSON
try {
$value = json_encode($value, \JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new RuntimeError(\sprintf('The "%s" attribute value cannot be JSON encoded.', $name), previous: $e);
}
} elseif (true === $value) {
// ... and convert boolean true to a 'true' string.
$value = 'true';
}
}

// Convert iterable values to token lists
if (!$value instanceof AttributeValueInterface && is_iterable($value)) {
if ('style' === $name) {
$value = new InlineStyle($value);
} else {
$value = new SeparatedTokenList($value);
}
}

if ($value instanceof AttributeValueInterface) {
$value = $value->getValue();
}

// In general, ...
if (true === $value) {
// ... use attribute="" for boolean true,
// which is XHTML compliant and indicates the "empty value default", see
// https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 and
// https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attributes
$value = '';
}

if (null === $value || false === $value) {
// omit null-valued and false attributes completely (note aria-* has been processed before)
continue;
}

if (\is_object($value) && !$value instanceof \Stringable) {
throw new RuntimeError(\sprintf('The "%s" attribute value should be a scalar, an iterable, or an object implementing "%s", got "%s".', $name, \Stringable::class, get_debug_type($value)));
}

$result .= $runtime->escape($name, 'html_attr_relaxed').'="'.$runtime->escape((string) $value).'" ';
}

return trim($result);
return new HtmlAttributes(
self::htmlAttrMerge(...$args),
$env->getRuntime(EscaperRuntime::class),
);
}
}
80 changes: 77 additions & 3 deletions extra/html-extra/Tests/HtmlAttrTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,15 @@ public function getIterator(): \Traversable

$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), $object);

self::assertSame('data-controller="dropdown tooltip" data-action="click-&gt;dropdown#toggle mouseover-&gt;tooltip#show"', $result);
self::assertSame('data-controller="dropdown tooltip" data-action="click-&gt;dropdown#toggle mouseover-&gt;tooltip#show"', (string) $result);
}

public function testDataAttributeWithNonJsonEncodableValueThrowsRuntimeError()
{
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('The "data-bad" attribute value cannot be JSON encoded.');

HtmlExtension::htmlAttr(
(string) HtmlExtension::htmlAttr(
new Environment(new ArrayLoader()),
['data-bad' => [\INF]] // INF cannot be JSON-encoded
);
Expand All @@ -287,11 +287,85 @@ public function testNonStringableObjectAsAttributeValueThrowsRuntimeError()
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('The "title" attribute value should be a scalar, an iterable, or an object implementing "Stringable"');

HtmlExtension::htmlAttr(
(string) HtmlExtension::htmlAttr(
new Environment(new ArrayLoader()),
['title' => new \stdClass()]
);
}

public function testHtmlAttributesIsStringable()
{
$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), ['class' => 'btn', 'id' => 'my-btn']);

self::assertSame('class="btn" id="my-btn"', (string) $result);
}

public function testHtmlAttributesIsIterable()
{
$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), ['class' => 'btn', 'id' => 'my-btn']);

self::assertSame(['class' => 'btn', 'id' => 'my-btn'], iterator_to_array($result));
}

public function testHtmlAttributesIsCountable()
{
$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), ['class' => 'btn', 'id' => 'my-btn']);

self::assertCount(2, $result);
}

public function testHtmlAttributesIterationReturnsScalarValues()
{
$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), [
'class' => ['btn', 'btn-primary'],
'data-action' => new SeparatedTokenList(['click->dialog#open', 'mouseover->tooltip#show']),
'style' => ['color' => 'red', 'font-size' => '16px'],
'required' => true,
'disabled' => false,
'aria-hidden' => true,
]);

$attrs = iterator_to_array($result);

self::assertSame('btn btn-primary', $attrs['class']);
self::assertSame('click->dialog#open mouseover->tooltip#show', $attrs['data-action']);
self::assertSame('color: red; font-size: 16px;', $attrs['style']);
self::assertSame('', $attrs['required']);
self::assertArrayNotHasKey('disabled', $attrs);
self::assertSame('true', $attrs['aria-hidden']);
}

public function testHtmlAttributesSpreadAfterMerge()
{
$alertTrigger = [
'data-action' => new SeparatedTokenList('click->alert-dialog#open'),
'data-alert-dialog-target' => 'trigger',
];

$tooltipTrigger = [
'data-action' => new SeparatedTokenList('mouseenter->tooltip#show mouseleave->tooltip#hide'),
'data-tooltip-target' => 'trigger',
];

$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), $alertTrigger, $tooltipTrigger);

$attrs = iterator_to_array($result);

self::assertSame('click->alert-dialog#open mouseenter->tooltip#show mouseleave->tooltip#hide', $attrs['data-action']);
self::assertSame('trigger', $attrs['data-alert-dialog-target']);
self::assertSame('trigger', $attrs['data-tooltip-target']);
}

public function testHtmlAttributesCountWithOmittedAttributes()
{
$result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), [
'class' => 'btn',
'disabled' => false,
'title' => null,
]);

self::assertCount(1, $result);
}
}

class StringableStub implements \Stringable
Expand Down
Loading