Skip to content

Commit 6408909

Browse files
committed
Introduce CVA to style TwigComponent
1 parent b07f864 commit 6408909

File tree

9 files changed

+412
-0
lines changed

9 files changed

+412
-0
lines changed

src/TwigComponent/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
},
2828
"require": {
2929
"php": ">=8.1",
30+
"gehrisandro/tailwind-merge-php": "dev-main",
3031
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
3132
"symfony/deprecation-contracts": "^2.2|^3.0",
3233
"symfony/event-dispatcher": "^5.4|^6.0|^7.0",

src/TwigComponent/src/CVA.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\TwigComponent;
13+
14+
/**
15+
* @author Mathéo Daninos <matheo.daninos@gmail.com>
16+
*/
17+
class CVA
18+
{
19+
private ?string $base;
20+
21+
/**
22+
* @var array<string, array<string, string>>
23+
*/
24+
private ?array $variants;
25+
26+
/**
27+
* @var array<array<string, string[]>>|null
28+
*/
29+
private ?array $compounds;
30+
31+
public function __construct(?string $base = null, ?array $variants = null, ?array $compounds = null)
32+
{
33+
$this->base = $base;
34+
$this->variants = $variants;
35+
$this->compounds = $compounds;
36+
}
37+
38+
public static function init(array $recipe): self
39+
{
40+
return new self(
41+
$recipe['base'] ?? '',
42+
$recipe['variants'] ?? null,
43+
$recipe['compounds'] ?? null
44+
);
45+
}
46+
47+
public function resolve(array $recipes): string
48+
{
49+
$classes = '';
50+
51+
if (null !== $this->base) {
52+
$classes .= $this->base;
53+
}
54+
55+
foreach ($recipes as $recipeName => $recipeValue) {
56+
if (!isset($this->variants[$recipeName])) {
57+
continue;
58+
}
59+
60+
if (!isset($this->variants[$recipeName][$recipeValue])) {
61+
continue;
62+
}
63+
64+
$classes .= ' '.$this->variants[$recipeName][$recipeValue];
65+
}
66+
67+
if (null !== $this->compounds) {
68+
foreach ($this->compounds as $compound) {
69+
$isCompound = true;
70+
foreach ($compound as $compoundName => $compoundValues) {
71+
if ('class' === $compoundName) {
72+
continue;
73+
}
74+
75+
if (!isset($recipes[$compoundName])) {
76+
$isCompound = false;
77+
break;
78+
}
79+
80+
if (!\in_array($recipes[$compoundName], $compoundValues)) {
81+
$isCompound = false;
82+
break;
83+
}
84+
}
85+
86+
if ($isCompound) {
87+
$classes .= ' '.$compound['class'];
88+
}
89+
}
90+
}
91+
92+
return trim($classes);
93+
}
94+
}

src/TwigComponent/src/Twig/ComponentExtension.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
use Psr\Container\ContainerInterface;
1515
use Symfony\Contracts\Service\ServiceSubscriberInterface;
1616
use Symfony\UX\TwigComponent\ComponentRenderer;
17+
use Symfony\UX\TwigComponent\CVA;
1718
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
19+
use TailwindMerge\TailwindMerge;
1820
use Twig\Error\RuntimeError;
1921
use Twig\Extension\AbstractExtension;
2022
use Twig\TwigFunction;
@@ -41,6 +43,9 @@ public function getFunctions(): array
4143
{
4244
return [
4345
new TwigFunction('component', [$this, 'render'], ['is_safe' => ['all']]),
46+
new TwigFunction('cva', [$this, 'cva'], ['needs_context' => true]),
47+
new TwigFunction('twm', [$this, 'twm'], ['needs_context' => true]),
48+
new TwigFunction('clm', [$this, 'clm'], ['needs_context' => true]),
4449
];
4550
}
4651

@@ -84,6 +89,43 @@ public function finishEmbeddedComponentRender(): void
8489
$this->container->get(ComponentRenderer::class)->finishEmbeddedComponentRender();
8590
}
8691

