Skip to content

Commit 1301956

Browse files
smnandrekbond
authored andcommitted
[Twig] CVA - Rework implementation + documentation
1 parent 2176c58 commit 1301956

File tree

3 files changed

+165
-97
lines changed

3 files changed

+165
-97
lines changed

src/TwigComponent/src/CVA.php

Lines changed: 93 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -12,107 +12,124 @@
1212
namespace Symfony\UX\TwigComponent;
1313

1414
/**
15-
* @author Mathéo Daninos <matheo.daninos@gmail.com>
15+
* Class Variant Authority (CVA) resolver.
16+
*
17+
* The CVA concept is used to render multiple variations of components, applying
18+
* a set of conditions and recipes to dynamically compose CSS class strings.
19+
*
20+
* @see https://cva.style/docs
1621
*
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+
* @doc https://symfony.com/bundles/ux-twig-component/current/index.html
23+
*
24+
* @author Mathéo Daninos <matheo.daninos@gmail.com>
2225
*
2326
* @experimental
2427
*/
2528
final class CVA
2629
{
2730
/**
28-
* @var string|list<string|null>|null
29-
* @var array<string, array<string, string|list<string|null>>|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|array<string>>> the array should have the following format ['variantsCategory' => ['variantName', 'variantName'], 'class' => 'text-red-500']
32-
* @var array<string, string>|null
31+
* @var list<string|null>
32+
*/
33+
private readonly array $base;
34+
35+
/**
36+
* @param string|list<string|null> $base The base classes to apply to the component
3337
*/
3438
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+
string|array $base = [],
40+
/**
41+
* The variants to apply based on recipes.
42+
*
43+
* Format: [variantCategory => [variantName => classes]]
44+
*
45+
* Example:
46+
* 'colors' => [
47+
* 'primary' => 'bleu-8000',
48+
* 'danger' => 'red-800 text-bold',
49+
* ],
50+
* 'size' => [...],
51+
*
52+
* @var array<string, array<string, string|list<string>>>
53+
*/
54+
private readonly array $variants = [],
55+
56+
/**
57+
* The compound variants to apply based on recipes.
58+
*
59+
* Format: [variantsCategory => ['variantName', 'variantName'], class: classes]
60+
*
61+
* Example:
62+
* [
63+
* 'colors' => ['primary'],
64+
* 'size' => ['small'],
65+
* 'class' => 'text-red-500',
66+
* ],
67+
* [
68+
* 'size' => ['large'],
69+
* 'class' => 'font-weight-500',
70+
* ]
71+
*
72+
* @var array<array<string, string|array<string>>>
73+
*/
74+
private readonly array $compoundVariants = [],
75+
76+
/**
77+
* The default variants to apply if specific recipes aren't provided.
78+
*
79+
* Format: [variantCategory => variantName]
80+
*
81+
* Example:
82+
* 'colors' => 'primary',
83+
*
84+
* @var array<string, string>
85+
*/
86+
private readonly array $defaultVariants = [],
3987
) {
88+
$this->base = (array) $base;
4089
}
4190

42-
public function apply(array $recipes, ?string ...$classes): string
91+
public function apply(array $recipes, ?string ...$additionalClasses): string
4392
{
44-
return trim($this->resolve($recipes).' '.implode(' ', array_filter($classes)));
45-
}
93+
$classes = [...$this->base];
4694

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 ?? '';
95+
// Resolve recipes against variants
96+
foreach ($recipes as $recipeName => $recipeValue) {
97+
$recipeClasses = $this->variants[$recipeName][$recipeValue] ?? [];
98+
$classes = [...$classes, ...(array) $recipeClasses];
5399
}
54100

