diff --git a/CHANGELOG b/CHANGELOG index 0eb1aacba88..ba2bd9dea05 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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) diff --git a/extra/html-extra/HtmlAttributes.php b/extra/html-extra/HtmlAttributes.php new file mode 100644 index 00000000000..34d476d346b --- /dev/null +++ b/extra/html-extra/HtmlAttributes.php @@ -0,0 +1,136 @@ +Click + * + * {# On a Twig Component (spread as key-value pairs) #} + * Click + * + * @implements \IteratorAggregate + */ +final class HtmlAttributes implements \Stringable, \IteratorAggregate, \Countable +{ + /** + * @param array $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 + */ + 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 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; + } +} diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index 966b43327d2..54342a6628e 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -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), + ); } } diff --git a/extra/html-extra/Tests/HtmlAttrTest.php b/extra/html-extra/Tests/HtmlAttrTest.php index 41f704cbadf..bbd1d394f52 100644 --- a/extra/html-extra/Tests/HtmlAttrTest.php +++ b/extra/html-extra/Tests/HtmlAttrTest.php @@ -268,7 +268,7 @@ public function getIterator(): \Traversable $result = HtmlExtension::htmlAttr(new Environment(new ArrayLoader()), $object); - self::assertSame('data-controller="dropdown tooltip" data-action="click->dropdown#toggle mouseover->tooltip#show"', $result); + self::assertSame('data-controller="dropdown tooltip" data-action="click->dropdown#toggle mouseover->tooltip#show"', (string) $result); } public function testDataAttributeWithNonJsonEncodableValueThrowsRuntimeError() @@ -276,7 +276,7 @@ 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 ); @@ -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