Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce CVA to style TwigComponent #1416

Merged
merged 1 commit into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
WebMamba marked this conversation as resolved.
Show resolved Hide resolved

## 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
WebMamba marked this conversation as resolved.
Show resolved Hide resolved

## 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)
-------------------------------------
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need a versionadded for 2.16


CVA (Class Variant Authority) is a concept from the JS world (https://cva.style/docs/getting-started/variants).
WebMamba marked this conversation as resolved.
Show resolved Hide resolved
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.
WebMamba marked this conversation as resolved.
Show resolved Hide resolved
WebMamba marked this conversation as resolved.
Show resolved Hide resolved
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>

WebMamba marked this conversation as resolved.
Show resolved Hide resolved

{# 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">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Show a color attribute here so we show that you can pass the variants AND custom classes..

<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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tailwind CSS

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still need to fixing the casing of Tailwind in this sentence :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
CVA work perfectly with tailwindcss. The only drawback is you can have class conflicts,
CVA work perfectly with tailwindcss. The only drawback is that you can have class conflicts.

to have a better control you can use this following bundle (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
to have a better control you can use this following bundle (
To "merge" conflicting classes together and keep only the one you need, use the
``tailwind_merge()` method from [tales-from-a-dev/twig-tailwind-extra](https://github.com/tales-from-a-dev/twig-tailwind-extra)
with the ``cva()`` function:

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 ',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just put // ... inside the cva() function instead of listing base and variants again.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Compounds variants
Compounds Variants

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

*Compound Variants or Compounded Variants is probably even better?

~~~~~~~~~~~~~~~~~~

You can define compound variants. A compound variant is a variants that apply
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
You can define compound variants. A compound variant is a variants that apply
A compound variant is a variant that applies

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'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe a comment above this would help describe it - e.g.

// if colors=red AND size = (md or lg), add the `font-bold` class

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Default variants
Default Variants

~~~~~~~~~~~~~~~~

You can define defaults variants, so if no variants are matching you
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
You can define defaults variants, so if no variants are matching you
If no variants match, you can define a default set of classes to apply:

can still defined a default set of class to apply.

.. code-block:: html+twig

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need rounded = '' up here?

And if so, then does defaultVariants have limited usage? Not that we should remove it, just clarifying :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CVA can be use outside of a component this is why we may need it. What do you think ?


{% 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: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
defaultsVariants: {
defaultVariants: {

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;

/**
WebMamba marked this conversation as resolved.
Show resolved Hide resolved
* @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'];
WebMamba marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

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'/>