55-
foreach ($recipes as $recipeName => $recipeValue) {
56-
if (!isset($this->variants[$recipeName][$recipeValue])) {
57-
continue;
58-
}
101+
// Resolve compound variants
102+
foreach ($this->compoundVariants as $compound) {
103+
$compoundClasses = $this->resolveCompoundVariant($compound, $recipes) ?? [];
104+
$classes = [...$classes, ...$compoundClasses];
105+
}
59106

60-
if (\is_string($this->variants[$recipeName][$recipeValue])) {
61-
$classes .= ' '.$this->variants[$recipeName][$recipeValue];
62-
} else {
63-
$classes .= ' '.implode(' ', $this->variants[$recipeName][$recipeValue]);
107+
// Apply default variants if specific recipes aren't provided
108+
foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
109+
if (!isset($recipes[$defaultVariantName])) {
110+
$variantClasses = $this->variants[$defaultVariantName][$defaultVariantValue] ?? [];
111+
$classes = [...$classes, ...(array) $variantClasses];
64112
}
65113
}
114+
$classes = [...$classes, ...array_values($additionalClasses)];
66115

67-
if (null !== $this->compoundVariants) {
68-
foreach ($this->compoundVariants 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 (!\is_array($compoundValues)) {
81-
$compoundValues = [$compoundValues];
82-
}
83-
84-
if (!\in_array($recipes[$compoundName], $compoundValues)) {
85-
$isCompound = false;
86-
break;
87-
}
88-
}
116+
$classes = implode(' ', array_filter($classes, is_string(...)));
117+
$classes = preg_split('#\s+#', $classes, -1, \PREG_SPLIT_NO_EMPTY) ?: [];
89118

90-
if ($isCompound) {
91-
if (!isset($compound['class'])) {
92-
throw new \LogicException('A compound recipe matched but no classes are registered for this match');
93-
}
94-
95-
if (!\is_string($compound['class']) && !\is_array($compound['class'])) {
96-
throw new \LogicException('The class of a compound recipe should be a string or an array of string');
97-
}
119+
return implode(' ', array_unique($classes));
120+
}
98121

99-
if (\is_string($compound['class'])) {
100-
$classes .= ' '.$compound['class'];
101-
} else {
102-
$classes .= ' '.implode(' ', $compound['class']);
103-
}
104-
}
122+
private function resolveCompoundVariant(array $compound, array $recipes): array
123+
{
124+
foreach ($compound as $compoundName => $compoundValues) {
125+
if ('class' === $compoundName) {
126+
continue;
105127
}
106-
}
107-
108-
if (null !== $this->defaultVariants) {
109-
foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
110-
if (!isset($recipes[$defaultVariantName])) {
111-
$classes .= ' '.$this->variants[$defaultVariantName][$defaultVariantValue];
112-
}
128+
if (!isset($recipes[$compoundName]) || !\in_array($recipes[$compoundName], (array) $compoundValues)) {
129+
return [];
113130
}
114131
}
115132

116-
return trim($classes);
133+
return (array) ($compound['class'] ?? []);
117134
}
118135
}

src/TwigComponent/src/Twig/ComponentExtension.php

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,25 +87,29 @@ public function finishEmbeddedComponentRender(): void
8787
}
8888

8989
/**
90-
* @param array{
91-
* base: string|list<string|null>|null,
92-
* variants: array<string, array<string, string|array<string>>>,
93-
* compoundVariants: array<array<string, string|array<string>>>,
94-
* defaultVariants: array<string, string>
95-
* } $cva
90+
* Create a CVA instance.
9691
*
9792
* base some base class you want to have in every matching recipes
9893
* variants your recipes class
9994
* compoundVariants compounds allow you to add extra class when multiple variation are matching in the same time
10095
* defaultVariants allow you to add a default class when no recipe is matching
96+
*
97+
* @see https://symfony.com/bundles/ux-twig-component/current/index.html#component-with-complex-variants-cva
98+
*
99+
* @param array{
100+
* base: string|string[]|null,
101+
* variants: array<string, array<string, string|string[]>>,
102+
* compoundVariants: list<array<string, string|string[]>>,
103+
* defaultVariants: array<string, string>,
104+
* } $cva
101105
*/
102106
public function cva(array $cva): CVA
103107
{
104108
return new CVA(
105-
$cva['base'] ?? null,
106-
$cva['variants'] ?? null,
107-
$cva['compoundVariants'] ?? null,
108-
$cva['defaultVariants'] ?? null,
109+
$cva['base'] ?? '',
110+
$cva['variants'] ?? [],
111+
$cva['compoundVariants'] ?? [],
112+
$cva['defaultVariants'] ?? [],
109113
);
110114
}
111115

src/TwigComponent/tests/Unit/CVATest.php

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ class CVATest extends TestCase
2424
*/
2525
public function testRecipes(array $recipe, array $recipes, string $expected): void
2626
{
27-
$recipeClass = new CVA($recipe['base'] ?? '', $recipe['variants'] ?? [], $recipe['compounds'] ?? [], $recipe['defaultVariants'] ?? []);
27+
$recipeClass = new CVA(
28+
base: $recipe['base'] ?? '',
29+
variants: (array) ($recipe['variants'] ?? []),
30+
compoundVariants: (array) ($recipe['compounds'] ?? []),
31+
defaultVariants: (array) ($recipe['defaultVariants'] ?? []),
32+
);
2833

29-
$this->assertEquals($expected, $recipeClass->resolve($recipes));
34+
$this->assertEquals($expected, $recipeClass->apply($recipes));
3035
}
3136

