Skip to content

Commit 8ee104a

Browse files
committed
feat(twig): Allow nested attributes
1 parent c9dbea1 commit 8ee104a

File tree

7 files changed

+181
-1
lines changed

7 files changed

+181
-1
lines changed

src/TwigComponent/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## 2.17.0
4+
5+
- Added nested attribute support.
6+
37
## 2.16.0
48

59
- Introduce CVA to style TwigComponent #1416

src/TwigComponent/doc/index.rst

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,63 @@ Exclude specific attributes:
10581058
My Component!
10591059
</div>
10601060

1061+
Nested Attributes
1062+
~~~~~~~~~~~~~~~~~
1063+
1064+
.. versionadded:: 2.17
1065+
1066+
The Nested Attributes feature was added in TwigComponents 2.17.
1067+
1068+
You can have attributes that aren't meant to be used on the *root* element
1069+
but one of its *descendants*. This is useful for, say, a dialog component where
1070+
you want to allow customizing the attributes of the dialog's content, title,
1071+
and footer. Here's an example of this:
1072+
1073+
.. code-block:: html+twig
1074+
1075+
{# templates/components/Dialog.html.twig #}
1076+
<div{{ attributes }}>
1077+
<div{{ attributes.nested('title') }}>
1078+
{% block title %}Default Title{% endblock %}
1079+
</div>
1080+
<div{{ attributes.nested('body') }}>
1081+
{% block content %}{% endblock %}
1082+
</div>
1083+
<div{{ attributes.nested('footer') }}>
1084+
{% block footer %}Default Footer{% endblock %}
1085+
</div>
1086+
</div>
1087+
1088+
{# render #}
1089+
<twig:Dialog class="foo" title:class="bar" body:class="baz" footer:class="qux">
1090+
Some content
1091+
</twig:MyDialog>
1092+
1093+
{# output #}
1094+
<div class="foo">
1095+
<div class="bar">
1096+
Default Title
1097+
</div>
1098+
<div class="baz">
1099+
Some content
1100+
</div>
1101+
<div class="qux">
1102+
Default Footer
1103+
</div>
1104+
</div>
1105+
1106+
The nesting is recursive so you could potentially do something like this:
1107+
1108+
.. code-block:: html+twig
1109+
1110+
<twig:Form
1111+
:form="form"
1112+
class="ui-form"
1113+
row:class="ui-form-row"
1114+
row:label:class="ui-form-label"
1115+
row:widget:class="ui-form-widget"
1116+
/>
1117+
10611118
Component with Complex Variants (CVA)
10621119
-------------------------------------
10631120

src/TwigComponent/src/ComponentAttributes.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
*
2020
* @immutable
2121
*/
22-
final class ComponentAttributes implements \IteratorAggregate, \Countable
22+
final class ComponentAttributes implements \Stringable, \IteratorAggregate, \Countable
2323
{
24+
private const NESTED_REGEX = '#^([\w-]+):(.+)$#';
25+
2426
/** @var array<string,true> */
2527
private array $rendered = [];
2628

@@ -39,6 +41,10 @@ public function __toString(): string
3941
fn (string $key) => !isset($this->rendered[$key])
4042
),
4143
function (string $carry, string $key) {
44+
if (preg_match(self::NESTED_REGEX, $key)) {
45+
return $carry;
46+
}
47+
4248
$value = $this->attributes[$key];
4349

4450
if ($value instanceof \Stringable) {
@@ -196,6 +202,19 @@ public function remove($key): self
196202
return new self($attributes);
197203
}
198204

205+
public function nested(string $namespace): self
206+
{
207+
$attributes = [];
208+
209+
foreach ($this->attributes as $key => $value) {
210+
if (preg_match(self::NESTED_REGEX, $key, $matches) && $namespace === $matches[1]) {
211+
$attributes[$matches[2]] = $value;
212+
}
213+
}
214+
215+
return new self($attributes);
216+
}
217+
199218
public function getIterator(): \Traversable
200219
{
201220
return new \ArrayIterator($this->attributes);
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<div{{ attributes }}/>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<main{{ attributes }}>
2+
<div{{ attributes.nested('title') }}>
3+
<span{{ attributes.nested('title').nested('span') }}>
4+
{{ component('JustAttributes', [...attributes.nested('inner')]) }}
5+
</span>
6+
</div>
7+
</main>

src/TwigComponent/tests/Integration/ComponentExtensionTest.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,84 @@ public function testComponentWithClassMerge(): void
263263
$this->assertStringContainsString('class="alert alert-red alert-lg font-semibold rounded-md dark:bg-gray-600 flex p-4"', $output);
264264
}
265265

266+
public function testRenderingComponentWithNestedAttributes(): void
267+
{
268+
$output = $this->renderComponent('NestedAttributes');
269+
270+
$this->assertSame(<<<HTML
271+
<main>
272+
<div>
273+
<span>
274+
<div/>
275+
276+
</span>
277+
</div>
278+
</main>
279+
HTML,
280+
trim($output)
281+
);
282+
283+
$output = $this->renderComponent('NestedAttributes', [
284+
'class' => 'foo',
285+
'title:class' => 'bar',
286+
'title:span:class' => 'baz',
287+
]);
288+
289+
$this->assertSame(<<<HTML
290+
<main class="foo">
291+
<div class="bar">
292+
<span class="baz">
293+
<div/>
294+
295+
</span>
296+
</div>
297+
</main>
298+
HTML,
299+
trim($output)
300+
);
301+
}
302+
303+
public function testRenderingHtmlSyntaxComponentWithNestedAttributes(): void
304+
{
305+
$output = self::getContainer()
306+
->get(Environment::class)
307+
->createTemplate('<twig:NestedAttributes />')
308+
->render()
309+
;
310+
311+
$this->assertSame(<<<HTML
312+
<main>
313+
<div>
314+
<span>
315+
<div/>
316+
317+
</span>
318+
</div>
319+
</main>
320+
HTML,
321+
trim($output)
322+
);
323+
324+
$output = self::getContainer()
325+
->get(Environment::class)
326+
->createTemplate('<twig:NestedAttributes class="foo" title:class="bar" title:span:class="baz" inner:class="foo" />')
327+
->render()
328+
;
329+
330+
$this->assertSame(<<<HTML
331+
<main class="foo">
332+
<div class="bar">
333+
<span class="baz">
334+
<div class="foo"/>
335+
336+
</span>
337+
</div>
338+
</main>
339+
HTML,
340+
trim($output)
341+
);
342+
}
343+
266344
private function renderComponent(string $name, array $data = []): string
267345
{
268346
return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [

src/TwigComponent/tests/Unit/ComponentAttributesTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,4 +244,18 @@ public function testCanCheckIfAttributeExists(): void
244244

245245
$this->assertTrue($attributes->has('foo'));
246246
}
247+
248+
public function testNestedAttributes(): void
249+
{
250+
$attributes = new ComponentAttributes([
251+
'class' => 'foo',
252+
'title:class' => 'bar',
253+
'title:span:class' => 'baz',
254+
]);
255+
256+
$this->assertSame(' class="foo"', (string) $attributes);
257+
$this->assertSame(' class="bar"', (string) $attributes->nested('title'));
258+
$this->assertSame(' class="baz"', (string) $attributes->nested('title')->nested('span'));
259+
$this->assertSame('', (string) $attributes->nested('invalid'));
260+
}
247261
}

0 commit comments

Comments
 (0)