Skip to content

Commit 62c931e

Browse files
committed
docs(class-variance): docs for class-variance
1 parent f58be75 commit 62c931e

1 file changed

Lines changed: 345 additions & 0 deletions

File tree

Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
---
2+
title: Class Variance
3+
description: "The Class Variance package can be used standalone or with Tempest framework to provide a tailwind-variants style CSS variant system."
4+
---
5+
## Introduction
6+
7+
Class variance — a PHP package for building component class strings with variants, compound variants, slot support, and conflict-aware merging. Heavily inspired by [CVA](https://cva.style) and [Tailwind-Variants](https://www.tailwind-variants.org/), but with the ability to extend and support other frameworks also.
8+
9+
Two flavours:
10+
11+
- **`cv()`** — separator-based merging, framework-agnostic CSS, somewhat limited by default but can be extended to support other frameworks.
12+
- **`tv()`** — builds on `cv()` with Tailwind-aware merging, aiming to provide the same outcomes as [tailwind-merge](https://github.com/dcastil/tailwind-merge).
13+
14+
---
15+
16+
## Installation
17+
18+
### With Tempest
19+
20+
```bash
21+
composer require tempest/class-variance
22+
```
23+
24+
Discovery handles the rest. The `CvMergerInitializer` and `TvMergerInitializer` are Discovered automatically, binding tagged `ClassMerger` singletons for injection.
25+
26+
To customise the configuration, publish a config file:
27+
28+
```bash
29+
./tempest class-variance:publish:config
30+
```
31+
32+
This scaffolds a `class-variance.config.php` in your app's source directory that Tempest discovers automatically. You can, as with other packages, move this wherever you wish, as Discovery will handle that for you.
33+
34+
### Standalone (without Tempest)
35+
36+
```bash
37+
composer require tempest/class-variance
38+
```
39+
40+
Import the helper functions and call them directly — no container required:
41+
42+
```php
43+
use function Tempest\ClassVariance\cv;
44+
use function Tempest\ClassVariance\tv;
45+
```
46+
47+
48+
## `cv()` — Generic class variance
49+
50+
`cv()` uses a separator heuristic to resolve conflicts: everything before the first `-` is treated as the group key, and the last class in a group wins.
51+
52+
```php
53+
use function Tempest\ClassVariance\cv;
54+
55+
$button = cv(
56+
base: 'btn',
57+
variants: [
58+
'size' => [
59+
'sm' => 'btn-sm',
60+
'md' => 'btn-md',
61+
'lg' => 'btn-lg',
62+
],
63+
'color' => [
64+
'primary' => 'btn-primary',
65+
'danger' => 'btn-danger',
66+
],
67+
],
68+
defaultVariants: [
69+
'size' => 'md',
70+
],
71+
);
72+
73+
echo $button(); // 'btn btn-md'
74+
echo $button(['color' => 'primary']); // 'btn btn-md btn-primary'
75+
echo $button(['size' => 'lg', 'color' => 'danger']); // 'btn btn-lg btn-danger'
76+
```
77+
78+
### Compound variants
79+
80+
Apply extra classes only when multiple props match simultaneously:
81+
82+
```php
83+
$badge = cv(
84+
base: 'badge',
85+
variants: [
86+
'color' => ['primary' => 'badge-primary', 'danger' => 'badge-danger'],
87+
'outline' => ['true' => 'badge-outline'],
88+
],
89+
compoundVariants: [
90+
[
91+
'color' => 'danger',
92+
'outline' => 'true',
93+
'class' => 'border-danger',
94+
],
95+
],
96+
defaultVariants: ['color' => 'primary'],
97+
);
98+
99+
echo $badge(['outline' => 'true']); // 'badge badge-primary badge-outline'
100+
echo $badge(['color' => 'danger', 'outline' => 'true']); // 'badge badge-danger badge-outline border-danger'
101+
```
102+
103+
### Slots
104+
105+
Split the output across named slots — useful for multi-element components:
106+
107+
```php
108+
$card = cv(
109+
base: [
110+
'base' => 'card',
111+
'header' => 'card-header',
112+
'body' => 'card-body',
113+
],
114+
variants: [
115+
'size' => [
116+
'sm' => ['base' => 'card-sm', 'body' => 'p-2'],
117+
'lg' => ['base' => 'card-lg', 'body' => 'p-8'],
118+
],
119+
],
120+
defaultVariants: ['size' => 'sm'],
121+
);
122+
123+
echo $card(slot: 'base'); // 'card card-sm'
124+
echo $card(slot: 'header'); // 'card-header'
125+
echo $card(['size' => 'lg'], slot: 'body'); // 'card-body p-8'
126+
```
127+
128+
:::info
129+
When your base defines exactly one slot, the slot is inferred automatically, and will internally default to 'base' — you do not need to pass `slot:`.
130+
:::
131+
132+
### Passing extra classes
133+
134+
Pass `class` or `className` in the props to append caller-supplied classes (these go through the same merger):
135+
136+
```php
137+
echo $button(['color' => 'primary', 'class' => 'w-full']); // 'btn btn-md btn-primary w-full'
138+
```
139+
140+
:::info
141+
Generally speaking, standardise on using `class` - `className` is provided only as a syntax courtesy for users of other, older CVA tools which are using this, however consider best practice to use `class` going forward.
142+
:::
143+
144+
145+
## `tv()` — Tailwind-aware class variance
146+
147+
`tv()` ships with the full Tailwind CSS class group definitions from `tailwind-merge`. Conflicting utilities are deduplicated automatically — `p-2 p-4` resolves to `p-4`, `bg-red-500 bg-blue-500` resolves to `bg-blue-500`. The package implements a 'right-wins' approach which ensures that custom classes always override the default.
148+
149+
The API is identical to `cv()`:
150+
151+
```php
152+
use function Tempest\ClassVariance\tv;
153+
154+
$button = tv(
155+
base: 'inline-flex items-center rounded px-3 py-1.5 text-sm font-medium',
156+
variants: [
157+
'intent' => [
158+
'primary' => 'bg-blue-600 text-white hover:bg-blue-700',
159+
'secondary' => 'bg-gray-100 text-gray-900 hover:bg-gray-200',
160+
'danger' => 'bg-red-600 text-white hover:bg-red-700',
161+
],
162+
'size' => [
163+
'sm' => 'px-2 py-1 text-xs',
164+
'lg' => 'px-4 py-2 text-base',
165+
],
166+
],
167+
defaultVariants: ['intent' => 'primary'],
168+
);
169+
170+
echo $button(); // base + primary intent classes
171+
echo $button(['intent' => 'danger']); // px-3 wins over default, bg-red-600 replaces bg-blue-600
172+
echo $button(['size' => 'sm']); // px-2 py-1 text-xs override base padding and text size
173+
```
174+
175+
The conflict resolution means a caller can safely pass overriding classes without knowing what the component already applies:
176+
177+
```php
178+
// Caller overrides just the background — no class duplication
179+
echo $button(['intent' => 'primary', 'class' => 'bg-indigo-600']);
180+
// → '...text-white hover:bg-blue-700 bg-indigo-600' (bg-blue-600 removed)
181+
```
182+
183+
## Customising the Tailwind config
184+
185+
### Publish the config file (Tempest)
186+
187+
```bash
188+
./tempest class-variance:publish:config
189+
```
190+
191+
The published file returns a `TailwindClassVarianceConfig` object that Tempest discovers automatically. We don't currently publish a GenericConfig file, but you can use the same stub to create your own.
192+
193+
### Single callsite
194+
195+
Pass `config:` directly to override for one specific call. Everything else in your app uses the default.
196+
197+
```php
198+
$config = new TailwindClassVarianceConfig(prefix: 'tw-', extend: new Classmap(/* ... */));
199+
200+
$button = tv(base: 'tw-rounded tw-px-3', config: $config);
201+
```
202+
203+
### Whole component class
204+
205+
Inject the tagged ClassMerger singleton. It picks up your app's config automatically.
206+
207+
```php
208+
use Tempest\ClassVariance\ClassMerger;
209+
use Tempest\Container\Tag;
210+
211+
final readonly class ButtonComponent
212+
{
213+
public function __construct(
214+
#[Tag('tv')] private ClassMerger $merger, // or Tag('cv')
215+
) {}
216+
217+
public function render(string $intent = 'primary'): string
218+
{
219+
$button = tv(
220+
base: 'rounded px-3 py-1.5',
221+
variants: ['intent' => ['primary' => 'bg-blue-600', 'danger' => 'bg-red-600']],
222+
);
223+
224+
return $button(['intent' => $intent]);
225+
}
226+
}
227+
228+
final readonly class ButtonComponent
229+
{
230+
public function __construct(
231+
#[Tag('cv')] private ClassMerger $merger,
232+
) {}
233+
}
234+
```
235+
236+
### Override the default for all usages
237+
238+
Publish a config file and Tempest will discover it automatically. It is registered in the container and used by both the tagged ClassMerger singletons and any tv() / cv() function calls throughout your app — no config: argument needed anywhere.
239+
240+
```php class-variance.config.php
241+
use Tempest\ClassVariance\Classmaps\Classmap;
242+
use Tempest\ClassVariance\Config\TailwindClassVarianceConfig;
243+
244+
return new TailwindClassVarianceConfig(
245+
prefix: 'tw-',
246+
extend: new Classmap(
247+
classGroups: [
248+
'scrollbar' => [['scrollbar' => ['hide', 'default']]],
249+
],
250+
),
251+
);
252+
```
253+
254+
```php class-variance.config.php
255+
use Tempest\ClassVariance\Config\GenericClassVarianceConfig;
256+
257+
return new GenericClassVarianceConfig(separator: '__');
258+
```
259+
260+
For standalone use (no Tempest container), pass `config:` at each callsite or assign it once via a shared variable in your bootstrap.
261+
262+
## Implementing a custom CSS kit
263+
264+
If you're using a CSS framework other than Tailwind you can provide your own conflict-resolution strategy by implementing `ClassVarianceConfig`.
265+
266+
### 1. Implement `ClassMerger`
267+
268+
```php
269+
use Tempest\ClassVariance\ClassMerger;
270+
271+
final readonly class DaisyUiMerger implements ClassMerger
272+
{
273+
public function merge(string ...$classes): string
274+
{
275+
// Last class per group wins. Group = everything before the first '-'.
276+
$resolved = [];
277+
278+
foreach ($classes as $class) {
279+
$group = str_contains($class, '-')
280+
? substr($class, 0, strpos($class, '-'))
281+
: $class;
282+
283+
$resolved[$group] = $class;
284+
}
285+
286+
return implode(' ', array_values($resolved));
287+
}
288+
}
289+
```
290+
291+
### 2. Implement `ClassVarianceConfig`
292+
293+
```php
294+
use Tempest\ClassVariance\ClassMerger;
295+
use Tempest\ClassVariance\Config\ClassVarianceConfig;
296+
297+
final class DaisyUiConfig implements ClassVarianceConfig
298+
{
299+
public ClassMerger $merger {
300+
get => new DaisyUiMerger();
301+
}
302+
}
303+
```
304+
305+
### 3. Pass your config to `cv()` or `tv()`
306+
307+
```php
308+
$button = cv(
309+
base: 'btn',
310+
variants: [
311+
'color' => ['primary' => 'btn-primary', 'ghost' => 'btn-ghost'],
312+
'size' => ['sm' => 'btn-sm', 'lg' => 'btn-lg'],
313+
],
314+
config: new DaisyUiConfig(),
315+
);
316+
```
317+
318+
### 4. Bind in Tempest (optional)
319+
320+
Return your config object from a `*.config.php` file — Tempest will discover it automatically and use it for any `cv()` or `tv()` calls that don't receive an explicit `$config` argument:
321+
322+
```php daisy-ui.config.php
323+
return new DaisyUiConfig();
324+
```
325+
326+
Or bind it explicitly in an initializer if you need constructor arguments:
327+
328+
```php
329+
use Tempest\Container\Container;
330+
use Tempest\Container\Initializer;
331+
use Tempest\Container\Singleton;
332+
333+
final readonly class DaisyUiConfigInitializer implements Initializer
334+
{
335+
#[Singleton]
336+
public function initialize(Container $container): DaisyUiConfig
337+
{
338+
return new DaisyUiConfig();
339+
}
340+
}
341+
```
342+
343+
:::info
344+
If you do create a binding for a CSS UI kit, please consider [contributing](https://github.com/tempestphp/tempest-framework/pulls) it back!
345+
:::

0 commit comments

Comments
 (0)