3237
public function testApply(): void
@@ -223,12 +228,12 @@ public static function recipeProvider(): iterable
223228
[
224229
'colors' => 'primary',
225230
'sizes' => ['sm'],
226-
'class' => 'text-red-500',
231+
'class' => 'text-red-100',
227232
],
228233
],
229234
],
230235
['colors' => 'primary', 'sizes' => 'sm'],
231-
'font-semibold border rounded text-primary text-sm text-red-500',
236+
'font-semibold border rounded text-primary text-sm text-red-100',
232237
];
233238

234239
yield 'compound variants as array' => [
@@ -249,12 +254,12 @@ public static function recipeProvider(): iterable
249254
[
250255
'colors' => ['primary'],
251256
'sizes' => ['sm'],
252-
'class' => ['text-red-500', 'bold'],
257+
'class' => ['text-red-900', 'bold'],
253258
],
254259
],
255260
],
256261
['colors' => 'primary', 'sizes' => 'sm'],
257-
'font-semibold border rounded text-primary text-sm text-red-500 bold',
262+
'font-semibold border rounded text-primary text-sm text-red-900 bold',
258263
];
259264

260265
yield 'multiple compound variants' => [
@@ -275,17 +280,17 @@ public static function recipeProvider(): iterable
275280
[
276281
'colors' => ['primary'],
277282
'sizes' => ['sm'],
278-
'class' => 'text-red-500',
283+
'class' => 'text-red-300',
279284
],
280285
[
281286
'colors' => ['primary'],
282287
'sizes' => ['md'],
283-
'class' => 'text-blue-500',
288+
'class' => 'text-blue-300',
284289
],
285290
],
286291
],
287292
['colors' => 'primary', 'sizes' => 'sm'],
288-
'font-semibold border rounded text-primary text-sm text-red-500',
293+
'font-semibold border rounded text-primary text-sm text-red-300',
289294
];
290295

291296
yield 'compound with multiple variants' => [
@@ -306,12 +311,12 @@ public static function recipeProvider(): iterable
306311
[
307312
'colors' => ['primary', 'secondary'],
308313
'sizes' => ['sm'],
309-
'class' => 'text-red-500',
314+
'class' => 'text-red-800',
310315
],
311316
],
312317
],
313318
['colors' => 'primary', 'sizes' => 'sm'],
314-
'font-semibold border rounded text-primary text-sm text-red-500',
319+
'font-semibold border rounded text-primary text-sm text-red-800',
315320
];
316321

317322
yield 'compound doesn\'t match' => [
@@ -445,4 +450,46 @@ public static function recipeProvider(): iterable
445450
'font-semibold border rounded text-primary text-sm rounded-md',
446451
];
447452
}
453+
454+
/**
455+
* @dataProvider provideAdditionalClassesCases
456+
*/
457+
public function testAdditionalClasses(string|array $base, array|string $additionals, string $expected): void
458+
{
459+
$cva = new CVA($base);
460+
if ([] === $additionals || '' === $additionals) {
461+
$this->assertEquals($expected, $cva->apply([]));
462+
} else {
463+
$this->assertEquals($expected, $cva->apply([], ...(array) $additionals));
464+
}
465+
}
466+
467+
public static function provideAdditionalClassesCases(): iterable
468+
{
469+
yield 'additionals_are_optional' => [
470+
'',
471+
'foo',
472+
'foo',
473+
];
474+
yield 'additional_are_used' => [
475+
'',
476+
'foo',
477+
'foo',
478+
];
479+
yield 'additionals_are_used' => [
480+
'',
481+
['foo', 'bar'],
482+
'foo bar',
483+
];
484+
yield 'additionals_preserve_order' => [
485+
['foo'],
486+
['bar', 'foo'],
487+
'foo bar',
488+
];
489+
yield 'additional_are_deduplicated' => [
490+
'',
491+
['bar', 'bar'],
492+
'bar',
493+
];
494+
}
448495
}

0 commit comments

Comments
 (0)