diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md
index f9fd2eeeb65..02b46078539 100644
--- a/src/TwigComponent/CHANGELOG.md
+++ b/src/TwigComponent/CHANGELOG.md
@@ -1,5 +1,9 @@
# CHANGELOG
+## 2.17.0
+
+- Add nested attribute support #1405
+
## 2.16.0
- Introduce CVA to style TwigComponent #1416
diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst
index e58dc4970cc..712b173090d 100644
--- a/src/TwigComponent/doc/index.rst
+++ b/src/TwigComponent/doc/index.rst
@@ -1058,6 +1058,63 @@ Exclude specific attributes:
My Component!
+Nested Attributes
+~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 2.17
+
+ The Nested Attributes feature was added in TwigComponents 2.17.
+
+You can have attributes that aren't meant to be used on the *root* element
+but one of its *descendants*. This is useful for, say, a dialog component where
+you want to allow customizing the attributes of the dialog's content, title,
+and footer. Here's an example of this:
+
+.. code-block:: html+twig
+
+ {# templates/components/Dialog.html.twig #}
+
+
+ {% block title %}Default Title{% endblock %}
+
+
+ {% block content %}{% endblock %}
+
+
+ {% block footer %}Default Footer{% endblock %}
+
+
+
+ {# render #}
+
+ Some content
+
+
+ {# output #}
+
+
+ Default Title
+
+
+ Some content
+
+
+ Default Footer
+
+
+
+The nesting is recursive so you could potentially do something like this:
+
+.. code-block:: html+twig
+
+
+
Component with Complex Variants (CVA)
-------------------------------------
diff --git a/src/TwigComponent/src/ComponentAttributes.php b/src/TwigComponent/src/ComponentAttributes.php
index 08d19fd56cd..ea351bd38a7 100644
--- a/src/TwigComponent/src/ComponentAttributes.php
+++ b/src/TwigComponent/src/ComponentAttributes.php
@@ -19,8 +19,10 @@
*
* @immutable
*/
-final class ComponentAttributes implements \IteratorAggregate, \Countable
+final class ComponentAttributes implements \Stringable, \IteratorAggregate, \Countable
{
+ private const NESTED_REGEX = '#^([\w-]+):(.+)$#';
+
/** @var array */
private array $rendered = [];
@@ -39,6 +41,10 @@ public function __toString(): string
fn (string $key) => !isset($this->rendered[$key])
),
function (string $carry, string $key) {
+ if (preg_match(self::NESTED_REGEX, $key)) {
+ return $carry;
+ }
+
$value = $this->attributes[$key];
if ($value instanceof \Stringable) {
@@ -196,6 +202,19 @@ public function remove($key): self
return new self($attributes);
}
+ public function nested(string $namespace): self
+ {
+ $attributes = [];
+
+ foreach ($this->attributes as $key => $value) {
+ if (preg_match(self::NESTED_REGEX, $key, $matches) && $namespace === $matches[1]) {
+ $attributes[$matches[2]] = $value;
+ }
+ }
+
+ return new self($attributes);
+ }
+
public function getIterator(): \Traversable
{
return new \ArrayIterator($this->attributes);
diff --git a/src/TwigComponent/tests/Fixtures/templates/components/JustAttributes.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/JustAttributes.html.twig
new file mode 100644
index 00000000000..b245f294419
--- /dev/null
+++ b/src/TwigComponent/tests/Fixtures/templates/components/JustAttributes.html.twig
@@ -0,0 +1 @@
+
diff --git a/src/TwigComponent/tests/Fixtures/templates/components/NestedAttributes.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/NestedAttributes.html.twig
new file mode 100644
index 00000000000..6397b9fa6d6
--- /dev/null
+++ b/src/TwigComponent/tests/Fixtures/templates/components/NestedAttributes.html.twig
@@ -0,0 +1,7 @@
+
+
+
+ {{ component('JustAttributes', [...attributes.nested('inner')]) }}
+
+
+
diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php
index a6933710e85..74e424882a4 100644
--- a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php
+++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php
@@ -263,6 +263,84 @@ public function testComponentWithClassMerge(): void
$this->assertStringContainsString('class="alert alert-red alert-lg font-semibold rounded-md dark:bg-gray-600 flex p-4"', $output);
}
+ public function testRenderingComponentWithNestedAttributes(): void
+ {
+ $output = $this->renderComponent('NestedAttributes');
+
+ $this->assertSame(<<
+
+
+ HTML,
+ trim($output)
+ );
+
+ $output = $this->renderComponent('NestedAttributes', [
+ 'class' => 'foo',
+ 'title:class' => 'bar',
+ 'title:span:class' => 'baz',
+ ]);
+
+ $this->assertSame(<<
+
+
+ HTML,
+ trim($output)
+ );
+ }
+
+ public function testRenderingHtmlSyntaxComponentWithNestedAttributes(): void
+ {
+ $output = self::getContainer()
+ ->get(Environment::class)
+ ->createTemplate('')
+ ->render()
+ ;
+
+ $this->assertSame(<<
+
+
+ HTML,
+ trim($output)
+ );
+
+ $output = self::getContainer()
+ ->get(Environment::class)
+ ->createTemplate('')
+ ->render()
+ ;
+
+ $this->assertSame(<<
+
+
+ HTML,
+ trim($output)
+ );
+ }
+
private function renderComponent(string $name, array $data = []): string
{
return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [
diff --git a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php
index 22e587566c6..d983de8172b 100644
--- a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php
+++ b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php
@@ -244,4 +244,18 @@ public function testCanCheckIfAttributeExists(): void
$this->assertTrue($attributes->has('foo'));
}
+
+ public function testNestedAttributes(): void
+ {
+ $attributes = new ComponentAttributes([
+ 'class' => 'foo',
+ 'title:class' => 'bar',
+ 'title:span:class' => 'baz',
+ ]);
+
+ $this->assertSame(' class="foo"', (string) $attributes);
+ $this->assertSame(' class="bar"', (string) $attributes->nested('title'));
+ $this->assertSame(' class="baz"', (string) $attributes->nested('title')->nested('span'));
+ $this->assertSame('', (string) $attributes->nested('invalid'));
+ }
}