Skip to content

Commit

Permalink
Introduce CVA to style TwigComponent
Browse files Browse the repository at this point in the history
  • Loading branch information
WebMamba authored and weaverryan committed Feb 29, 2024
1 parent 2265069 commit afd3b74
Show file tree
Hide file tree
Showing 8 changed files with 703 additions and 1 deletion.
2 changes: 2 additions & 0 deletions src/TwigComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
- Add the ability to render specific attributes from the `attributes` variable #1442
- Restrict Twig 3.9 for now #1486
- Build reproducible TemplateMap to fix possible post-deploy breakage #1497
- Add CVA (Class variant authority) integration #1416

## 2.14.0

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

## 2.13.0

Expand Down
192 changes: 192 additions & 0 deletions src/TwigComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,198 @@ Exclude specific attributes:
My Component!
</div>

Component with Complex Variants (CVA)
-------------------------------------

CVA (Class Variant Authority) is a concept from the JS world (https://cva.style/docs/getting-started/variants).
It's a concept used by the famous shadcn/ui library (https://ui.shadcn.com).
CVA allows you to display a component with different variants (color, size, etc.),
to create highly reusable and customizable components.
You can use the cva function to define variants for your component.
The cva function take as argument an array key-value pairs.
The base key allow you define a set of classes commune to all variants.
In the variants key you define the different variants of your component.

.. code-block:: html+twig

{# templates/components/Alert.html.twig #}
{% props color = 'blue', size = 'md' %}

{% set alert = cva({
base: 'alert ',
variants: {
color: {
blue: 'bg-blue',
red: 'bg-red',
green: 'bg-green',
},
size: {
sm: 'text-sm',
md: 'text-md',
lg: 'text-lg',
}
}
}) %}

<div class="{{ alert.apply({color, size}, attributes.render('class')) }}">
{% block content %}{% endblock %}
</div>


{# index.html.twig #}

<twig:Alert color="red" size="lg">
<div>My content</div>
</twig:Alert>
// class="alert bg-red text-lg"

<twig:Alert color="green" size="sm">
<div>My content</div>
</twig:Alert>
// class="alert bg-green text-sm"

<twig:Alert class="flex items-center justify-center">
<div>My content</div>
</twig:Alert>
// class="alert bg-blue text-md flex items-center justify-center"

CVA and Tailwind CSS
~~~~~~~~~~~~~~~~~~~~

CVA work perfectly with tailwindcss. The only drawback is you can have class conflicts,
to have a better control you can use this following bundle (
https://github.com/tales-from-a-dev/twig-tailwind-extra
) in addition to the cva function:

.. code-block:: terminal
$ composer require tales-from-a-dev/twig-tailwind-extra
.. code-block:: html+twig

{# templates/components/Alert.html.twig #}
{% props color = 'blue', size = 'md' %}

{% set alert = cva({
base: 'alert ',
variants: {
color: {
blue: 'bg-blue',
red: 'bg-red',
green: 'bg-green',
},
size: {
sm: 'text-sm',
md: 'text-md',
lg: 'text-lg',
}
}
}) %}

<div class="{{ alert.apply({color, size}, attributes.render('class')) | tailwind_merge }}">
{% block content %}{% endblock %}
</div>

Compounds variants
~~~~~~~~~~~~~~~~~~

You can define compound variants. A compound variant is a variants that apply
when multiple other variant conditions are met.

.. code-block:: html+twig

{# templates/components/Alert.html.twig #}
{% props color = 'blue', size = 'md' %}

{% set alert = cva({
base: 'alert ',
variants: {
color: {
blue: 'bg-blue',
red: 'bg-red',
green: 'bg-green',
},
size: {
sm: 'text-sm',
md: 'text-md',
lg: 'text-lg',
}
},
compound: {
colors: ['red'],
size: ['md', 'lg'],
class: 'font-bold'
}
}) %}

<div class="{{ alert.apply({color, size}) }}">
{% block content %}{% endblock %}
</div>

{# index.html.twig #}

<twig:Alert color="red" size="lg">
<div>My content</div>
</twig:Alert>
// class="alert bg-red text-lg font-bold"

<twig:Alert color="green" size="sm">
<div>My content</div>
</twig:Alert>
// class="alert bg-green text-sm"

<twig:Alert color="red" size="md">
<div>My content</div>
</twig:Alert>
// class="alert bg-green text-lg font-bold"

Default variants
~~~~~~~~~~~~~~~~

You can define defaults variants, so if no variants are matching you
can still defined a default set of class to apply.

.. code-block:: html+twig

{# templates/components/Alert.html.twig #}
{% props color = 'blue', size = 'md' %}

{% set alert = 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',
}
},
defaultsVariants: {
rounded: 'rounded-md',
}
}) %}

<div class="{{ alert.apply({color, size}) }}">
{% block content %}{% endblock %}
</div>

{# index.html.twig #}

<twig:Alert color="red" size="lg">
<div>My content</div>
</twig:Alert>
// class="alert bg-red text-lg font-bold rounded-md"


Test Helpers
------------

Expand Down
102 changes: 102 additions & 0 deletions src/TwigComponent/src/CVA.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\TwigComponent;

/**
* @author Mathéo Daninos <matheo.daninos@gmail.com>
*
* CVA (class variant authority), is a concept from the js world.
* https://cva.style/docs
* The UI library shadcn is build on top of this principle
* https://ui.shadcn.com
* The concept behind CVA is to let you build component with a lot of different variations called recipes.
*
* @experimental
*/
final class CVA
{
/**
* @var string|list<string|null>|null
* @var array<string, array<string, string>>|null the array should have the following format [variantCategory => [variantName => classes]]
* ex: ['colors' => ['primary' => 'bleu-8000', 'danger' => 'red-800 text-bold'], 'size' => [...]]
* @var array<array<string, string[]>>|null the array should have the following format ['variantsCategory' => ['variantName', 'variantName'], 'class' => 'text-red-500']
* @var array<string, string>|null
*/
public function __construct(
private string|array|null $base = null,
private ?array $variants = null,
private ?array $compoundVariants = null,
private ?array $defaultVariants = null,
) {
}

public function apply(array $recipes, string ...$classes): string
{
return trim($this->resolve($recipes).' '.implode(' ', $classes));
}

public function resolve(array $recipes): string
{
if (\is_array($this->base)) {
$classes = implode(' ', $this->base);
} else {
$classes = $this->base ?? '';
}

foreach ($recipes as $recipeName => $recipeValue) {
if (!isset($this->variants[$recipeName][$recipeValue])) {
continue;
}

$classes .= ' '.$this->variants[$recipeName][$recipeValue];
}

if (null !== $this->compoundVariants) {
foreach ($this->compoundVariants as $compound) {
$isCompound = true;
foreach ($compound as $compoundName => $compoundValues) {
if ('class' === $compoundName) {
continue;
}

if (!isset($recipes[$compoundName])) {
$isCompound = false;
break;
}

if (!\in_array($recipes[$compoundName], $compoundValues)) {
$isCompound = false;
break;
}
}

if ($isCompound) {
if (!isset($compound['class']) || !\is_string($compound['class'])) {
throw new \LogicException('A compound recipe matched but no classes are registered for this match');
}

$classes .= ' '.$compound['class'];
}
}
}

if (null !== $this->defaultVariants) {
foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
if (!isset($recipes[$defaultVariantName])) {
$classes .= ' '.$this->variants[$defaultVariantName][$defaultVariantValue];
}
}
}

return trim($classes);
}
}
25 changes: 25 additions & 0 deletions src/TwigComponent/src/Twig/ComponentExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Psr\Container\ContainerInterface;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\UX\TwigComponent\ComponentRenderer;
use Symfony\UX\TwigComponent\CVA;
use Symfony\UX\TwigComponent\Event\PreRenderEvent;
use Twig\Error\RuntimeError;
use Twig\Extension\AbstractExtension;
Expand Down Expand Up @@ -41,6 +42,7 @@ public function getFunctions(): array
{
return [
new TwigFunction('component', [$this, 'render'], ['is_safe' => ['all']]),
new TwigFunction('cva', [$this, 'cva']),
];
}

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

/**
* @param array{
* base: string|string[]|null,
* variants: array<string, array<string, string>>,
* compoundVariants: array<array<string, string>>,
* defaultVariants: array<string, string>
* } $cva
*
* base some base class you want to have in every matching recipes
* variants your recipes class
* compoundVariants compounds allow you to add extra class when multiple variation are matching in the same time
* defaultVariants allow you to add a default class when no recipe is matching
*/
public function cva(array $cva): CVA
{
return new CVA(
$cva['base'] ?? null,
$cva['variants'] ?? null,
$cva['compoundVariants'] ?? null,
$cva['defaultVariants'] ?? null,
);
}

private function throwRuntimeError(string $name, \Throwable $e): void
{
// if it's already a Twig RuntimeError, just rethrow it
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<twig:Alert color='red' size='lg' class='dark:bg-gray-600'/>

0 comments on commit afd3b74

Please sign in to comment.