diff --git a/doc/functions/html_cva.rst b/doc/functions/html_cva.rst
new file mode 100644
index 000000000..70226e77b
--- /dev/null
+++ b/doc/functions/html_cva.rst
@@ -0,0 +1,185 @@
+``html_cva``
+============
+
+`CVA (Class Variant Authority)`_ is a concept from the JavaScript world and used
+by the well-known `shadcn/ui`_ library.
+The CVA concept is used to render multiple variations of components, applying
+a set of conditions and recipes to dynamically compose CSS class strings (color, size, etc.),
+to create highly reusable and customizable templates.
+
+The concept of CVA is powered by a ``html_cva()`` Twig
+function where you define ``base`` classes that should always be present and then different
+``variants`` and the corresponding classes:
+
+.. code-block:: html+twig
+
+ {# templates/alert.html.twig #}
+ {% set alert = html_cva(
+ base='alert ',
+ variants={
+ color: {
+ blue: 'bg-blue',
+ red: 'bg-red',
+ green: 'bg-green',
+ },
+ size: {
+ sm: 'text-sm',
+ md: 'text-md',
+ lg: 'text-lg',
+ }
+ }
+ ) %}
+
+
+ ...
+
+
+Then use the ``color`` and ``size`` variants to select the needed classes:
+
+.. code-block:: twig
+
+ {# index.html.twig #}
+ {{ include('alert.html.twig', {'color': 'blue', 'size': 'md'}) }}
+ // class="alert bg-red text-lg"
+
+ {{ include('alert.html.twig', {'color': 'green', 'size': 'sm'}) }}
+ // class="alert bg-green text-sm"
+
+ {{ include('alert.html.twig', {'color': 'red', 'class': 'flex items-center justify-center'}) }}
+ // class="alert bg-red text-md flex items-center justify-center"
+
+CVA and Tailwind CSS
+--------------------
+
+CVA work perfectly with Tailwind CSS. The only drawback is that you can have class conflicts.
+To "merge" conflicting classes together and keep only the ones you need, use the
+``tailwind_merge()`` filter from `tales-from-a-dev/twig-tailwind-extra`_
+with the ``html_cva()`` function:
+
+.. code-block:: terminal
+
+ $ composer require tales-from-a-dev/twig-tailwind-extra
+
+.. code-block:: html+twig
+
+ {% set alert = html_cva(
+ // ...
+ ) %}
+
+
+ ...
+
+
+Compound Variants
+-----------------
+
+You can define compound variants. A compound variant is a variant that applies
+when multiple other variant conditions are met:
+
+.. code-block:: html+twig
+
+ {% set alert = html_cva(
+ base='alert',
+ variants={
+ color: {
+ blue: 'bg-blue',
+ red: 'bg-red',
+ green: 'bg-green',
+ },
+ size: {
+ sm: 'text-sm',
+ md: 'text-md',
+ lg: 'text-lg',
+ }
+ },
+ compoundVariants=[{
+ // if color = red AND size = (md or lg), add the `font-bold` class
+ color: ['red'],
+ size: ['md', 'lg'],
+ class: 'font-bold'
+ }]
+ ) %}
+
+
+ ...
+
+
+ {# index.html.twig #}
+
+ {{ include('alert.html.twig', {color: 'red', size: 'lg'}) }}
+ // class="alert bg-red text-lg font-bold"
+
+ {{ include('alert.html.twig', {color: 'green', size: 'sm'}) }}
+ // class="alert bg-green text-sm"
+
+ {{ include('alert.html.twig', {color: 'red', size: 'md'}) }}
+ // class="alert bg-green text-lg font-bold"
+
+Default Variants
+----------------
+
+If no variants match, you can define a default set of classes to apply:
+
+.. code-block:: html+twig
+
+ {% set alert = html_cva(
+ base='alert ',
+ variants={
+ color: {
+ blue: 'bg-blue',
+ red: 'bg-red',
+ green: 'bg-green',
+ },
+ size: {
+ sm: 'text-sm',
+ md: 'text-md',
+ lg: 'text-lg',
+ },
+ rounded: {
+ sm: 'rounded-sm',
+ md: 'rounded-md',
+ lg: 'rounded-lg',
+ }
+ },
+ defaultVariants={
+ rounded: 'md',
+ }
+ ) %}
+
+
+ ...
+
+
+ {# index.html.twig #}
+
+ {{ include('alert.html.twig', {color: 'red', size: 'lg'}) }}
+ // class="alert bg-red text-lg font-bold rounded-md"
+
+.. note::
+
+ The ``html_cva`` function is part of the ``HtmlExtension`` which is not
+ installed by default. Install it first:
+
+ .. code-block:: bash
+
+ $ composer require twig/html-extra
+
+ Then, on Symfony projects, install the ``twig/extra-bundle``:
+
+ .. code-block:: bash
+
+ $ composer require twig/extra-bundle
+
+ Otherwise, add the extension explicitly on the Twig environment::
+
+ use Twig\Extra\Html\HtmlExtension;
+
+ $twig = new \Twig\Environment(...);
+ $twig->addExtension(new HtmlExtension());
+
+This function works best when used with `TwigComponent`_.
+
+.. _`CVA (Class Variant Authority)`: https://cva.style/docs/getting-started/variants
+.. _`shadcn/ui`: https://ui.shadcn.com
+.. _`tales-from-a-dev/twig-tailwind-extra`: https://github.com/tales-from-a-dev/twig-tailwind-extra
+.. _`TwigComponent`: https://symfony.com/bundles/ux-twig-component/current/index.html
\ No newline at end of file
diff --git a/extra/html-extra/Cva.php b/extra/html-extra/Cva.php
new file mode 100644
index 000000000..a77d7dfdf
--- /dev/null
+++ b/extra/html-extra/Cva.php
@@ -0,0 +1,129 @@
+
+ */
+final class Cva
+{
+ /**
+ * @var list
+ */
+ private array $base;
+
+ /**
+ * @param string|list $base The base classes to apply to the component
+ */
+ public function __construct(
+ string|array $base = [],
+ /**
+ * The variants to apply based on recipes.
+ *
+ * Format: [variantCategory => [variantName => classes]]
+ *
+ * Example:
+ * 'colors' => [
+ * 'primary' => 'bleu-8000',
+ * 'danger' => 'red-800 text-bold',
+ * ],
+ * 'size' => [...],
+ *
+ * @var array>>
+ */
+ private array $variants = [],
+
+ /**
+ * The compound variants to apply based on recipes.
+ *
+ * Format: [variantsCategory => ['variantName', 'variantName'], class: classes]
+ *
+ * Example:
+ * [
+ * 'colors' => ['primary'],
+ * 'size' => ['small'],
+ * 'class' => 'text-red-500',
+ * ],
+ * [
+ * 'size' => ['large'],
+ * 'class' => 'font-weight-500',
+ * ]
+ *
+ * @var array>>
+ */
+ private array $compoundVariants = [],
+
+ /**
+ * The default variants to apply if specific recipes aren't provided.
+ *
+ * Format: [variantCategory => variantName]
+ *
+ * Example:
+ * 'colors' => 'primary',
+ *
+ * @var array
+ */
+ private array $defaultVariants = [],
+ ) {
+ $this->base = (array) $base;
+ }
+
+ public function apply(array $recipes, ?string ...$additionalClasses): string
+ {
+ $classes = $this->base;
+
+ // Resolve recipes against variants
+ foreach ($recipes as $recipeName => $recipeValue) {
+ if (\is_bool($recipeValue)) {
+ $recipeValue = $recipeValue ? 'true' : 'false';
+ }
+ $recipeClasses = $this->variants[$recipeName][$recipeValue] ?? [];
+ $classes = [...$classes, ...(array) $recipeClasses];
+ }
+
+ // Resolve compound variants
+ foreach ($this->compoundVariants as $compound) {
+ $compoundClasses = $this->resolveCompoundVariant($compound, $recipes) ?? [];
+ $classes = [...$classes, ...$compoundClasses];
+ }
+
+ // Apply default variants if specific recipes aren't provided
+ foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
+ if (!isset($recipes[$defaultVariantName])) {
+ $variantClasses = $this->variants[$defaultVariantName][$defaultVariantValue] ?? [];
+ $classes = [...$classes, ...(array) $variantClasses];
+ }
+ }
+ $classes = [...$classes, ...array_values($additionalClasses)];
+
+ $classes = implode(' ', array_filter($classes, 'is_string'));
+ $classes = preg_split('#\s+#', $classes, -1, \PREG_SPLIT_NO_EMPTY) ?: [];
+
+ return implode(' ', array_unique($classes));
+ }
+
+ private function resolveCompoundVariant(array $compound, array $recipes): array
+ {
+ foreach ($compound as $compoundName => $compoundValues) {
+ if ('class' === $compoundName) {
+ continue;
+ }
+ if (!isset($recipes[$compoundName]) || !\in_array($recipes[$compoundName], (array) $compoundValues)) {
+ return [];
+ }
+ }
+
+ return (array) ($compound['class'] ?? []);
+ }
+}
diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php
index 83fcc58b1..06329977f 100644
--- a/extra/html-extra/HtmlExtension.php
+++ b/extra/html-extra/HtmlExtension.php
@@ -37,6 +37,7 @@ final class HtmlExtension extends AbstractExtension
{
return [
new TwigFunction('html_classes', [self::class, 'htmlClasses']),
+ new TwigFunction('html_cva', [self::class, 'htmlCva']),
];
}
@@ -110,4 +111,17 @@ final class HtmlExtension extends AbstractExtension
return implode(' ', array_unique(array_filter($classes, static function ($v) { return '' !== $v; })));
}
+
+ /**
+ * @param string|list $base
+ * @param array> $variants
+ * @param array>> $compoundVariants
+ * @param array $defaultVariant
+ *
+ * @internal
+ */
+ public static function htmlCva(array|string $base = [], array $variants = [], array $compoundVariants = [], array $defaultVariant = []): Cva
+ {
+ return new Cva($base, $variants, $compoundVariants, $defaultVariant);
+ }
}
diff --git a/extra/html-extra/README.md b/extra/html-extra/README.md
index e2c46b08e..9cd51fe28 100644
--- a/extra/html-extra/README.md
+++ b/extra/html-extra/README.md
@@ -9,5 +9,8 @@ This package is a Twig extension that provides the following:
* [`html_classes`][2] function: returns a string by conditionally joining class
names together.
+ * [`html_cva`][3] function: returns a `Cva` object to handle class variants.
+
[1]: https://twig.symfony.com/data_uri
[2]: https://twig.symfony.com/html_classes
+[3]: https://twig.symfony.com/html_cva
diff --git a/extra/html-extra/Tests/CvaTest.php b/extra/html-extra/Tests/CvaTest.php
new file mode 100644
index 000000000..b8b32fbef
--- /dev/null
+++ b/extra/html-extra/Tests/CvaTest.php
@@ -0,0 +1,668 @@
+assertEquals($expected, $recipeClass->apply($recipes));
+ }
+
+ public function testApply()
+ {
+ $recipe = new Cva('font-semibold border rounded', [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ], [
+ [
+ 'colors' => ['primary'],
+ 'sizes' => ['sm'],
+ 'class' => 'text-red-500',
+ ],
+ ]);
+
+ $this->assertEquals('font-semibold border rounded text-primary text-sm text-red-500', $recipe->apply(['colors' => 'primary', 'sizes' => 'sm']));
+ }
+
+ public function testApplyWithNullString()
+ {
+ $recipe = new Cva('font-semibold border rounded', [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ], [
+ [
+ 'colors' => ['primary'],
+ 'sizes' => ['sm'],
+ 'class' => 'text-red-500',
+ ],
+ ]);
+
+ $this->assertEquals('font-semibold border rounded text-primary text-sm text-red-500 flex justify-center', $recipe->apply(['colors' => 'primary', 'sizes' => 'sm'], 'flex', null, 'justify-center'));
+ }
+
+ public static function recipeProvider(): iterable
+ {
+ yield 'base null' => [
+ ['variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ]],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'text-primary text-sm',
+ ];
+
+ yield 'base empty' => [
+ [
+ 'base' => '',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ]],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'text-primary text-sm',
+ ];
+
+ yield 'base array' => [
+ [
+ 'base' => ['font-semibold', 'border', 'rounded'],
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ]],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'font-semibold border rounded text-primary text-sm',
+ ];
+
+ yield 'no recipes match' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ],
+ ],
+ ['colors' => 'red', 'sizes' => 'test'],
+ 'font-semibold border rounded',
+ ];
+
+ yield 'simple variants' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'font-semibold border rounded text-primary text-sm',
+ ];
+
+ yield 'simple variants as array' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => ['text-primary', 'uppercase'],
+ 'secondary' => ['text-secondary', 'uppercase'],
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'font-semibold border rounded text-primary uppercase text-sm',
+ ];
+
+ yield 'simple variants with custom' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ],
+ ],
+ ['colors' => 'secondary', 'sizes' => 'md'],
+ 'font-semibold border rounded text-secondary text-md',
+ ];
+
+ yield 'compound variants' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ],
+ 'compounds' => [
+ [
+ 'colors' => 'primary',
+ 'sizes' => ['sm'],
+ 'class' => 'text-red-100',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'font-semibold border rounded text-primary text-sm text-red-100',
+ ];
+
+ yield 'compound variants with true' => [
+ [
+ 'base' => 'button',
+ 'variants' => [
+ 'colors' => [
+ 'blue' => 'btn-blue',
+ 'red' => 'btn-red',
+ ],
+ 'disabled' => [
+ 'true' => 'disabled',
+ ],
+ ],
+ 'compounds' => [
+ [
+ 'colors' => 'blue',
+ 'disabled' => ['true'],
+ 'class' => 'font-bold',
+ ],
+ ],
+ ],
+ ['colors' => 'blue', 'disabled' => 'true'],
+ 'button btn-blue disabled font-bold',
+ ];
+
+ yield 'compound variants as array' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ],
+ 'compounds' => [
+ [
+ 'colors' => ['primary'],
+ 'sizes' => ['sm'],
+ 'class' => ['text-red-900', 'bold'],
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'font-semibold border rounded text-primary text-sm text-red-900 bold',
+ ];
+
+ yield 'multiple compound variants' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ],
+ 'compounds' => [
+ [
+ 'colors' => ['primary'],
+ 'sizes' => ['sm'],
+ 'class' => 'text-red-300',
+ ],
+ [
+ 'colors' => ['primary'],
+ 'sizes' => ['md'],
+ 'class' => 'text-blue-300',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'font-semibold border rounded text-primary text-sm text-red-300',
+ ];
+
+ yield 'compound with multiple variants' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ],
+ 'compounds' => [
+ [
+ 'colors' => ['primary', 'secondary'],
+ 'sizes' => ['sm'],
+ 'class' => 'text-red-800',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'font-semibold border rounded text-primary text-sm text-red-800',
+ ];
+
+ yield 'compound doesn\'t match' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ ],
+ 'compounds' => [
+ [
+ 'colors' => ['danger', 'secondary'],
+ 'sizes' => ['sm'],
+ 'class' => 'text-red-500',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'font-semibold border rounded text-primary text-sm',
+ ];
+
+ yield 'default variables' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ 'rounded' => [
+ 'sm' => 'rounded-sm',
+ 'md' => 'rounded-md',
+ 'lg' => 'rounded-lg',
+ ],
+ ],
+ 'compounds' => [
+ [
+ 'colors' => ['danger', 'secondary'],
+ 'sizes' => 'sm',
+ 'class' => 'text-red-500',
+ ],
+ ],
+ 'defaultVariants' => [
+ 'colors' => 'primary',
+ 'sizes' => 'sm',
+ 'rounded' => 'md',
+ ],
+ ],
+ ['colors' => 'primary', 'sizes' => 'sm'],
+ 'font-semibold border rounded text-primary text-sm rounded-md',
+ ];
+
+ yield 'default variables all overwrite' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ 'rounded' => [
+ 'sm' => 'rounded-sm',
+ 'md' => 'rounded-md',
+ 'lg' => 'rounded-lg',
+ ],
+ ],
+ 'compounds' => [
+ [
+ 'colors' => ['danger', 'secondary'],
+ 'sizes' => ['sm'],
+ 'class' => 'text-red-500',
+ ],
+ ],
+ 'defaultVariants' => [
+ 'colors' => 'primary',
+ 'sizes' => 'sm',
+ 'rounded' => 'md',
+ ],
+ ],
+ ['colors' => 'primary', 'sizes' => 'sm', 'rounded' => 'lg'],
+ 'font-semibold border rounded text-primary text-sm rounded-lg',
+ ];
+
+ yield 'default variables without matching variants' => [
+ [
+ 'base' => 'font-semibold border rounded',
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'md' => 'text-md',
+ 'lg' => 'text-lg',
+ ],
+ 'rounded' => [
+ 'sm' => 'rounded-sm',
+ 'md' => 'rounded-md',
+ 'lg' => 'rounded-lg',
+ ],
+ ],
+ 'compounds' => [
+ [
+ 'colors' => ['danger', 'secondary'],
+ 'sizes' => ['sm'],
+ 'class' => 'text-red-500',
+ ],
+ ],
+ 'defaultVariants' => [
+ 'colors' => 'primary',
+ 'sizes' => 'sm',
+ 'rounded' => 'md',
+ ],
+ ],
+ [],
+ 'font-semibold border rounded text-primary text-sm rounded-md',
+ ];
+
+ yield 'default variables with boolean' => [
+ [
+ 'base' => 'button',
+ 'variants' => [
+ 'colors' => [
+ 'blue' => 'btn-blue',
+ 'red' => 'btn-red',
+ ],
+ 'disabled' => [
+ 'true' => 'disabled',
+ 'false' => 'opacity-100',
+ ],
+ ],
+ 'defaultVariants' => [
+ 'colors' => 'blue',
+ 'disabled' => 'false',
+ ],
+ ],
+ [],
+ 'button btn-blue opacity-100',
+ ];
+
+ yield 'boolean string variants true / true' => [
+ [
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'disabled' => [
+ 'true' => 'disable',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'disabled' => true],
+ 'text-primary disable',
+ ];
+
+ yield 'boolean string variants true / false' => [
+ [
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'disabled' => [
+ 'true' => 'disable',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'disabled' => false],
+ 'text-primary',
+ ];
+
+ yield 'boolean string variants false / true' => [
+ [
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'disabled' => [
+ 'false' => 'disable',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'disabled' => true],
+ 'text-primary',
+ ];
+
+ yield 'boolean string variants false / false' => [
+ [
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'disabled' => [
+ 'false' => 'disable',
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'disabled' => false],
+ 'text-primary disable',
+ ];
+
+ yield 'boolean string variants missing' => [
+ [
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'disabled' => [
+ 'true' => 'disable',
+ ],
+ ],
+ ],
+ ['colors' => 'primary'],
+ 'text-primary',
+ ];
+
+ yield 'boolean list variants true' => [
+ [
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'disabled' => [
+ 'true' => ['disable', 'opacity-50'],
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'disabled' => true],
+ 'text-primary disable opacity-50',
+ ];
+
+ yield 'boolean list variants false' => [
+ [
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'disabled' => [
+ 'true' => ['disable', 'opacity-50'],
+ ],
+ ],
+ ],
+ ['colors' => 'primary', 'disabled' => false],
+ 'text-primary',
+ ];
+
+ yield 'boolean list variants missing' => [
+ [
+ 'variants' => [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary',
+ ],
+ 'disabled' => [
+ 'true' => ['disable', 'opacity-50'],
+ ],
+ ],
+ ],
+ ['colors' => 'primary'],
+ 'text-primary',
+ ];
+ }
+
+ /**
+ * @dataProvider provideAdditionalClassesCases
+ */
+ public function testAdditionalClasses(string|array $base, array|string $additionals, string $expected)
+ {
+ $cva = new Cva($base);
+ if (!$additionals) {
+ $this->assertEquals($expected, $cva->apply([]));
+ } else {
+ $this->assertEquals($expected, $cva->apply([], ...(array) $additionals));
+ }
+ }
+
+ public static function provideAdditionalClassesCases(): iterable
+ {
+ yield 'additionals_are_optional' => [
+ '',
+ 'foo',
+ 'foo',
+ ];
+
+ yield 'additional_are_used' => [
+ '',
+ 'foo',
+ 'foo',
+ ];
+
+ yield 'additionals_are_used' => [
+ '',
+ ['foo', 'bar'],
+ 'foo bar',
+ ];
+
+ yield 'additionals_preserve_order' => [
+ ['foo'],
+ ['bar', 'foo'],
+ 'foo bar',
+ ];
+
+ yield 'additional_are_deduplicated' => [
+ '',
+ ['bar', 'bar'],
+ 'bar',
+ ];
+ }
+}
diff --git a/extra/html-extra/Tests/Fixtures/html_cva.test b/extra/html-extra/Tests/Fixtures/html_cva.test
new file mode 100644
index 000000000..71e16036a
--- /dev/null
+++ b/extra/html-extra/Tests/Fixtures/html_cva.test
@@ -0,0 +1,37 @@
+--TEST--
+"html_cva" function
+--TEMPLATE--
+{% set alert = html_cva(
+ ['alert'],
+ {
+ color: {
+ blue: 'alert-blue',
+ red: 'alert-red',
+ green: 'alert-green',
+ yellow: 'alert-yellow',
+ },
+ size: {
+ sm: 'alert-sm',
+ md: 'alert-md',
+ lg: 'alert-lg',
+ },
+ rounded: {
+ sm: 'rounded-sm',
+ md: 'rounded-md',
+ lg: 'rounded-lg',
+ }
+ },
+ [{
+ color: ['red'],
+ size: ['lg'],
+ class: 'font-semibold'
+ }],
+ {
+ rounded: 'md'
+ }
+) %}
+{{ alert.apply({color: 'blue', size: 'sm'}) }}
+--DATA--
+return []
+--EXPECT--
+alert alert-blue alert-sm rounded-md
diff --git a/extra/html-extra/Tests/Fixtures/html_cva_pass_to_template.test b/extra/html-extra/Tests/Fixtures/html_cva_pass_to_template.test
new file mode 100644
index 000000000..49b842fac
--- /dev/null
+++ b/extra/html-extra/Tests/Fixtures/html_cva_pass_to_template.test
@@ -0,0 +1,19 @@
+--TEST--
+pass Cva object to template
+--TEMPLATE--
+{{ alert.apply({colors: 'primary', sizes: 'sm'}) }}
+--DATA--
+return [
+ 'alert' => new Twig\Extra\Html\Cva('font-semibold border rounded', [
+ 'colors' => [
+ 'primary' => 'text-primary',
+ 'secondary' => 'text-secondary'
+ ],
+ 'sizes' => [
+ 'sm' => 'text-sm',
+ 'lg' => 'text-lg'
+ ]
+ ])
+];
+--EXPECT--
+font-semibold border rounded text-primary text-sm