Skip to content

Commit 9df11d3

Browse files
committed
wip
1 parent 4a25b49 commit 9df11d3

File tree

2 files changed

+161
-80
lines changed

2 files changed

+161
-80
lines changed

src/TwigComponent/src/CVA.php

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

1414
/**
15-
* @author Mathéo Daninos <[email protected]>
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.
1619
*
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.
20+
* @see https://cva.style/docs
21+
*
22+
* @doc https://symfony.com/bundles/ux-twig-component/current/index.html
23+
*
24+
* @author Mathéo Daninos <[email protected]>
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>
32+
*/
33+
private readonly array $base;
34+
35+
/**
36+
* @param string|list<string> $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+
if (\is_string($base)) {
89+
$base = preg_split('#\s+#', $base) ?: [];
90+
}
91+
$this->base = $base;
4092
}
4193

42-
public function apply(array $recipes, ?string ...$classes): string
94+
public function resolve(array $recipes): string
4395
{
44-
return trim($this->resolve($recipes).' '.implode(' ', array_filter($classes)));
96+
trigger_deprecation('symfony/ux-twig-component', '2.17', 'The method "%s()" is deprecated and will be remove in 3.0. Use "apply()" instead.', __METHOD__);
97+
98+
return $this->apply($recipes);
4599
}
46100

47-
public function resolve(array $recipes): string
101+
public function apply(array $recipes, string ...$additionalClasses): string
48102
{
49-
if (\is_array($this->base)) {
50-
$classes = implode(' ', $this->base);
51-
} else {
52-
$classes = $this->base ?? '';
53-
}
103+
$classes = [...$this->base, ...$additionalClasses];
54104

55105
foreach ($recipes as $recipeName => $recipeValue) {
56-
if (!isset($this->variants[$recipeName][$recipeValue])) {
57-
continue;
106+
if (isset($this->variants[$recipeName][$recipeValue])) {
107+
$classes = [...$classes, ...(array) $this->variants[$recipeName][$recipeValue]];
58108
}
59-
60-
if (\is_string($this->variants[$recipeName][$recipeValue])) {
61-
$classes .= ' '.$this->variants[$recipeName][$recipeValue];
62-
} else {
63-
$classes .= ' '.implode(' ', $this->variants[$recipeName][$recipeValue]);
109+
}
110+
foreach ($this->compoundVariants as $compound) {
111+
if ($compoundClasses = $this->resolveCompoundVariant($compound, $recipes)) {
112+
$classes = [...$classes, ...$compoundClasses];
64113
}
65114
}
66-
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-
}
89-
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-
}
98-
99-
if (\is_string($compound['class'])) {
100-
$classes .= ' '.$compound['class'];
101-
} else {
102-
$classes .= ' '.implode(' ', $compound['class']);
103-
}
104-
}
115+
foreach ($this->defaultVariants as $variantName => $variantValue) {
116+
if (!isset($recipes[$variantName]) && isset($this->variants[$variantName][$variantValue])) {
117+
$classes = [...$classes, ...(array) $this->variants[$variantName][$variantValue]];
105118
}
106119
}
107120

108-
if (null !== $this->defaultVariants) {
109-
foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
110-
if (!isset($recipes[$defaultVariantName])) {
111-
$classes .= ' '.$this->variants[$defaultVariantName][$defaultVariantValue];
112-
}
121+
return implode(' ', array_unique(array_map('trim', $classes)));
122+
}
123+
124+
private function resolveCompoundVariant(array $compound, array $recipes): ?array
125+
{
126+
foreach ($compound as $compoundName => $compoundValues) {
127+
if ('class' === $compoundName) {
128+
continue;
129+
}
130+
if (!isset($recipes[$compoundName]) || !\in_array($recipes[$compoundName], (array) $compoundValues)) {
131+
return null; // Early exit if the recipe does not match the compound variant
113132
}
114133
}
115134

116-
return trim($classes);
135+
// Return compound class as array, ensuring it's not null and is always treated as an array
136+
return (array) ($compound['class'] ?? []);
117137
}
118138
}

src/TwigComponent/tests/Unit/CVATest.php

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,73 @@
2020
class CVATest extends TestCase
2121
{
2222
/**
23-
* @dataProvider recipeProvider
23+
* @dataProvider provideRecipesCases
2424
*/
25-
public function testRecipes(array $recipe, array $recipes, string $expected): void
25+
public function testRecipes(array $variants, array $recipes, string $expected): void
2626
{
27-
$recipeClass = new CVA($recipe['base'] ?? '', $recipe['variants'] ?? [], $recipe['compounds'] ?? [], $recipe['defaultVariants'] ?? []);
27+
$cva = new CVA('ux-cva ', ...array_values($variants));
28+
$this->assertEquals('ux-cva '.$expected, $cva->apply($recipes));
29+
}
2830

29-
$this->assertEquals($expected, $recipeClass->resolve($recipes));
31+
public static function provideRecipesCases(): iterable
32+
{
33+
yield 'reco' => [
34+
[
35+
'csolors' => [
36+
'primary' => 'text-primary',
37+
'secondary' => 'text-secondary',
38+
],
39+
'sizes' => [
40+
'sm' => 'text-sm',
41+
'md' => 'text-md',
42+
'lg' => 'text-lg',
43+
],
44+
],
45+
['colors' => 'primary', 'sizes' => 'sm'],
46+
'text-primary text-sm',
47+
];
48+
}
49+
50+
/**
51+
* @dataProvider provideAdditionalClassesCases
52+
*/
53+
public function testAdditionalClasses(string|array $base, array|string $additionals, string $expected): void
54+
{
55+
$cva = new CVA($base);
56+
if ([] === $additionals || '' === $additionals) {
57+
$this->assertEquals($expected, $cva->apply([]));
58+
} else {
59+
$this->assertEquals($expected, $cva->apply([], ...(array) $additionals));
60+
}
61+
}
62+
63+
public static function provideAdditionalClassesCases(): iterable
64+
{
65+
yield 'additionals_are_optional' => [
66+
'',
67+
'foo',
68+
'foo',
69+
];
70+
yield 'additional_are_used' => [
71+
'',
72+
'foo',
73+
'foo',
74+
];
75+
yield 'additionals_are_used' => [
76+
'',
77+
['foo', 'bar'],
78+
'foo bar',
79+
];
80+
yield 'additionals_preserve_order' => [
81+
['foo'],
82+
['bar', 'foo'],
83+
'foo bar',
84+
];
85+
yield 'additional_are_deduplicated' => [
86+
'',
87+
['bar', 'bar'],
88+
'bar',
89+
];
3090
}
3191

3292
public function testApply(): void
@@ -106,7 +166,8 @@ public static function recipeProvider(): iterable
106166
'md' => 'text-md',
107167
'lg' => 'text-lg',
108168
],
109-
]],
169+
],
170+
],
110171
['colors' => 'primary', 'sizes' => 'sm'],
111172
'text-primary text-sm',
112173
];

0 commit comments

Comments
 (0)