Skip to content

Commit 0271a3e

Browse files
committed
[TwigComponent] Document high-order components
1 parent efd12ee commit 0271a3e

File tree

5 files changed

+114
-0
lines changed

5 files changed

+114
-0
lines changed

src/TwigComponent/doc/index.rst

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,61 @@ If no variants match, you can define a default set of classes to apply:
14071407
...
14081408
</div>
14091409

1410+
Higher-Order Components (Component Wrappers)
1411+
--------------------------------------------
1412+
1413+
You can create a component that wraps another component to add additional
1414+
markup, behavior, or structure. This is useful when you want to extend a base
1415+
component without modifying it.
1416+
1417+
This type of component is sometimes called a "Higher-Order Component" (HOC)
1418+
or a "Component Wrapper".
1419+
1420+
For example, create a base ``Modal`` component:
1421+
1422+
.. code-block:: html+twig
1423+
1424+
{# templates/components/Modal.html.twig #}
1425+
<div{{ attributes.defaults({class: 'modal'}) }}>
1426+
<div class="modal-content">
1427+
{% block content %}{% endblock %}
1428+
</div>
1429+
</div>
1430+
1431+
Then create a ``Modal:Confirm`` component that wraps it and adds confirmation buttons:
1432+
1433+
.. code-block:: html+twig
1434+
1435+
{# templates/components/Modal/Confirm.html.twig #}
1436+
{% props confirmText = 'Confirm', cancelText = 'Cancel' %}
1437+
1438+
<twig:Modal {{ ...attributes.defaults({class: 'modal-confirm'}) }}>
1439+
{{ block(outerBlocks.content) }}
1440+
1441+
<div class="modal-actions">
1442+
<button type="button" class="btn-secondary">{{ cancelText }}</button>
1443+
<button type="submit" class="btn-primary">{{ confirmText }}</button>
1444+
</div>
1445+
</twig:Modal>
1446+
1447+
Usage:
1448+
1449+
.. code-block:: html+twig
1450+
1451+
<twig:Modal:Confirm>
1452+
Are you sure you want to delete this item?
1453+
</twig:Modal:Confirm>
1454+
1455+
<twig:Modal:Confirm confirmText="Yes, delete it" data-controller="modal">
1456+
This action cannot be undone.
1457+
</twig:Modal:Confirm>
1458+
1459+
The key parts are:
1460+
1461+
- **Spread operator** ``{{ ...attributes }}`` - passes attributes to the wrapped component
1462+
- **``outerBlocks``** - forwards content blocks from the wrapper to the wrapped component
1463+
- The wrapper can add its own props (``confirmText``, ``cancelText``) and markup
1464+
14101465
Test Helpers
14111466
------------
14121467

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<div{{ attributes.defaults({class: 'modal'}) }}>
2+
<div class="modal-content">
3+
{% block content %}{% endblock %}
4+
</div>
5+
</div>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{% props confirmText = 'Confirm', cancelText = 'Cancel' %}
2+
3+
<twig:Modal {{ ...attributes.defaults({class: 'modal-confirm'}) }}>
4+
{{ block(outerBlocks.content) }}
5+
6+
<div class="modal-actions">
7+
<button type="button" class="btn-secondary">{{ cancelText }}</button>
8+
<button type="submit" class="btn-primary">{{ confirmText }}</button>
9+
</div>
10+
</twig:Modal>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{# Test base Modal component #}
2+
<twig:Modal>
3+
Simple modal content
4+
</twig:Modal>
5+
6+
{# Test Modal:Confirm wrapper component with default buttons #}
7+
<twig:Modal:Confirm>
8+
Are you sure you want to delete this item?
9+
</twig:Modal:Confirm>
10+
11+
{# Test Modal:Confirm with custom button text #}
12+
<twig:Modal:Confirm confirmText="Yes, delete it" cancelText="No, keep it">
13+
This action cannot be undone.
14+
</twig:Modal:Confirm>
15+
16+
{# Test Modal:Confirm with custom attributes passed through #}
17+
<twig:Modal:Confirm data-controller="modal" id="delete-modal">
18+
Confirm deletion?
19+
</twig:Modal:Confirm>

src/TwigComponent/tests/Integration/ComponentExtensionTest.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,31 @@ public function testAnonymousComponentWithPropsOverwriteParentsProps()
488488
$this->assertStringNotContainsString('I am md', $output);
489489
}
490490

491+
public function testHigherOrderComponentWithAttributeDefaults()
492+
{
493+
$output = self::getContainer()->get(Environment::class)->render('higher_order_component.html.twig');
494+
495+
// Test base Modal component
496+
$this->assertStringContainsString('<div class="modal">', $output);
497+
$this->assertStringContainsString('Simple modal content', $output);
498+
499+
// Test Modal:Confirm adds confirmation buttons with default text and default class
500+
$this->assertStringContainsString('Are you sure you want to delete this item?', $output);
501+
$this->assertStringContainsString('<button type="button" class="btn-secondary">Cancel</button>', $output);
502+
$this->assertStringContainsString('<button type="submit" class="btn-primary">Confirm</button>', $output);
503+
$this->assertStringContainsString('<div class="modal modal-confirm">', $output);
504+
505+
// Test Modal:Confirm with custom button text
506+
$this->assertStringContainsString('This action cannot be undone.', $output);
507+
$this->assertStringContainsString('<button type="button" class="btn-secondary">No, keep it</button>', $output);
508+
$this->assertStringContainsString('<button type="submit" class="btn-primary">Yes, delete it</button>', $output);
509+
510+
// Test Modal:Confirm passes attributes through to base Modal
511+
$this->assertStringContainsString('data-controller="modal"', $output);
512+
$this->assertStringContainsString('id="delete-modal"', $output);
513+
$this->assertStringContainsString('Confirm deletion?', $output);
514+
}
515+
491516
private function renderComponent(string $name, array $data = []): string
492517
{
493518
return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [

0 commit comments

Comments
 (0)