Skip to content
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
55 changes: 55 additions & 0 deletions src/TwigComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,61 @@ If no variants match, you can define a default set of classes to apply:
...
</div>

Higher-Order Components (Component Wrappers)
--------------------------------------------

You can create a component that wraps another component to add additional
markup, behavior, or structure. This is useful when you want to extend a base
component without modifying it.

This type of component is sometimes called a "Higher-Order Component" (HOC)
or a "Component Wrapper".

For example, create a base ``Modal`` component:

.. code-block:: html+twig

{# templates/components/Modal.html.twig #}
<div{{ attributes.defaults({class: 'modal'}) }}>
<div class="modal-content">
{% block content %}{% endblock %}
</div>
</div>

Then create a ``Modal:Confirm`` component that wraps it and adds confirmation buttons:

.. code-block:: html+twig

{# templates/components/Modal/Confirm.html.twig #}
{% props confirmText = 'Confirm', cancelText = 'Cancel' %}

<twig:Modal {{ ...attributes.defaults({class: 'modal-confirm'}) }}>
{{ block(outerBlocks.content) }}

<div class="modal-actions">
<button type="button" class="btn-secondary">{{ cancelText }}</button>
<button type="submit" class="btn-primary">{{ confirmText }}</button>
</div>
</twig:Modal>

Usage:

.. code-block:: html+twig

<twig:Modal:Confirm>
Are you sure you want to delete this item?
</twig:Modal:Confirm>

<twig:Modal:Confirm confirmText="Yes, delete it" data-controller="modal">
This action cannot be undone.
</twig:Modal:Confirm>

The key parts are:

- **Spread operator** ``{{ ...attributes }}`` - passes attributes to the wrapped component
- **``outerBlocks``** - forwards content blocks from the wrapper to the wrapped component
- The wrapper can add its own props (``confirmText``, ``cancelText``) and markup

Test Helpers
------------

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<div{{ attributes.defaults({class: 'modal'}) }}>
<div class="modal-content">
{% block content %}{% endblock %}
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{% props confirmText = 'Confirm', cancelText = 'Cancel' %}

<twig:Modal {{ ...attributes.defaults({class: 'modal-confirm'}) }}>
{{ block(outerBlocks.content) }}

<div class="modal-actions">
<button type="button" class="btn-secondary">{{ cancelText }}</button>
<button type="submit" class="btn-primary">{{ confirmText }}</button>
</div>
</twig:Modal>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{# Test base Modal component #}
<twig:Modal>
Simple modal content
</twig:Modal>

{# Test Modal:Confirm wrapper component with default buttons #}
<twig:Modal:Confirm>
Are you sure you want to delete this item?
</twig:Modal:Confirm>

{# Test Modal:Confirm with custom button text #}
<twig:Modal:Confirm confirmText="Yes, delete it" cancelText="No, keep it">
This action cannot be undone.
</twig:Modal:Confirm>

{# Test Modal:Confirm with custom attributes passed through #}
<twig:Modal:Confirm data-controller="modal" id="delete-modal">
Confirm deletion?
</twig:Modal:Confirm>
27 changes: 26 additions & 1 deletion src/TwigComponent/tests/Integration/ComponentExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@ public function testDynamicSyntaxEscapesAttributeValues(string $input)

public static function provideUnsafeAttributes(): iterable
{
return array_map(fn ($s) => (array) $s, [
return array_map(static fn ($s) => (array) $s, [
'"><script>alert("XSS")</script>',
'\"><script>alert(\"XSS\")</script>',
"'><script>alert(\"XSS\")</script>",
Expand All @@ -488,6 +488,31 @@ public function testAnonymousComponentWithPropsOverwriteParentsProps()
$this->assertStringNotContainsString('I am md', $output);
}

public function testHigherOrderComponentWithAttributeDefaults()
{
$output = self::getContainer()->get(Environment::class)->render('higher_order_component.html.twig');

// Test base Modal component
$this->assertStringContainsString('<div class="modal">', $output);
$this->assertStringContainsString('Simple modal content', $output);

// Test Modal:Confirm adds confirmation buttons with default text and default class
$this->assertStringContainsString('Are you sure you want to delete this item?', $output);
$this->assertStringContainsString('<button type="button" class="btn-secondary">Cancel</button>', $output);
$this->assertStringContainsString('<button type="submit" class="btn-primary">Confirm</button>', $output);
$this->assertStringContainsString('<div class="modal modal-confirm">', $output);

// Test Modal:Confirm with custom button text
$this->assertStringContainsString('This action cannot be undone.', $output);
$this->assertStringContainsString('<button type="button" class="btn-secondary">No, keep it</button>', $output);
$this->assertStringContainsString('<button type="submit" class="btn-primary">Yes, delete it</button>', $output);

// Test Modal:Confirm passes attributes through to base Modal
$this->assertStringContainsString('data-controller="modal"', $output);
$this->assertStringContainsString('id="delete-modal"', $output);
$this->assertStringContainsString('Confirm deletion?', $output);
}

private function renderComponent(string $name, array $data = []): string
{
return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [
Expand Down
Loading