92+
public function cva(array &$context, string $recipeName, array $recipe): void
93+
{
94+
$key = '__recipe__'.$recipeName;
95+
96+
$context[$key] = CVA::init($recipe);
97+
}
98+
99+
public function twm(array $context, string $recipeName, array $variants, string $custom = null): string
100+
{
101+
$key = '__recipe__'.$recipeName;
102+
103+
if (!isset($context[$key])) {
104+
return '';
105+
}
106+
107+
$tw = TailwindMerge::instance();
108+
109+
/** @var CVA $recipe */
110+
$cva = $context[$key];
111+
112+
return $tw->merge($cva->resolve($variants).' '.$custom ?? '');
113+
}
114+
115+
public function clm(array $context, string $recipeName, array $variants, string $custom = null): string
116+
{
117+
$key = '__recipe__'.$recipeName;
118+
119+
if (!isset($context[$key])) {
120+
return '';
121+
}
122+
123+
/** @var CVA $recipe */
124+
$cva = $context[$key];
125+
126+
return $cva->resolve($variants).' '.$custom ?? '';
127+
}
128+
87129
private function throwRuntimeError(string $name, \Throwable $e): void
88130
{
89131
// if it's already a Twig RuntimeError, just rethrow it
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<twig:Alert2 color='red' size='lg' class='dark:bg-gray-600'/>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{% props color = 'blue', size = 'md', class = '' %}
2+
3+
{{ cva('alert', {
4+
'base': 'rounded-lg',
5+
'variants': {
6+
'color': {
7+
'blue': 'text-blue-800 bg-blue-50 dark:bg-gray-800 dark:text-blue-400',
8+
'red': 'text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400',
9+
'green': 'text-green-800 bg-green-50 dark:bg-gray-800 dark:text-green-400',
10+
'yellow': 'text-yellow-800 bg-yellow-50 dark:bg-gray-800 dark:text-yellow-400',
11+
},
12+
'size': {
13+
'sm': 'px-4 py-3 text-sm',
14+
'md': 'px-6 py-4 text-base',
15+
'lg': 'px-8 py-5 text-lg',
16+
}
17+
}
18+
}) }}
19+
20+
<div class="{{ twm('alert', {color, size}, class) }}">
21+
...
22+
</div>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{% props color = 'blue', size = 'md', class = '' %}
2+
3+
{{ cva('alert', {
4+
'base': 'alert',
5+
'variants': {
6+
'color': {
7+
'blue': 'alert-blue',
8+
'red': 'alert-red',
9+
'green': 'alert-green',
10+
'yellow': 'alert-yellow',
11+
},
12+
'size': {
13+
'sm': 'alert-sm',
14+
'md': 'alert-md',
15+
'lg': 'alert-lg',
16+
}
17+
}
18+
}) }}
19+
20+
<div class="{{ clm('alert', {color, size}, class) }}">
21+
...
22+
</div>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<twig:Alert color='red' size='lg' class='dark:bg-gray-600'/>

src/TwigComponent/tests/Integration/ComponentExtensionTest.php

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

219+
public function testComponentWithTailwindMerge(): void
220+
{
221+
$output = self::getContainer()->get(Environment::class)->render('tailwind_merge.html.twig');
222+
223+
$this->assertStringContainsString('class="rounded-lg text-red-800 bg-red-50 dark:text-red-400 px-8 py-5 text-lg dark:bg-gray-600"', $output);
224+
}
225+
226+
public function testComponentWithClassMerge(): void
227+
{
228+
$output = self::getContainer()->get(Environment::class)->render('class_merge.html.twig');
229+
230+
$this->assertStringContainsString('class="alert alert-red alert-lg dark:bg-gray-600"', $output);
231+
}
232+
219233
private function renderComponent(string $name, array $data = []): string
220234
{
221235
return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [

0 commit comments

Comments
 (0)