Skip to content

Commit 0d027d5

Browse files
committed
feat(twig): Add attribute rendering system
1 parent 40d9f11 commit 0d027d5

File tree

6 files changed

+182
-1
lines changed

6 files changed

+182
-1
lines changed

src/TwigComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Make `ComponentAttributes` traversable/countable
66
- Fixed lexing some `{# twig comments #}` with HTML Twig syntax
77
- Fix various usages of deprecated Twig code
8+
- Add attribute rendering system
89

910
## 2.13.0
1011

src/TwigComponent/doc/index.rst

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,79 @@ the exception of *class*. For ``class``, the defaults are prepended:
945945
{# renders as: #}
946946
<button class="bar foo" type="submit">Save</button>
947947

948+
Render
949+
~~~~~~
950+
951+
.. versionadded:: 2.15
952+
953+
The ability to *render* attributes was added in TwigComponents 2.15.
954+
955+
You can take full control over the attributes that are rendered by using the
956+
``render()`` method.
957+
958+
.. code-block:: html+twig
959+
960+
{# templates/components/MyComponent.html.twig #}
961+
<div
962+
style="{{ attributes.render('style') }} display:block;"
963+
{{ attributes }} {# be sure to always render the remaining attributes! #}
964+
>
965+
My Component!
966+
</div>
967+
968+
{# render component #}
969+
{{ component('MyComponent', { style: 'color:red;' }) }}
970+
971+
{# renders as: #}
972+
<div style="color:red; display:block;">
973+
My Component!
974+
</div>
975+
976+
.. caution::
977+
978+
There are a few important things to know about using ``render()``:
979+
980+
1. You need to be sure to call your ``render()`` methods before calling ``{{ attributes }}`` or some
981+
attributes could be rendered twice. For instance:
982+
983+
.. code-block:: html+twig
984+
985+
{# templates/components/MyComponent.html.twig #}
986+
<div
987+
{{ attributes }} {# called before style is rendered #}
988+
style="{{ attributes.render('style') }} display:block;"
989+
>
990+
My Component!
991+
</div>
992+
993+
{# render component #}
994+
{{ component('MyComponent', { style: 'color:red;' }) }}
995+
996+
{# renders as: #}
997+
<div style="color:red;" style="color:red; display:block;"> {# style is rendered twice! #}
998+
My Component!
999+
</div>
1000+
1001+
2. If you add an attribute without calling ``render()``, it will be rendered twice. For instance:
1002+
1003+
.. code-block:: html+twig
1004+
1005+
{# templates/components/MyComponent.html.twig #}
1006+
<div
1007+
style="display:block;" {# not calling attributes.render('style') #}
1008+
{{ attributes }}
1009+
>
1010+
My Component!
1011+
</div>
1012+
1013+
{# render component #}
1014+
{{ component('MyComponent', { style: 'color:red;' }) }}
1015+
1016+
{# renders as: #}
1017+
<div style="display:block;" style="color:red;"> {# style is rendered twice! #}
1018+
My Component!
1019+
</div>
1020+
9481021
Only
9491022
~~~~
9501023

src/TwigComponent/src/ComponentAttributes.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
*/
2222
final class ComponentAttributes implements \IteratorAggregate, \Countable
2323
{
24+
/** @var array<string,true> */
25+
private array $rendered = [];
26+
2427
/**
2528
* @param array<string, string|bool> $attributes
2629
*/
@@ -31,7 +34,10 @@ public function __construct(private array $attributes)
3134
public function __toString(): string
3235
{
3336
return array_reduce(
34-
array_keys($this->attributes),
37+
array_filter(
38+
array_keys($this->attributes),
39+
fn (string $key) => !isset($this->rendered[$key])
40+
),
3541
function (string $carry, string $key) {
3642
$value = $this->attributes[$key];
3743

@@ -54,6 +60,26 @@ function (string $carry, string $key) {
5460
);
5561
}
5662

63+
public function __clone(): void
64+
{
65+
$this->rendered = [];
66+
}
67+
68+
public function render(string $attribute): ?string
69+
{
70+
if (null === $value = $this->attributes[$attribute] ?? null) {
71+
return null;
72+
}
73+
74+
if (!\is_string($value)) {
75+
throw new \LogicException(sprintf('Can only get string attributes (%s is a %s).', $attribute, get_debug_type($value)));
76+
}
77+
78+
$this->rendered[$attribute] = true;
79+
80+
return $value;
81+
}
82+
5783
/**
5884
* @return array<string, string|bool>
5985
*/
@@ -89,6 +115,10 @@ public function defaults(iterable $attributes): self
89115
$attributes[$key] = $value;
90116
}
91117

118+
foreach (array_keys($this->rendered) as $attribute) {
119+
unset($attributes[$attribute]);
120+
}
121+
92122
return new self($attributes);
93123
}
94124

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<div
2+
foo="{{ attributes.render('foo') }}"
3+
bar="{{ attributes.render('bar')|default('default') }}"
4+
baz="default {{ attributes.render('baz') }}"
5+
qux="{{ attributes.render('qux') }} default"
6+
{{ attributes }}
7+
/>

src/TwigComponent/tests/Integration/ComponentExtensionTest.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,51 @@ public function testComponentPropsWithTrailingComma(): void
216216
$this->assertStringContainsString('Hello FOO, 123, and 456', $output);
217217
}
218218

219+
/**
220+
* @dataProvider renderingAttributesManuallyProvider
221+
*/
222+
public function testRenderingAttributesManually(array $attributes, string $expected): void
223+
{
224+
$actual = trim($this->renderComponent('RenderAttributes', $attributes));
225+
226+
$this->assertSame($expected, trim($actual));
227+
}
228+
229+
public static function renderingAttributesManuallyProvider(): iterable
230+
{
231+
yield [
232+
['class' => 'block'],
233+
<<<HTML
234+
<div
235+
foo=""
236+
bar="default"
237+
baz="default "
238+
qux=" default"
239+
class="block"
240+
/>
241+
HTML,
242+
];
243+
244+
yield [
245+
[
246+
'class' => 'block',
247+
'foo' => 'value',
248+
'bar' => 'value',
249+
'baz' => 'value',
250+
'qux' => 'value',
251+
],
252+
<<<HTML
253+
<div
254+
foo="value"
255+
bar="value"
256+
baz="default value"
257+
qux="value default"
258+
class="block"
259+
/>
260+
HTML,
261+
];
262+
}
263+
219264
private function renderComponent(string $name, array $data = []): string
220265
{
221266
return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [

src/TwigComponent/tests/Unit/ComponentAttributesTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,29 @@ public function testIsTraversableAndCountable(): void
199199
$this->assertSame($attributes->all(), iterator_to_array($attributes));
200200
$this->assertCount(1, $attributes);
201201
}
202+
203+
public function testRenderSingleAttribute(): void
204+
{
205+
$attributes = new ComponentAttributes(['attr1' => 'value1', 'attr2' => 'value2']);
206+
207+
$this->assertSame('value1', $attributes->render('attr1'));
208+
$this->assertNull($attributes->render('attr3'));
209+
}
210+
211+
public function testRenderingSingleAttributeExcludesFromString(): void
212+
{
213+
$attributes = new ComponentAttributes(['attr1' => 'value1', 'attr2' => 'value2']);
214+
215+
$this->assertSame('value1', $attributes->render('attr1'));
216+
$this->assertSame(' attr2="value2"', (string) $attributes);
217+
}
218+
219+
public function testCannotRenderNonStringAttribute(): void
220+
{
221+
$attributes = new ComponentAttributes(['attr1' => false]);
222+
223+
$this->expectException(\LogicException::class);
224+
225+
$attributes->render('attr1');
226+
}
202227
}

0 commit comments

Comments
 (0)