Skip to content

Commit

Permalink
feat(twig): Add attribute rendering system
Browse files Browse the repository at this point in the history
  • Loading branch information
kbond committed Feb 7, 2024
1 parent 40d9f11 commit 0d027d5
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 1 deletion.
1 change: 1 addition & 0 deletions src/TwigComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Make `ComponentAttributes` traversable/countable
- Fixed lexing some `{# twig comments #}` with HTML Twig syntax
- Fix various usages of deprecated Twig code
- Add attribute rendering system

## 2.13.0

Expand Down
73 changes: 73 additions & 0 deletions src/TwigComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,79 @@ the exception of *class*. For ``class``, the defaults are prepended:
{# renders as: #}
<button class="bar foo" type="submit">Save</button>

Render
~~~~~~

.. versionadded:: 2.15

The ability to *render* attributes was added in TwigComponents 2.15.

You can take full control over the attributes that are rendered by using the
``render()`` method.

.. code-block:: html+twig

{# templates/components/MyComponent.html.twig #}
<div
style="{{ attributes.render('style') }} display:block;"
{{ attributes }} {# be sure to always render the remaining attributes! #}
>
My Component!
</div>

{# render component #}
{{ component('MyComponent', { style: 'color:red;' }) }}

{# renders as: #}
<div style="color:red; display:block;">
My Component!
</div>

.. caution::

There are a few important things to know about using ``render()``:

1. You need to be sure to call your ``render()`` methods before calling ``{{ attributes }}`` or some
attributes could be rendered twice. For instance:

.. code-block:: html+twig

{# templates/components/MyComponent.html.twig #}
<div
{{ attributes }} {# called before style is rendered #}
style="{{ attributes.render('style') }} display:block;"
>
My Component!
</div>

{# render component #}
{{ component('MyComponent', { style: 'color:red;' }) }}

{# renders as: #}
<div style="color:red;" style="color:red; display:block;"> {# style is rendered twice! #}
My Component!
</div>

2. If you add an attribute without calling ``render()``, it will be rendered twice. For instance:

.. code-block:: html+twig

{# templates/components/MyComponent.html.twig #}
<div
style="display:block;" {# not calling attributes.render('style') #}
{{ attributes }}
>
My Component!
</div>

{# render component #}
{{ component('MyComponent', { style: 'color:red;' }) }}

{# renders as: #}
<div style="display:block;" style="color:red;"> {# style is rendered twice! #}
My Component!
</div>

Only
~~~~

Expand Down
32 changes: 31 additions & 1 deletion src/TwigComponent/src/ComponentAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
*/
final class ComponentAttributes implements \IteratorAggregate, \Countable
{
/** @var array<string,true> */
private array $rendered = [];

/**
* @param array<string, string|bool> $attributes
*/
Expand All @@ -31,7 +34,10 @@ public function __construct(private array $attributes)
public function __toString(): string
{
return array_reduce(
array_keys($this->attributes),
array_filter(
array_keys($this->attributes),
fn (string $key) => !isset($this->rendered[$key])
),
function (string $carry, string $key) {
$value = $this->attributes[$key];

Expand All @@ -54,6 +60,26 @@ function (string $carry, string $key) {
);
}

public function __clone(): void
{
$this->rendered = [];
}

public function render(string $attribute): ?string
{
if (null === $value = $this->attributes[$attribute] ?? null) {
return null;
}

if (!\is_string($value)) {
throw new \LogicException(sprintf('Can only get string attributes (%s is a %s).', $attribute, get_debug_type($value)));
}

$this->rendered[$attribute] = true;

return $value;
}

/**
* @return array<string, string|bool>
*/
Expand Down Expand Up @@ -89,6 +115,10 @@ public function defaults(iterable $attributes): self
$attributes[$key] = $value;
}

foreach (array_keys($this->rendered) as $attribute) {
unset($attributes[$attribute]);
}

return new self($attributes);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div
foo="{{ attributes.render('foo') }}"
bar="{{ attributes.render('bar')|default('default') }}"
baz="default {{ attributes.render('baz') }}"
qux="{{ attributes.render('qux') }} default"
{{ attributes }}
/>
45 changes: 45 additions & 0 deletions src/TwigComponent/tests/Integration/ComponentExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,51 @@ public function testComponentPropsWithTrailingComma(): void
$this->assertStringContainsString('Hello FOO, 123, and 456', $output);
}

/**
* @dataProvider renderingAttributesManuallyProvider
*/
public function testRenderingAttributesManually(array $attributes, string $expected): void
{
$actual = trim($this->renderComponent('RenderAttributes', $attributes));

$this->assertSame($expected, trim($actual));
}

public static function renderingAttributesManuallyProvider(): iterable
{
yield [
['class' => 'block'],
<<<HTML
<div
foo=""
bar="default"
baz="default "
qux=" default"
class="block"
/>
HTML,
];

yield [
[
'class' => 'block',
'foo' => 'value',
'bar' => 'value',
'baz' => 'value',
'qux' => 'value',
],
<<<HTML
<div
foo="value"
bar="value"
baz="default value"
qux="value default"
class="block"
/>
HTML,
];
}

private function renderComponent(string $name, array $data = []): string
{
return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [
Expand Down
25 changes: 25 additions & 0 deletions src/TwigComponent/tests/Unit/ComponentAttributesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,29 @@ public function testIsTraversableAndCountable(): void
$this->assertSame($attributes->all(), iterator_to_array($attributes));
$this->assertCount(1, $attributes);
}

public function testRenderSingleAttribute(): void
{
$attributes = new ComponentAttributes(['attr1' => 'value1', 'attr2' => 'value2']);

$this->assertSame('value1', $attributes->render('attr1'));
$this->assertNull($attributes->render('attr3'));
}

public function testRenderingSingleAttributeExcludesFromString(): void
{
$attributes = new ComponentAttributes(['attr1' => 'value1', 'attr2' => 'value2']);

$this->assertSame('value1', $attributes->render('attr1'));
$this->assertSame(' attr2="value2"', (string) $attributes);
}

public function testCannotRenderNonStringAttribute(): void
{
$attributes = new ComponentAttributes(['attr1' => false]);

$this->expectException(\LogicException::class);

$attributes->render('attr1');
}
}

0 comments on commit 0d027d5

Please sign in to comment.