Skip to content

Commit afd3b74

Browse files
WebMambaweaverryan
authored andcommitted
Introduce CVA to style TwigComponent
1 parent 2265069 commit afd3b74

File tree

8 files changed

+703
-1
lines changed

8 files changed

+703
-1
lines changed

src/TwigComponent/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
- Add the ability to render specific attributes from the `attributes` variable #1442
66
- Restrict Twig 3.9 for now #1486
77
- Build reproducible TemplateMap to fix possible post-deploy breakage #1497
8+
- Add CVA (Class variant authority) integration #1416
89

910
## 2.14.0
1011

1112
- Make `ComponentAttributes` traversable/countable
1213
- Fixed lexing some `{# twig comments #}` with HTML Twig syntax
1314
- Fix various usages of deprecated Twig code
15+
- Add attribute rendering system
1416

1517
## 2.13.0
1618

src/TwigComponent/doc/index.rst

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,198 @@ Exclude specific attributes:
10581058
My Component!
10591059
</div>
10601060

1061+
Component with Complex Variants (CVA)
1062+
-------------------------------------
1063+
1064+
CVA (Class Variant Authority) is a concept from the JS world (https://cva.style/docs/getting-started/variants).
1065+
It's a concept used by the famous shadcn/ui library (https://ui.shadcn.com).
1066+
CVA allows you to display a component with different variants (color, size, etc.),
1067+
to create highly reusable and customizable components.
1068+
You can use the cva function to define variants for your component.
1069+
The cva function take as argument an array key-value pairs.
1070+
The base key allow you define a set of classes commune to all variants.
1071+
In the variants key you define the different variants of your component.
1072+
1073+
.. code-block:: html+twig
1074+
1075+
{# templates/components/Alert.html.twig #}
1076+
{% props color = 'blue', size = 'md' %}
1077+
1078+
{% set alert = cva({
1079+
base: 'alert ',
1080+
variants: {
1081+
color: {
1082+
blue: 'bg-blue',
1083+
red: 'bg-red',
1084+
green: 'bg-green',
1085+
},
1086+
size: {
1087+
sm: 'text-sm',
1088+
md: 'text-md',
1089+
lg: 'text-lg',
1090+
}
1091+
}
1092+
}) %}
1093+
1094+
<div class="{{ alert.apply({color, size}, attributes.render('class')) }}">
1095+
{% block content %}{% endblock %}
1096+
</div>
1097+
1098+
1099+
{# index.html.twig #}
1100+
1101+
<twig:Alert color="red" size="lg">
1102+
<div>My content</div>
1103+
</twig:Alert>
1104+
// class="alert bg-red text-lg"
1105+
1106+
<twig:Alert color="green" size="sm">
1107+
<div>My content</div>
1108+
</twig:Alert>
1109+
// class="alert bg-green text-sm"
1110+
1111+
<twig:Alert class="flex items-center justify-center">
1112+
<div>My content</div>
1113+
</twig:Alert>
1114+
// class="alert bg-blue text-md flex items-center justify-center"
1115+
1116+
CVA and Tailwind CSS
1117+
~~~~~~~~~~~~~~~~~~~~
1118+
1119+
CVA work perfectly with tailwindcss. The only drawback is you can have class conflicts,
1120+
to have a better control you can use this following bundle (
1121+
https://github.com/tales-from-a-dev/twig-tailwind-extra
1122+
) in addition to the cva function:
1123+
1124+
.. code-block:: terminal
1125+
1126+
$ composer require tales-from-a-dev/twig-tailwind-extra
1127+
1128+
.. code-block:: html+twig
1129+
1130+
{# templates/components/Alert.html.twig #}
1131+
{% props color = 'blue', size = 'md' %}
1132+
1133+
{% set alert = cva({
1134+
base: 'alert ',
1135+
variants: {
1136+
color: {
1137+
blue: 'bg-blue',
1138+
red: 'bg-red',
1139+
green: 'bg-green',
1140+
},
1141+
size: {
1142+
sm: 'text-sm',
1143+
md: 'text-md',
1144+
lg: 'text-lg',
1145+
}
1146+
}
1147+
}) %}
1148+
1149+
<div class="{{ alert.apply({color, size}, attributes.render('class')) | tailwind_merge }}">
1150+
{% block content %}{% endblock %}
1151+
</div>
1152+
1153+
Compounds variants
1154+
~~~~~~~~~~~~~~~~~~
1155+
1156+
You can define compound variants. A compound variant is a variants that apply
1157+
when multiple other variant conditions are met.
1158+
1159+
.. code-block:: html+twig
1160+
1161+
{# templates/components/Alert.html.twig #}
1162+
{% props color = 'blue', size = 'md' %}
1163+
1164+
{% set alert = cva({
1165+
base: 'alert ',
1166+
variants: {
1167+
color: {
1168+
blue: 'bg-blue',
1169+
red: 'bg-red',
1170+
green: 'bg-green',
1171+
},
1172+
size: {
1173+
sm: 'text-sm',
1174+
md: 'text-md',
1175+
lg: 'text-lg',
1176+
}
1177+
},
1178+
compound: {
1179+
colors: ['red'],
1180+
size: ['md', 'lg'],
1181+
class: 'font-bold'
1182+
}
1183+
}) %}
1184+
1185+
<div class="{{ alert.apply({color, size}) }}">
1186+
{% block content %}{% endblock %}
1187+
</div>
1188+
1189+
{# index.html.twig #}
1190+
1191+
<twig:Alert color="red" size="lg">
1192+
<div>My content</div>
1193+
</twig:Alert>
1194+
// class="alert bg-red text-lg font-bold"
1195+
1196+
<twig:Alert color="green" size="sm">
1197+
<div>My content</div>
1198+
</twig:Alert>
1199+
// class="alert bg-green text-sm"
1200+
1201+
<twig:Alert color="red" size="md">
1202+
<div>My content</div>
1203+
</twig:Alert>
1204+
// class="alert bg-green text-lg font-bold"
1205+
1206+
Default variants
1207+
~~~~~~~~~~~~~~~~
1208+
1209+
You can define defaults variants, so if no variants are matching you
1210+
can still defined a default set of class to apply.
1211+
1212+
.. code-block:: html+twig
1213+
1214+
{# templates/components/Alert.html.twig #}
1215+
{% props color = 'blue', size = 'md' %}
1216+
1217+
{% set alert = cva({
1218+
base: 'alert ',
1219+
variants: {
1220+
color: {
1221+
blue: 'bg-blue',
1222+
red: 'bg-red',
1223+
green: 'bg-green',
1224+
},
1225+
size: {
1226+
sm: 'text-sm',
1227+
md: 'text-md',
1228+
lg: 'text-lg',
1229+
},
1230+
rounded: {
1231+
sm: 'rounded-sm',
1232+
md: 'rounded-md',
1233+
lg: 'rounded-lg',
1234+
}
1235+
},
1236+
defaultsVariants: {
1237+
rounded: 'rounded-md',
1238+
}
1239+
}) %}
1240+
1241+
<div class="{{ alert.apply({color, size}) }}">
1242+
{% block content %}{% endblock %}
1243+
</div>
1244+
1245+
{# index.html.twig #}
1246+
1247+
<twig:Alert color="red" size="lg">
1248+
<div>My content</div>
1249+
</twig:Alert>
1250+
// class="alert bg-red text-lg font-bold rounded-md"
1251+
1252+
10611253
Test Helpers
10621254
------------
10631255

src/TwigComponent/src/CVA.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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 <[email protected]>
16+
*
17+
* CVA (class variant authority), is a concept from the js world.
18+
* https://cva.style/docs
19+
* The UI library shadcn is build on top of this principle
20+
* https://ui.shadcn.com
21+
* The concept behind CVA is to let you build component with a lot of different variations called recipes.
22+
*
23+
* @experimental
24+
*/
25+
final class CVA
26+
{
27+
/**
28+
* @var string|list<string|null>|null
29+
* @var array<string, array<string, string>>|null the array should have the following format [variantCategory => [variantName => classes]]
30+
* ex: ['colors' => ['primary' => 'bleu-8000', 'danger' => 'red-800 text-bold'], 'size' => [...]]
31+
* @var array<array<string, string[]>>|null the array should have the following format ['variantsCategory' => ['variantName', 'variantName'], 'class' => 'text-red-500']
32+
* @var array<string, string>|null
33+
*/
34+
public function __construct(
35+
private string|array|null $base = null,
36+
private ?array $variants = null,
37+
private ?array $compoundVariants = null,
38+
private ?array $defaultVariants = null,
39+
) {
40+
}
41+
42+
public function apply(array $recipes, string ...$classes): string
43+
{
44+
return trim($this->resolve($recipes).' '.implode(' ', $classes));
45+
}
46+
47+
public function resolve(array $recipes): string
48+
{
49+
if (\is_array($this->base)) {
50+
$classes = implode(' ', $this->base);
51+
} else {
52+
$classes = $this->base ?? '';
53+
}
54+
55+
foreach ($recipes as $recipeName => $recipeValue) {
56+
if (!isset($this->variants[$recipeName][$recipeValue])) {
57+
continue;
58+
}
59+
60+
$classes .= ' '.$this->variants[$recipeName][$recipeValue];
61+
}
62+
63+
if (null !== $this->compoundVariants) {
64+
foreach ($this->compoundVariants as $compound) {
65+
$isCompound = true;
66+
foreach ($compound as $compoundName => $compoundValues) {
67+
if ('class' === $compoundName) {
68+
continue;
69+
}
70+
71+
if (!isset($recipes[$compoundName])) {
72+
$isCompound = false;
73+
break;
74+
}
75+
76+
if (!\in_array($recipes[$compoundName], $compoundValues)) {
77+
$isCompound = false;
78+
break;
79+
}
80+
}
81+
82+
if ($isCompound) {
83+
if (!isset($compound['class']) || !\is_string($compound['class'])) {
84+
throw new \LogicException('A compound recipe matched but no classes are registered for this match');
85+
}
86+
87+
$classes .= ' '.$compound['class'];
88+
}
89+
}
90+
}
91+
92+
if (null !== $this->defaultVariants) {
93+
foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
94+
if (!isset($recipes[$defaultVariantName])) {
95+
$classes .= ' '.$this->variants[$defaultVariantName][$defaultVariantValue];
96+
}
97+
}
98+
}
99+
100+
return trim($classes);
101+
}
102+
}

src/TwigComponent/src/Twig/ComponentExtension.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
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;
1819
use Twig\Error\RuntimeError;
1920
use Twig\Extension\AbstractExtension;
@@ -41,6 +42,7 @@ public function getFunctions(): array
4142
{
4243
return [
4344
new TwigFunction('component', [$this, 'render'], ['is_safe' => ['all']]),
45+
new TwigFunction('cva', [$this, 'cva']),
4446
];
4547
}
4648

@@ -84,6 +86,29 @@ public function finishEmbeddedComponentRender(): void
8486
$this->container->get(ComponentRenderer::class)->finishEmbeddedComponentRender();
8587
}
8688

89+
/**
90+
* @param array{
91+
* base: string|string[]|null,
92+
* variants: array<string, array<string, string>>,
93+
* compoundVariants: array<array<string, string>>,
94+
* defaultVariants: array<string, string>
95+
* } $cva
96+
*
97+
* base some base class you want to have in every matching recipes
98+
* variants your recipes class
99+
* compoundVariants compounds allow you to add extra class when multiple variation are matching in the same time
100+
* defaultVariants allow you to add a default class when no recipe is matching
101+
*/
102+
public function cva(array $cva): CVA
103+
{
104+
return new CVA(
105+
$cva['base'] ?? null,
106+
$cva['variants'] ?? null,
107+
$cva['compoundVariants'] ?? null,
108+
$cva['defaultVariants'] ?? null,
109+
);
110+
}
111+
87112
private function throwRuntimeError(string $name, \Throwable $e): void
88113
{
89114
// 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:Alert color='red' size='lg' class='dark:bg-gray-600'/>

0 commit comments

Comments
 (0)