Skip to content

[Twig] Allow nested attributes #1405

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

Merged
merged 1 commit into from
Mar 23, 2024
Merged
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: 4 additions & 0 deletions src/TwigComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## 2.17.0

- Add nested attribute support #1405

## 2.16.0

- Introduce CVA to style TwigComponent #1416
Expand Down
57 changes: 57 additions & 0 deletions src/TwigComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,63 @@ Exclude specific attributes:
My Component!
</div>

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:

Comment on lines +1068 to +1072
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can pass attributes from the outside directly to the descendants of a component. This approach is especially useful for components like dialogs, to configure the rendering of inner parts like content, title, and footer. Here's an example:

.. code-block:: html+twig

{# templates/components/Dialog.html.twig #}
<div{{ attributes }}>
<div{{ attributes.nested('title') }}>
{% block title %}Default Title{% endblock %}
</div>
<div{{ attributes.nested('body') }}>
{% block content %}{% endblock %}
</div>
<div{{ attributes.nested('footer') }}>
{% block footer %}Default Footer{% endblock %}
</div>
</div>

{# render #}
<twig:Dialog class="foo" title:class="bar" body:class="baz" footer:class="qux">
Some content
</twig:MyDialog>

{# output #}
<div class="foo">
<div class="bar">
Default Title
</div>
<div class="baz">
Some content
</div>
<div class="qux">
Default Footer
</div>
</div>

The nesting is recursive so you could potentially do something like this:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm on very small details but this is not really recursive and more a tree here (in the meaning a parent call child, but never call itself back)

This paragraph and your following example make me want to do two small TwigComponent demos

  • one litterally recursive where you pass to the child a level max and something a bit dynamic (oh i think i have n idea, poke me if your'e around) and the recursion goes until the level is reached
  • a form demo with a cool theme, just like you did.

WDYT ?

(and we need to start the TwigComponent demo)


.. code-block:: html+twig

<twig:Form
:form="form"
class="ui-form"
row:class="ui-form-row"
row:label:class="ui-form-label"
row:widget:class="ui-form-widget"
/>

Component with Complex Variants (CVA)
-------------------------------------

Expand Down
21 changes: 20 additions & 1 deletion src/TwigComponent/src/ComponentAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
*
* @immutable
*/
final class ComponentAttributes implements \IteratorAggregate, \Countable
final class ComponentAttributes implements \Stringable, \IteratorAggregate, \Countable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3

{
private const NESTED_REGEX = '#^([\w-]+):(.+)$#';

/** @var array<string,true> */
private array $rendered = [];

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div{{ attributes }}/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<main{{ attributes }}>
<div{{ attributes.nested('title') }}>
<span{{ attributes.nested('title').nested('span') }}>
{{ component('JustAttributes', [...attributes.nested('inner')]) }}
</span>
</div>
</main>
78 changes: 78 additions & 0 deletions src/TwigComponent/tests/Integration/ComponentExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
<main>
<div>
<span>
<div/>

</span>
</div>
</main>
HTML,
trim($output)
);

$output = $this->renderComponent('NestedAttributes', [
'class' => 'foo',
'title:class' => 'bar',
'title:span:class' => 'baz',
]);

$this->assertSame(<<<HTML
<main class="foo">
<div class="bar">
<span class="baz">
<div/>

</span>
</div>
</main>
HTML,
trim($output)
);
}

public function testRenderingHtmlSyntaxComponentWithNestedAttributes(): void
{
$output = self::getContainer()
->get(Environment::class)
->createTemplate('<twig:NestedAttributes />')
->render()
;

$this->assertSame(<<<HTML
<main>
<div>
<span>
<div/>

</span>
</div>
</main>
HTML,
trim($output)
);

$output = self::getContainer()
->get(Environment::class)
->createTemplate('<twig:NestedAttributes class="foo" title:class="bar" title:span:class="baz" inner:class="foo" />')
->render()
;

$this->assertSame(<<<HTML
<main class="foo">
<div class="bar">
<span class="baz">
<div class="foo"/>

</span>
</div>
</main>
HTML,
trim($output)
);
}

private function renderComponent(string $name, array $data = []): string
{
return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [
Expand Down
14 changes: 14 additions & 0 deletions src/TwigComponent/tests/Unit/ComponentAttributesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}