From e3ac14e1c8abf1fa981934c667f5565d37e59653 Mon Sep 17 00:00:00 2001 From: Matheo Daninos Date: Sat, 9 Mar 2024 17:07:31 +0100 Subject: [PATCH] Introduce CVA to html-extra --- doc/functions/html_cva.rst | 185 +++++ extra/html-extra/Cva.php | 129 ++++ extra/html-extra/HtmlExtension.php | 14 + extra/html-extra/README.md | 3 + extra/html-extra/Tests/CvaTest.php | 668 ++++++++++++++++++ extra/html-extra/Tests/Fixtures/html_cva.test | 37 + .../Fixtures/html_cva_pass_to_template.test | 19 + 7 files changed, 1055 insertions(+) create mode 100644 doc/functions/html_cva.rst create mode 100644 extra/html-extra/Cva.php create mode 100644 extra/html-extra/Tests/CvaTest.php create mode 100644 extra/html-extra/Tests/Fixtures/html_cva.test create mode 100644 extra/html-extra/Tests/Fixtures/html_cva_pass_to_template.test 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