diff --git a/composer.json b/composer.json index cd01adc936..121047d516 100644 --- a/composer.json +++ b/composer.json @@ -97,6 +97,7 @@ "replace": { "tempest/auth": "self.version", "tempest/cache": "self.version", + "tempest/class-variance": "self.version", "tempest/clock": "self.version", "tempest/command-bus": "self.version", "tempest/console": "self.version", @@ -138,6 +139,7 @@ "psr-4": { "Tempest\\Auth\\": "packages/auth/src", "Tempest\\Cache\\": "packages/cache/src", + "Tempest\\ClassVariance\\": "packages/class-variance/src", "Tempest\\Clock\\": "packages/clock/src", "Tempest\\CommandBus\\": "packages/command-bus/src", "Tempest\\Console\\": "packages/console/src", @@ -171,6 +173,7 @@ "Tempest\\Vite\\": "packages/vite/src" }, "files": [ + "packages/class-variance/src/functions.php", "packages/clock/src/functions.php", "packages/command-bus/src/functions.php", "packages/container/src/functions.php", diff --git a/docs/3-packages/03-class-variance.md b/docs/3-packages/03-class-variance.md new file mode 100644 index 0000000000..9b117db023 --- /dev/null +++ b/docs/3-packages/03-class-variance.md @@ -0,0 +1,343 @@ +--- +title: Class Variance +description: "The Class Variance package can be used standalone or with Tempest framework to provide a tailwind-variants style CSS variant system." +--- +## Introduction + +Class variance — a PHP package for building component CSS 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. + +Two flavours: + +- **`cv()`** — separator-based merging, framework-agnostic CSS, somewhat limited by default but can be extended to support other frameworks. +- **`tv()`** — builds on `cv()` with Tailwind-aware merging, aiming to provide the same outcomes as [tailwind-merge](https://github.com/dcastil/tailwind-merge). + +## Installation + +### With Tempest + +```bash +composer require tempest/class-variance +``` + +Discovery handles the rest. The `CvMergerInitializer` and `TvMergerInitializer` are Discovered automatically, binding tagged `ClassMerger` singletons for injection. + +To customise the configuration, publish a config file: + +```bash +./tempest class-variance:publish:config +``` + +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. + +### Standalone (without Tempest) + +```bash +composer require tempest/class-variance +``` + +Import the helper functions and call them directly — no container required: + +```php +use function Tempest\ClassVariance\cv; +use function Tempest\ClassVariance\tv; +``` + + +## `cv()` — Generic class variance + +`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. + +```php +use function Tempest\ClassVariance\cv; + +$button = cv( + base: 'btn', + variants: [ + 'size' => [ + 'sm' => 'btn-sm', + 'md' => 'btn-md', + 'lg' => 'btn-lg', + ], + 'color' => [ + 'primary' => 'btn-primary', + 'danger' => 'btn-danger', + ], + ], + defaultVariants: [ + 'size' => 'md', + ], +); + +echo $button(); // 'btn btn-md' +echo $button(['color' => 'primary']); // 'btn btn-md btn-primary' +echo $button(['size' => 'lg', 'color' => 'danger']); // 'btn btn-lg btn-danger' +``` + +### Compound variants + +Apply extra classes only when multiple props match simultaneously: + +```php +$badge = cv( + base: 'badge', + variants: [ + 'color' => ['primary' => 'badge-primary', 'danger' => 'badge-danger'], + 'outline' => ['true' => 'badge-outline'], + ], + compoundVariants: [ + [ + 'color' => 'danger', + 'outline' => 'true', + 'class' => 'border-danger', + ], + ], + defaultVariants: ['color' => 'primary'], +); + +echo $badge(['outline' => 'true']); // 'badge badge-primary badge-outline' +echo $badge(['color' => 'danger', 'outline' => 'true']); // 'badge badge-danger badge-outline border-danger' +``` + +### Slots + +Split the output across named slots — useful for multi-element components: + +```php +$card = cv( + base: [ + 'base' => 'card', + 'header' => 'card-header', + 'body' => 'card-body', + ], + variants: [ + 'size' => [ + 'sm' => ['base' => 'card-sm', 'body' => 'p-2'], + 'lg' => ['base' => 'card-lg', 'body' => 'p-8'], + ], + ], + defaultVariants: ['size' => 'sm'], +); + +echo $card(slot: 'base'); // 'card card-sm' +echo $card(slot: 'header'); // 'card-header' +echo $card(['size' => 'lg'], slot: 'body'); // 'card-body p-8' +``` + +:::info +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:`. +::: + +### Passing extra classes + +Pass `class` or `className` in the props to append caller-supplied classes (these go through the same merger): + +```php +echo $button(['color' => 'primary', 'class' => 'w-full']); // 'btn btn-md btn-primary w-full' +``` + +:::info +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. +::: + + +## `tv()` — Tailwind-aware class variance + +`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. + +The API is identical to `cv()`: + +```php +use function Tempest\ClassVariance\tv; + +$button = tv( + base: 'inline-flex items-center rounded px-3 py-1.5 text-sm font-medium', + variants: [ + 'intent' => [ + 'primary' => 'bg-blue-600 text-white hover:bg-blue-700', + 'secondary' => 'bg-gray-100 text-gray-900 hover:bg-gray-200', + 'danger' => 'bg-red-600 text-white hover:bg-red-700', + ], + 'size' => [ + 'sm' => 'px-2 py-1 text-xs', + 'lg' => 'px-4 py-2 text-base', + ], + ], + defaultVariants: ['intent' => 'primary'], +); + +echo $button(); // base + primary intent classes +echo $button(['intent' => 'danger']); // px-3 wins over default, bg-red-600 replaces bg-blue-600 +echo $button(['size' => 'sm']); // px-2 py-1 text-xs override base padding and text size +``` + +The conflict resolution means a caller can safely pass overriding classes without knowing what the component already applies: + +```php +// Caller overrides just the background — no class duplication +echo $button(['intent' => 'primary', 'class' => 'bg-indigo-600']); +// → '...text-white hover:bg-blue-700 bg-indigo-600' (bg-blue-600 removed) +``` + +## Customising the Tailwind config + +### Publish the config file (Tempest) + +```bash +./tempest class-variance:publish:config +``` + +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. + +### Single callsite + +Pass `config:` directly to override for one specific call. Everything else in your app uses the default. + +```php +$config = new TailwindClassVarianceConfig(prefix: 'tw-', extend: new Classmap(/* ... */)); + +$button = tv(base: 'tw-rounded tw-px-3', config: $config); +``` + +### Whole component class + +Inject the tagged ClassMerger singleton. It picks up your app's config automatically. + +```php +use Tempest\ClassVariance\ClassMerger; +use Tempest\Container\Tag; + +final readonly class ButtonComponent +{ + public function __construct( + #[Tag('tv')] private ClassMerger $merger, // or Tag('cv') + ) {} + + public function render(string $intent = 'primary'): string + { + $button = tv( + base: 'rounded px-3 py-1.5', + variants: ['intent' => ['primary' => 'bg-blue-600', 'danger' => 'bg-red-600']], + ); + + return $button(['intent' => $intent]); + } +} + +final readonly class ButtonComponent +{ + public function __construct( + #[Tag('cv')] private ClassMerger $merger, + ) {} +} +``` + +### Override the default for all usages + +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. + +```php class-variance.config.php +use Tempest\ClassVariance\Classmaps\Classmap; +use Tempest\ClassVariance\Config\TailwindClassVarianceConfig; + +return new TailwindClassVarianceConfig( + prefix: 'tw-', + extend: new Classmap( + classGroups: [ + 'scrollbar' => [['scrollbar' => ['hide', 'default']]], + ], + ), +); +``` + +```php class-variance.config.php +use Tempest\ClassVariance\Config\GenericClassVarianceConfig; + +return new GenericClassVarianceConfig(separator: '__'); +``` + +For standalone use (no Tempest container), pass `config:` at each callsite or assign it once via a shared variable in your bootstrap. + +## Implementing a custom CSS kit + +If you're using a CSS framework other than Tailwind you can provide your own conflict-resolution strategy by implementing `ClassVarianceConfig`. + +### 1. Implement `ClassMerger` + +```php +use Tempest\ClassVariance\ClassMerger; + +final readonly class DaisyUiMerger implements ClassMerger +{ + public function merge(string ...$classes): string + { + // Last class per group wins. Group = everything before the first '-'. + $resolved = []; + + foreach ($classes as $class) { + $group = str_contains($class, '-') + ? substr($class, 0, strpos($class, '-')) + : $class; + + $resolved[$group] = $class; + } + + return implode(' ', array_values($resolved)); + } +} +``` + +### 2. Implement `ClassVarianceConfig` + +```php +use Tempest\ClassVariance\ClassMerger; +use Tempest\ClassVariance\Config\ClassVarianceConfig; + +final class DaisyUiConfig implements ClassVarianceConfig +{ + public ClassMerger $merger { + get => new DaisyUiMerger(); + } +} +``` + +### 3. Pass your config to `cv()` or `tv()` + +```php +$button = cv( + base: 'btn', + variants: [ + 'color' => ['primary' => 'btn-primary', 'ghost' => 'btn-ghost'], + 'size' => ['sm' => 'btn-sm', 'lg' => 'btn-lg'], + ], + config: new DaisyUiConfig(), +); +``` + +### 4. Bind in Tempest (optional) + +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: + +```php daisy-ui.config.php +return new DaisyUiConfig(); +``` + +Or bind it explicitly in an initializer if you need constructor arguments: + +```php +use Tempest\Container\Container; +use Tempest\Container\Initializer; +use Tempest\Container\Singleton; + +final readonly class DaisyUiConfigInitializer implements Initializer +{ + #[Singleton] + public function initialize(Container $container): DaisyUiConfig + { + return new DaisyUiConfig(); + } +} +``` + +:::info +If you do create a binding for a CSS UI kit, please consider [contributing](https://github.com/tempestphp/tempest-framework/pulls) it back! +::: \ No newline at end of file diff --git a/packages/class-variance/.gitattributes b/packages/class-variance/.gitattributes new file mode 100644 index 0000000000..3f7775660b --- /dev/null +++ b/packages/class-variance/.gitattributes @@ -0,0 +1,14 @@ +# Exclude build/test files from the release +.github/ export-ignore +tests/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpunit.xml export-ignore +README.md export-ignore + +# Configure diff output +*.view.php diff=html +*.php diff=php +*.css diff=css +*.html diff=html +*.md diff=markdown diff --git a/packages/class-variance/LICENSE.md b/packages/class-variance/LICENSE.md new file mode 100644 index 0000000000..54215b7261 --- /dev/null +++ b/packages/class-variance/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2024 Brent Roose brendt@stitcher.io + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/class-variance/composer.json b/packages/class-variance/composer.json new file mode 100644 index 0000000000..b839078e83 --- /dev/null +++ b/packages/class-variance/composer.json @@ -0,0 +1,24 @@ +{ + "name": "tempest/class-variance", + "description": "The PHP framework that gets out of your way.", + "require": { + "php": "^8.5", + "tempest/core": "3.x-dev", + "tempest/support": "3.x-dev" + }, + "autoload": { + "psr-4": { + "Tempest\\ClassVariance\\": "src" + }, + "files": [ + "src/functions.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Tempest\\ClassVariance\\Tests\\": "tests" + } + }, + "license": "MIT", + "minimum-stability": "dev" +} diff --git a/packages/class-variance/phpunit.xml b/packages/class-variance/phpunit.xml new file mode 100644 index 0000000000..94b26e80b6 --- /dev/null +++ b/packages/class-variance/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + src + + + diff --git a/packages/class-variance/src/ClassMerger.php b/packages/class-variance/src/ClassMerger.php new file mode 100644 index 0000000000..791096a4a6 --- /dev/null +++ b/packages/class-variance/src/ClassMerger.php @@ -0,0 +1,18 @@ + $items */ + private function __construct( + private array $items = [], + ) {} + + /** + * Parse any variant value into a ClassNames instance for the given slot. + * + * A plain string or indexed array implicitly targets the 'base' slot. + * Passing $slot = '' is the passthrough context (e.g. extra $props['class']) + * and always emits the classes regardless of slot. + * + * @param string|list|bool>|array|bool>|bool $input + */ + public static function of(string|array|bool $input, string $slot = ''): self + { + if (is_bool($input)) { + return new self(); + } + + if (is_string($input)) { + // Plain string implicitly targets 'base'. Skip for any other explicit slot. + if ($slot !== '' && $slot !== 'base') { + return new self(); + } + + return new self(self::tokenise($input)); + } + + // Indexed (list) array — implicitly 'base', same rule as plain string. + if (array_is_list($input)) { + if ($slot !== '' && $slot !== 'base') { + return new self(); + } + + $items = []; + foreach ($input as $entry) { + if (! is_string($entry)) { + continue; + } + + array_push($items, ...self::tokenise($entry)); + } + + return new self($items); + } + + // Associative (slot-keyed) array — extract the requested slot's value. + $slotValue = $input[$slot] ?? null; + + if ($slotValue === null || is_bool($slotValue)) { + return new self(); + } + + // Recurse with slot='' so the plain-string / list rules above apply + // without the 'base'-only guard (we have already resolved the slot key). + return self::of($slotValue, ''); + } + + public static function empty(): self + { + return new self(); + } + + public function concat(self $other): self + { + return new self([...$this->items, ...$other->items]); + } + + /** @return list */ + public function toArray(): array + { + return array_values(array_unique( + array_filter($this->items, static fn (string $c) => $c !== ''), + )); + } + + public function toString(): string + { + return implode(' ', $this->toArray()); + } + + /** @return list */ + private static function tokenise(string $value): array + { + return array_values(array_filter( + explode(' ', $value), + static fn (string $t) => $t !== '', + )); + } +} diff --git a/packages/class-variance/src/ClassVariance.php b/packages/class-variance/src/ClassVariance.php new file mode 100644 index 0000000000..6c25f5e8e8 --- /dev/null +++ b/packages/class-variance/src/ClassVariance.php @@ -0,0 +1,22 @@ +> $props Active variant prop values. + * @param string $slot Named slot to resolve (e.g. 'base', 'label'). When omitted + * and the base defines a single slot, that slot is inferred automatically. + */ + public function __invoke(array $props = [], string $slot = ''): string; +} diff --git a/packages/class-variance/src/Classmaps/Classmap.php b/packages/class-variance/src/Classmaps/Classmap.php new file mode 100644 index 0000000000..2eafa6e9ab --- /dev/null +++ b/packages/class-variance/src/Classmaps/Classmap.php @@ -0,0 +1,132 @@ + ['v1', 'v2']] → constrained prefix match + * matches 'prefix-v1', 'prefix-v2', etc. + * Use '' as a suffix to match the bare prefix + * e.g. ['border' => ['', '2', '4']] matches + * 'border', 'border-2', 'border-4' + * + * Iteration order determines priority: the first group whose matcher fires wins. + * Constrained prefix groups should come before wildcard prefix groups when both + * share the same prefix (e.g. font-size before text-color for the 'text' prefix). + * + * $conflictingClassGroups maps a group ID to the list of group IDs it supersedes. + * When a class from group A is encountered during merge, all previously accumulated + * classes from groups listed under A are removed. + * + * Use extend() to merge additional definitions on top of an existing map (additive), + * or override() to replace specific group definitions entirely. + */ +final readonly class Classmap +{ + /** + * @param array>|array{0: string}>> $classGroups + * @param array> $conflictingClassGroups + */ + public function __construct( + public array $classGroups = [], + public array $conflictingClassGroups = [], + ) {} + + /** + * Find the group ID for the given class, or null if unknown. + */ + public function findGroup(string $class): ?string + { + foreach ($this->classGroups as $groupId => $matchers) { + foreach ($matchers as $matcher) { + if (is_string($matcher)) { + // Exact match + if ($matcher === $class) { + return $groupId; + } + + continue; + } + + if (array_is_list($matcher)) { + // ['prefix'] — wildcard: matches prefix itself or prefix-{anything} + $prefix = $matcher[0]; + + if ($class === $prefix || str_starts_with($class, $prefix . '-')) { + return $groupId; + } + } else { + // ['prefix' => ['suffix1', 'suffix2']] — constrained suffix list + $prefix = (string) array_key_first($matcher); + /** @var list $suffixes */ + $suffixes = $matcher[$prefix]; + + foreach ($suffixes as $suffix) { + if ($suffix === '' && $class === $prefix) { + return $groupId; + } + + if ($suffix !== '' && $class === $prefix . '-' . $suffix) { + return $groupId; + } + } + } + } + } + + return null; + } + + /** + * Return a new map with $additions merged in. + * For groups that exist in both maps, matchers are concatenated (additive). + * Conflicting group lists are merged by union. + */ + public function extend(self $additions): self + { + $groups = $this->classGroups; + + foreach ($additions->classGroups as $groupId => $matchers) { + /** @var list>|array{0: string}> $merged */ + $merged = array_merge($groups[$groupId] ?? [], $matchers); + $groups[$groupId] = $merged; + } + + $conflicts = $this->conflictingClassGroups; + + foreach ($additions->conflictingClassGroups as $groupId => $conflicting) { + $conflicts[$groupId] = array_values(array_unique([ + ...($conflicts[$groupId] ?? []), + ...$conflicting, + ])); + } + + return new self($groups, $conflicts); + } + + /** + * Return a new map with specific groups replaced entirely by $replacements. + * Groups not present in $replacements are kept as-is. + */ + public function override(self $replacements): self + { + /** @var array>|array{0: string}>> $newGroups */ + $newGroups = array_replace($this->classGroups, $replacements->classGroups); + /** @var array> $newConflicts */ + $newConflicts = array_replace($this->conflictingClassGroups, $replacements->conflictingClassGroups); + + return new self($newGroups, $newConflicts); + } +} diff --git a/packages/class-variance/src/Classmaps/GenericClassmap.php b/packages/class-variance/src/Classmaps/GenericClassmap.php new file mode 100644 index 0000000000..d3a2d82e87 --- /dev/null +++ b/packages/class-variance/src/Classmaps/GenericClassmap.php @@ -0,0 +1,22 @@ + ['v1', 'v2']] → constrained prefix (named suffixes only) + * + * Groups sharing a prefix use constrained matchers for the scale/size group and + * wildcard matchers for the color group. Since iteration order determines priority, + * the scale group must appear before the color group in the returned array. + * + * Known limitation: arbitrary values (e.g. border-[3px]) are not disambiguated + * from color values — they fall through to the color group. Full disambiguation + * requires validator callbacks, which can be added via extend(). + */ +final class TailwindClassmap +{ + public static function default(): Classmap + { + return new Classmap( + classGroups: self::classGroups(), + conflictingClassGroups: self::conflictingClassGroups(), + ); + } + + /** @return array>|array{0: string}>> */ + private static function classGroups(): array + { + // Common scale values reused across groups + $borderWidthScale = ['', '0', '2', '4', '8']; + $tshirtScale = ['xs', 'sm', 'base', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', '8xl', '9xl']; + $fontSizeScale = ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl', '7xl', '8xl', '9xl']; + $shadowScale = ['', 'sm', 'md', 'lg', 'xl', '2xl', 'inner', 'none']; + + return [ + // ── Layout ─────────────────────────────────────────────────────────── + + 'aspect' => [['aspect']], + 'container' => ['container'], + 'columns' => [['columns']], + 'break-after' => [['break-after' => ['auto', 'avoid', 'all', 'avoid-page', 'page', 'left', 'right', 'column']]], + 'break-before' => [['break-before' => ['auto', 'avoid', 'all', 'avoid-page', 'page', 'left', 'right', 'column']]], + 'break-inside' => [['break-inside' => ['auto', 'avoid', 'avoid-page', 'avoid-column']]], + 'box-decoration' => [['box-decoration' => ['clone', 'slice']]], + 'box' => [['box' => ['border', 'content']]], + 'display' => [ + 'block', + 'inline-block', + 'inline', + 'flex', + 'inline-flex', + 'table', + 'inline-table', + 'table-caption', + 'table-cell', + 'table-column', + 'table-column-group', + 'table-footer-group', + 'table-header-group', + 'table-row-group', + 'table-row', + 'flow-root', + 'grid', + 'inline-grid', + 'contents', + 'list-item', + 'hidden', + ], + 'float' => [['float' => ['start', 'end', 'right', 'left', 'none']]], + 'clear' => [['clear' => ['start', 'end', 'right', 'left', 'both', 'none']]], + 'isolation' => ['isolate', 'isolation-auto'], + 'object-fit' => [['object' => ['contain', 'cover', 'fill', 'none', 'scale-down']]], + 'object-position' => [['object' => ['bottom', 'center', 'left', 'left-bottom', 'left-top', 'right', 'right-bottom', 'right-top', 'top']]], + 'overflow' => [['overflow' => ['auto', 'hidden', 'clip', 'visible', 'scroll']]], + 'overflow-x' => [['overflow-x' => ['auto', 'hidden', 'clip', 'visible', 'scroll']]], + 'overflow-y' => [['overflow-y' => ['auto', 'hidden', 'clip', 'visible', 'scroll']]], + 'overscroll' => [['overscroll' => ['auto', 'contain', 'none']]], + 'overscroll-x' => [['overscroll-x' => ['auto', 'contain', 'none']]], + 'overscroll-y' => [['overscroll-y' => ['auto', 'contain', 'none']]], + 'position' => ['static', 'fixed', 'absolute', 'relative', 'sticky'], + 'inset' => [['inset']], + 'inset-x' => [['inset-x']], + 'inset-y' => [['inset-y']], + 'start' => [['start']], + 'end' => [['end']], + 'top' => [['top']], + 'right' => [['right']], + 'bottom' => [['bottom']], + 'left' => [['left']], + 'visibility' => ['visible', 'invisible', 'collapse'], + 'z' => [['z']], + + // ── Flexbox & Grid ──────────────────────────────────────────────────── + + 'basis' => [['basis']], + 'flex-direction' => [['flex' => ['row', 'row-reverse', 'col', 'col-reverse']]], + 'flex-wrap' => [['flex' => ['wrap', 'wrap-reverse', 'nowrap']]], + 'flex' => [['flex']], + 'grow' => [['grow']], + 'shrink' => [['shrink']], + 'order' => [['order']], + 'grid-cols' => [['grid-cols']], + 'grid-rows' => [['grid-rows']], + 'col-span' => [['col-span'], 'col-auto'], + 'col-start' => [['col-start']], + 'col-end' => [['col-end']], + 'row-span' => [['row-span'], 'row-auto'], + 'row-start' => [['row-start']], + 'row-end' => [['row-end']], + 'grid-flow' => [['grid-flow' => ['row', 'col', 'dense', 'row-dense', 'col-dense']]], + 'auto-cols' => [['auto-cols']], + 'auto-rows' => [['auto-rows']], + 'gap' => [['gap']], + 'gap-x' => [['gap-x']], + 'gap-y' => [['gap-y']], + 'justify-content' => [['justify' => ['normal', 'start', 'end', 'center', 'between', 'around', 'evenly', 'stretch']]], + 'justify-items' => [['justify-items' => ['start', 'end', 'center', 'stretch']]], + 'justify-self' => [['justify-self' => ['auto', 'start', 'end', 'center', 'stretch']]], + 'align-content' => [['content' => ['normal', 'center', 'start', 'end', 'between', 'around', 'evenly', 'baseline', 'stretch']]], + 'align-items' => [['items' => ['start', 'end', 'center', 'baseline', 'stretch']]], + 'align-self' => [['self' => ['auto', 'start', 'end', 'center', 'stretch', 'baseline']]], + 'place-content' => [['place-content' => ['center', 'start', 'end', 'between', 'around', 'evenly', 'baseline', 'stretch']]], + 'place-items' => [['place-items' => ['start', 'end', 'center', 'baseline', 'stretch']]], + 'place-self' => [['place-self' => ['auto', 'start', 'end', 'center', 'stretch']]], + + // ── Spacing ─────────────────────────────────────────────────────────── + + 'p' => [['p']], + 'px' => [['px']], + 'py' => [['py']], + 'ps' => [['ps']], + 'pe' => [['pe']], + 'pt' => [['pt']], + 'pr' => [['pr']], + 'pb' => [['pb']], + 'pl' => [['pl']], + 'm' => [['m']], + 'mx' => [['mx']], + 'my' => [['my']], + 'ms' => [['ms']], + 'me' => [['me']], + 'mt' => [['mt']], + 'mr' => [['mr']], + 'mb' => [['mb']], + 'ml' => [['ml']], + 'space-x' => [['space-x']], + 'space-y' => [['space-y']], + 'space-x-reverse' => ['space-x-reverse'], + 'space-y-reverse' => ['space-y-reverse'], + + // ── Sizing ──────────────────────────────────────────────────────────── + + 'w' => [['w']], + 'min-w' => [['min-w']], + 'max-w' => [['max-w']], + 'h' => [['h']], + 'min-h' => [['min-h']], + 'max-h' => [['max-h']], + 'size' => [['size']], + + // ── Typography ──────────────────────────────────────────────────────── + // font-size uses constrained 'text' prefix; text-color uses wildcard. + // Iteration order ensures font-size is matched before text-color. + + 'font-size' => [['text' => $fontSizeScale]], + 'font-smoothing' => ['antialiased', 'subpixel-antialiased'], + 'font-style' => ['italic', 'not-italic'], + 'font-weight' => [['font' => ['thin', 'extralight', 'light', 'normal', 'medium', 'semibold', 'bold', 'extrabold', 'black']]], + 'font-family' => [['font']], + 'font-variant-numeric' => [ + 'normal-nums', + 'ordinal', + 'slashed-zero', + 'lining-nums', + 'oldstyle-nums', + 'proportional-nums', + 'tabular-nums', + 'diagonal-fractions', + 'stacked-fractions', + ], + 'tracking' => [['tracking' => ['tighter', 'tight', 'normal', 'wide', 'wider', 'widest']]], + 'line-clamp' => [['line-clamp']], + 'leading' => [['leading' => ['none', 'tight', 'snug', 'normal', 'relaxed', 'loose']]], + 'list-image' => [['list-image']], + 'list-style-position' => ['list-inside', 'list-outside'], + 'list-style-type' => [['list' => ['none', 'disc', 'decimal']]], + 'text-align' => [['text' => ['left', 'center', 'right', 'justify', 'start', 'end']]], + 'text-color' => [['text']], // wildcard — catches anything not matched above + 'text-decoration' => ['underline', 'overline', 'line-through', 'no-underline'], + 'text-decoration-color' => [['decoration']], + 'text-decoration-style' => [['decoration' => ['solid', 'double', 'dotted', 'dashed', 'wavy']]], + 'text-decoration-thickness' => [['underline-offset']], + 'text-transform' => ['uppercase', 'lowercase', 'capitalize', 'normal-case'], + 'text-overflow' => ['truncate', 'text-ellipsis', 'text-clip'], + 'text-wrap' => [['text' => ['wrap', 'nowrap', 'balance', 'pretty']]], + 'indent' => [['indent']], + 'vertical-align' => [['align' => ['baseline', 'top', 'middle', 'bottom', 'text-top', 'text-bottom', 'sub', 'super']]], + 'whitespace' => [['whitespace' => ['normal', 'nowrap', 'pre', 'pre-line', 'pre-wrap', 'break-spaces']]], + 'break-words' => [['break' => ['normal', 'words', 'all', 'keep']]], + 'hyphens' => [['hyphens' => ['none', 'manual', 'auto']]], + 'content' => [['content']], + + // ── Backgrounds ─────────────────────────────────────────────────────── + + 'bg-attach' => [['bg' => ['fixed', 'local', 'scroll']]], + 'bg-clip' => [['bg-clip' => ['border', 'padding', 'content', 'text']]], + 'bg-color' => [['bg']], // wildcard — catches bg-{color} + 'bg-origin' => [['bg-origin' => ['border', 'padding', 'content']]], + 'bg-position' => [['bg' => ['bottom', 'center', 'left', 'left-bottom', 'left-top', 'right', 'right-bottom', 'right-top', 'top']]], + 'bg-repeat' => [['bg' => ['repeat', 'no-repeat', 'repeat-x', 'repeat-y', 'repeat-round', 'repeat-space']]], + 'bg-size' => [['bg' => ['auto', 'cover', 'contain']]], + 'bg-image' => [ + ['bg' => ['none', 'gradient-to-t', 'gradient-to-tr', 'gradient-to-r', 'gradient-to-br', 'gradient-to-b', 'gradient-to-bl', 'gradient-to-l', 'gradient-to-tl']], + ], + 'gradient-from' => [['from']], + 'gradient-via' => [['via']], + 'gradient-to' => [['to']], + + // ── Borders ─────────────────────────────────────────────────────────── + // border-w uses constrained suffix list; border-color uses wildcard. + + 'rounded' => [['rounded']], + 'rounded-s' => [['rounded-s']], + 'rounded-e' => [['rounded-e']], + 'rounded-t' => [['rounded-t']], + 'rounded-r' => [['rounded-r']], + 'rounded-b' => [['rounded-b']], + 'rounded-l' => [['rounded-l']], + 'rounded-ss' => [['rounded-ss']], + 'rounded-se' => [['rounded-se']], + 'rounded-ee' => [['rounded-ee']], + 'rounded-es' => [['rounded-es']], + 'rounded-tl' => [['rounded-tl']], + 'rounded-tr' => [['rounded-tr']], + 'rounded-br' => [['rounded-br']], + 'rounded-bl' => [['rounded-bl']], + 'border-w' => [['border' => $borderWidthScale]], // constrained: border, border-0, border-2, border-4, border-8 + 'border-color' => [['border']], // wildcard: border-{color/anything-else} + 'border-w-x' => [['border-x' => $borderWidthScale]], + 'border-color-x' => [['border-x']], + 'border-w-y' => [['border-y' => $borderWidthScale]], + 'border-color-y' => [['border-y']], + 'border-w-s' => [['border-s' => $borderWidthScale]], + 'border-color-s' => [['border-s']], + 'border-w-e' => [['border-e' => $borderWidthScale]], + 'border-color-e' => [['border-e']], + 'border-w-t' => [['border-t' => $borderWidthScale]], + 'border-color-t' => [['border-t']], + 'border-w-r' => [['border-r' => $borderWidthScale]], + 'border-color-r' => [['border-r']], + 'border-w-b' => [['border-b' => $borderWidthScale]], + 'border-color-b' => [['border-b']], + 'border-w-l' => [['border-l' => $borderWidthScale]], + 'border-color-l' => [['border-l']], + 'border-style' => [['border' => ['solid', 'dashed', 'dotted', 'double', 'hidden', 'none']]], + 'divide-x' => [['divide-x' => $borderWidthScale]], + 'divide-color-x' => [['divide-x']], + 'divide-y' => [['divide-y' => $borderWidthScale]], + 'divide-color-y' => [['divide-y']], + 'divide-color' => [['divide']], + 'divide-style' => [['divide' => ['solid', 'dashed', 'dotted', 'double', 'none']]], + 'outline-w' => [['outline' => $borderWidthScale]], + 'outline-color' => [['outline']], + 'outline-style' => [['outline' => ['none', 'solid', 'dashed', 'dotted', 'double']]], + 'outline-offset' => [['outline-offset']], + 'ring-w' => [['ring' => ['', '0', '1', '2', '4', '8']]], // constrained: ring, ring-0, ring-1, ring-2, ring-4, ring-8 + 'ring-w-inset' => ['ring-inset'], + 'ring-color' => [['ring']], // wildcard: ring-{color} + 'ring-offset-w' => [['ring-offset' => $borderWidthScale]], + 'ring-offset-color' => [['ring-offset']], + + // ── Effects ─────────────────────────────────────────────────────────── + // shadow uses constrained suffix list; shadow-color uses wildcard. + + 'shadow' => [['shadow' => $shadowScale]], // shadow, shadow-sm, shadow-md, shadow-lg, shadow-xl, shadow-2xl, shadow-inner, shadow-none + 'shadow-color' => [['shadow']], // wildcard: shadow-{color} + 'inset-shadow' => [['inset-shadow' => $shadowScale]], + 'inset-shadow-color' => [['inset-shadow']], + 'opacity' => [['opacity']], + 'mix-blend' => [['mix-blend']], + 'bg-blend' => [['bg-blend']], + + // ── Filters ─────────────────────────────────────────────────────────── + + 'filter' => ['filter', 'filter-none'], + 'blur' => [['blur' => $tshirtScale]], + 'brightness' => [['brightness']], + 'contrast' => [['contrast']], + 'drop-shadow' => [['drop-shadow' => $shadowScale]], + 'drop-shadow-color' => [['drop-shadow']], + 'grayscale' => [['grayscale' => ['', '0']]], + 'hue-rotate' => [['hue-rotate']], + 'invert' => [['invert' => ['', '0']]], + 'saturate' => [['saturate']], + 'sepia' => [['sepia' => ['', '0']]], + 'backdrop-filter' => ['backdrop-filter', 'backdrop-filter-none'], + 'backdrop-blur' => [['backdrop-blur' => $tshirtScale]], + 'backdrop-brightness' => [['backdrop-brightness']], + 'backdrop-contrast' => [['backdrop-contrast']], + 'backdrop-grayscale' => [['backdrop-grayscale' => ['', '0']]], + 'backdrop-hue-rotate' => [['backdrop-hue-rotate']], + 'backdrop-invert' => [['backdrop-invert' => ['', '0']]], + 'backdrop-opacity' => [['backdrop-opacity']], + 'backdrop-saturate' => [['backdrop-saturate']], + 'backdrop-sepia' => [['backdrop-sepia' => ['', '0']]], + + // ── Transforms ──────────────────────────────────────────────────────── + + 'transform' => [['transform' => ['', 'cpu', 'gpu', 'none']]], + 'scale' => [['scale']], + 'scale-x' => [['scale-x']], + 'scale-y' => [['scale-y']], + 'rotate' => [['rotate']], + 'translate-x' => [['translate-x']], + 'translate-y' => [['translate-y']], + 'skew-x' => [['skew-x']], + 'skew-y' => [['skew-y']], + 'transform-origin' => [['origin']], + 'perspective' => [['perspective' => ['dramatic', 'near', 'normal', 'midrange', 'distant', 'none']]], + 'perspective-origin' => [['perspective-origin']], + 'backface' => [['backface' => ['hidden', 'visible']]], + + // ── Transitions & Animation ─────────────────────────────────────────── + + 'transition' => [['transition']], + 'duration' => [['duration']], + 'ease' => [['ease' => ['linear', 'in', 'out', 'in-out']]], + 'delay' => [['delay']], + 'animate' => [['animate' => ['none', 'spin', 'ping', 'pulse', 'bounce']]], + 'will-change' => [['will-change']], + + // ── Interactivity ───────────────────────────────────────────────────── + + 'appearance' => [['appearance' => ['none', 'auto']]], + 'cursor' => [['cursor']], + 'caret-color' => [['caret']], + 'pointer-events' => [['pointer-events' => ['none', 'auto']]], + 'resize' => ['resize-none', 'resize-y', 'resize-x', 'resize'], + 'scroll-behavior' => [['scroll' => ['auto', 'smooth']]], + 'scroll-m' => [['scroll-m']], + 'scroll-mx' => [['scroll-mx']], + 'scroll-my' => [['scroll-my']], + 'scroll-ms' => [['scroll-ms']], + 'scroll-me' => [['scroll-me']], + 'scroll-mt' => [['scroll-mt']], + 'scroll-mr' => [['scroll-mr']], + 'scroll-mb' => [['scroll-mb']], + 'scroll-ml' => [['scroll-ml']], + 'scroll-p' => [['scroll-p']], + 'scroll-px' => [['scroll-px']], + 'scroll-py' => [['scroll-py']], + 'scroll-ps' => [['scroll-ps']], + 'scroll-pe' => [['scroll-pe']], + 'scroll-pt' => [['scroll-pt']], + 'scroll-pr' => [['scroll-pr']], + 'scroll-pb' => [['scroll-pb']], + 'scroll-pl' => [['scroll-pl']], + 'snap-align' => [['snap' => ['start', 'end', 'center', 'align-none']]], + 'snap-stop' => [['snap' => ['normal', 'always']]], + 'snap-type' => [['snap' => ['none', 'x', 'y', 'both']]], + 'snap-strictness' => [['snap' => ['mandatory', 'proximity']]], + 'touch' => [['touch']], + 'select' => [['select' => ['none', 'text', 'all', 'auto']]], + + // ── SVG ─────────────────────────────────────────────────────────────── + + 'fill' => [['fill']], + 'stroke-w' => [['stroke' => ['', '0', '1', '2']]], + 'stroke-color' => [['stroke']], + + // ── Accessibility ───────────────────────────────────────────────────── + + 'sr' => ['sr-only', 'not-sr-only'], + + // ── Table ───────────────────────────────────────────────────────────── + + 'border-collapse' => [['border' => ['collapse', 'separate']]], + 'border-spacing' => [['border-spacing']], + 'border-spacing-x' => [['border-spacing-x']], + 'border-spacing-y' => [['border-spacing-y']], + 'caption-side' => [['caption' => ['top', 'bottom']]], + 'table-layout' => [['table' => ['auto', 'fixed']]], + ]; + } + + /** @return array> */ + private static function conflictingClassGroups(): array + { + return [ + 'p' => ['px', 'py', 'ps', 'pe', 'pt', 'pr', 'pb', 'pl'], + 'px' => ['ps', 'pe'], + 'py' => ['pt', 'pb'], + 'm' => ['mx', 'my', 'ms', 'me', 'mt', 'mr', 'mb', 'ml'], + 'mx' => ['ms', 'me'], + 'my' => ['mt', 'mb'], + 'inset' => ['inset-x', 'inset-y', 'start', 'end', 'top', 'right', 'bottom', 'left'], + 'inset-x' => ['start', 'end'], + 'inset-y' => ['top', 'bottom'], + 'gap' => ['gap-x', 'gap-y'], + 'overflow' => ['overflow-x', 'overflow-y'], + 'overscroll' => ['overscroll-x', 'overscroll-y'], + 'rounded' => [ + 'rounded-s', + 'rounded-e', + 'rounded-t', + 'rounded-r', + 'rounded-b', + 'rounded-l', + 'rounded-ss', + 'rounded-se', + 'rounded-ee', + 'rounded-es', + 'rounded-tl', + 'rounded-tr', + 'rounded-br', + 'rounded-bl', + ], + 'rounded-t' => ['rounded-tl', 'rounded-tr'], + 'rounded-r' => ['rounded-tr', 'rounded-br'], + 'rounded-b' => ['rounded-br', 'rounded-bl'], + 'rounded-l' => ['rounded-tl', 'rounded-bl'], + 'rounded-s' => ['rounded-ss', 'rounded-es'], + 'rounded-e' => ['rounded-se', 'rounded-ee'], + 'border-w' => ['border-w-x', 'border-w-y', 'border-w-s', 'border-w-e', 'border-w-t', 'border-w-r', 'border-w-b', 'border-w-l'], + 'border-w-x' => ['border-w-s', 'border-w-e'], + 'border-w-y' => ['border-w-t', 'border-w-b'], + 'border-color' => ['border-color-x', 'border-color-y', 'border-color-s', 'border-color-e', 'border-color-t', 'border-color-r', 'border-color-b', 'border-color-l'], + 'border-color-x' => ['border-color-s', 'border-color-e'], + 'border-color-y' => ['border-color-t', 'border-color-b'], + 'scale' => ['scale-x', 'scale-y'], + 'font-size' => ['leading'], + 'scroll-m' => ['scroll-mx', 'scroll-my', 'scroll-ms', 'scroll-me', 'scroll-mt', 'scroll-mr', 'scroll-mb', 'scroll-ml'], + 'scroll-mx' => ['scroll-ms', 'scroll-me'], + 'scroll-my' => ['scroll-mt', 'scroll-mb'], + 'scroll-p' => ['scroll-px', 'scroll-py', 'scroll-ps', 'scroll-pe', 'scroll-pt', 'scroll-pr', 'scroll-pb', 'scroll-pl'], + 'scroll-px' => ['scroll-ps', 'scroll-pe'], + 'scroll-py' => ['scroll-pt', 'scroll-pb'], + ]; + } +} diff --git a/packages/class-variance/src/Commands/PublishConfigCommand.php b/packages/class-variance/src/Commands/PublishConfigCommand.php new file mode 100644 index 0000000000..8604361653 --- /dev/null +++ b/packages/class-variance/src/Commands/PublishConfigCommand.php @@ -0,0 +1,102 @@ +console->ask( + question: 'Where should the config file be created?', + default: $relative, + ); + + $destination = str_starts_with($destination, '/') + ? $destination + : root_path($destination); + + if (Filesystem\is_file($destination)) { + $overwrite = $this->console->confirm( + question: "The file {$relative} already exists. Overwrite it?", + default: false, + ); + + if (! $overwrite) { + $this->console->info('Aborted.'); + + return; + } + } + + Filesystem\ensure_directory_exists(dirname($destination)); + Filesystem\write_file($destination, $this->stub()); + + $this->console->success("Config published to {$relative}."); + } + + private function stub(): string + { + $stub = <<<'PHP' + ['my-class', ['my-prefix']], + * ], + * conflictingClassGroups: [ + * 'my-group' => ['another-group'], + * ], + * ), + * + * For cv() / GenericClassVarianceConfig, swap TailwindClassVarianceConfig + * for GenericClassVarianceConfig and use its $separator and $classGroups options. + */ + return new TailwindClassVarianceConfig( + prefix: '', + separator: ':', + extend: null, + override: null, + ); + PHP; + + return str_replace(' ', '', $stub); + } +} diff --git a/packages/class-variance/src/Config/ClassVarianceConfig.php b/packages/class-variance/src/Config/ClassVarianceConfig.php new file mode 100644 index 0000000000..854f6eeb80 --- /dev/null +++ b/packages/class-variance/src/Config/ClassVarianceConfig.php @@ -0,0 +1,19 @@ + new SeparatorClassMerger($this->separator, $this->classGroups); + } +} diff --git a/packages/class-variance/src/Config/TailwindClassVarianceConfig.php b/packages/class-variance/src/Config/TailwindClassVarianceConfig.php new file mode 100644 index 0000000000..05f81a50af --- /dev/null +++ b/packages/class-variance/src/Config/TailwindClassVarianceConfig.php @@ -0,0 +1,48 @@ +extend; + if ($extend instanceof Classmap) { + $groups = $groups->extend($extend); + } + + $override = $this->override; + if ($override instanceof Classmap) { + $groups = $groups->override($override); + } + + return new GroupClassMerger($groups, $this->prefix, $this->separator); + } + } +} diff --git a/packages/class-variance/src/CvMergerInitializer.php b/packages/class-variance/src/CvMergerInitializer.php new file mode 100644 index 0000000000..0d6f97ff1b --- /dev/null +++ b/packages/class-variance/src/CvMergerInitializer.php @@ -0,0 +1,31 @@ +has(GenericClassVarianceConfig::class) + ? $container->get(GenericClassVarianceConfig::class) + : new GenericClassVarianceConfig(); + + return $config->merger; + } +} diff --git a/packages/class-variance/src/GenericClassVariance.php b/packages/class-variance/src/GenericClassVariance.php new file mode 100644 index 0000000000..b8cf975b4f --- /dev/null +++ b/packages/class-variance/src/GenericClassVariance.php @@ -0,0 +1,35 @@ +|array>>|list|string $base + * Base classes. A plain string or indexed array targets the implicit 'base' slot. + * An associative array is a slot-keyed map: ['base' => '...', 'label' => '...']. + * @param array|array>>> $variants + * Variant dimensions. Keys are prop names; values map prop values to classes. + * Per-value classes may be a plain string (implicit 'base') or a slot-keyed array. + * @param array> $compoundVariants + * Each entry is a compound rule. All keys except 'class'/'className' are match + * conditions; 'class' holds the classes to apply when all conditions match. + * @param array $defaultVariants + * Default prop values applied when a prop is not explicitly provided. + */ + public function __construct( + public array|string $base, + public ClassMerger $merger, + public array $variants = [], + public array $compoundVariants = [], + public array $defaultVariants = [], + ) {} +} diff --git a/packages/class-variance/src/GroupClassMerger.php b/packages/class-variance/src/GroupClassMerger.php new file mode 100644 index 0000000000..d0e1ae91ab --- /dev/null +++ b/packages/class-variance/src/GroupClassMerger.php @@ -0,0 +1,119 @@ + $resolved key => original class string */ + $resolved = []; + + foreach ($classes as $class) { + if ($class === '') { + continue; + } + + [$modifiers, $baseClass] = $this->extractModifiers($class); + + $withoutPrefix = $this->stripPrefix($baseClass); + + $groupId = $this->classGroups->findGroup($withoutPrefix); + + if ($groupId === null) { + // Unknown class — keep it, keyed by the full string so it is never + // displaced by another unknown class that happens to share a name. + $resolved['__unknown__' . $class] = $class; + continue; + } + + $key = $modifiers . ':' . $groupId; + + // Remove classes from groups that conflict with the incoming group. + foreach ($this->classGroups->conflictingClassGroups[$groupId] ?? [] as $conflictId) { + $conflictKey = $modifiers . ':' . $conflictId; + unset($resolved[$conflictKey]); + } + + $resolved[$key] = $class; + } + + return implode(' ', array_values($resolved)); + } + + /** + * Split a class string into [modifiers, baseClass]. + * Separators inside [] brackets (arbitrary values) are ignored. + * + * Example: 'dark:hover:bg-red-500' → ['dark:hover', 'bg-red-500'] + * Example: 'p-[color:red]' → ['', 'p-[color:red]'] + * + * @return array{0: string, 1: string} + */ + private function extractModifiers(string $class): array + { + if ($this->separator === '' || ! str_contains($class, $this->separator)) { + return ['', $class]; + } + + $sepLen = strlen($this->separator); + $depth = 0; + $lastPos = -1; + $len = strlen($class); + + for ($i = 0; $i < $len; $i++) { + if ($class[$i] === '[') { + $depth++; + } elseif ($class[$i] === ']') { + $depth--; + } elseif ($depth === 0 && substr($class, $i, $sepLen) === $this->separator) { + $lastPos = $i; + } + } + + if ($lastPos === -1) { + return ['', $class]; + } + + return [ + substr($class, 0, $lastPos), + substr($class, $lastPos + $sepLen), + ]; + } + + private function stripPrefix(string $class): string + { + if ($this->prefix === '' || ! str_starts_with($class, $this->prefix)) { + return $class; + } + + return substr($class, strlen($this->prefix)); + } +} diff --git a/packages/class-variance/src/ResolvesVariants.php b/packages/class-variance/src/ResolvesVariants.php new file mode 100644 index 0000000000..29e59b1bba --- /dev/null +++ b/packages/class-variance/src/ResolvesVariants.php @@ -0,0 +1,185 @@ +> $props */ + public function __invoke(array $props = [], string $slot = ''): string + { + $props = $this->applyDefaultVariants($props); + $slot = $this->resolveSlot($slot); + + $classes = ClassNames::of($this->base, $slot) + ->concat($this->resolveVariants($props, $slot)) + ->concat($this->resolveCompoundVariants($props, $slot)) + ->concat($this->resolvePassthrough($props, $slot)); + + return $this->merger->merge(...$classes->toArray()); + } + + /** + * @param array> $props + * @return array> + */ + private function applyDefaultVariants(array $props): array + { + /** @var array $defaults */ + $defaults = $this->defaultVariants; + + foreach ($defaults as $key => $value) { + $props[$key] ??= $value; + } + + return $props; + } + + private function resolveSlot(string $slot): string + { + if ($slot !== '') { + return $slot; + } + + // If base is a slot-keyed associative array, try to infer the slot. + if (is_array($this->base) && ! array_is_list($this->base)) { + if (count($this->base) === 1) { + return (string) array_key_first($this->base); + } else { + throw new InvalidArgumentException( + 'Multiple slots defined but no slot specified. Available slots: ' . implode(', ', array_keys($this->base)), + ); + } + } + + return $slot; + } + + /** + * Resolve the extra classes from $props['class'] or $props['className']. + * + * Plain strings and indexed arrays always apply to the current slot (passthrough context). + * Associative (slot-keyed) arrays are extracted by the current slot name, + * matching the same shape used in variant definitions. + * + * @param array $props + */ + private function resolvePassthrough(array $props, string $slot): ClassNames + { + /** @var string|array|bool $value */ + $value = $props['class'] ?? $props['className'] ?? ''; + + if (is_bool($value) || $value === '') { + return ClassNames::empty(); + } + + // Slot-keyed array: extract only the classes for the current slot. + if (is_array($value) && ! array_is_list($value)) { + return ClassNames::of($value, $slot); + } + + // Plain string or indexed list: applies to the current slot unconditionally. + return ClassNames::of($value, ''); + } + + /** + * @param array> $props + */ + private function resolveVariants(array $props, string $slot): ClassNames + { + $classes = ClassNames::empty(); + + /** @var array>> $variants */ + $variants = $this->variants; + + foreach ($props as $key => $value) { + if (is_array($value)) { + continue; + } + + $lookupKey = match (true) { + $value === true => 'true', + $value === false => 'false', + default => $value, + }; + $entry = $variants[$key][$lookupKey] ?? null; + + if ($entry === null) { + continue; + } + + $classes = $classes->concat(ClassNames::of($entry, $slot)); + } + + return $classes; + } + + /** + * @param array> $props + */ + private function resolveCompoundVariants(array $props, string $slot): ClassNames + { + $classes = ClassNames::empty(); + + /** @var array> $compoundVariants */ + $compoundVariants = $this->compoundVariants; + + foreach ($compoundVariants as $compound) { + if (! $this->compoundMatches($props, $compound)) { + continue; + } + + /** @var string|array|bool $classValue */ + $classValue = $compound['class'] ?? $compound['className'] ?? ''; + $classes = $classes->concat(ClassNames::of($classValue, $slot)); + } + + return $classes; + } + + /** + * @param array> $props + * @param array $compound + */ + private function compoundMatches(array $props, array $compound): bool + { + foreach ($compound as $key => $value) { + if ($key === 'class') { + continue; + } + if ($key === 'className') { + continue; + } + if (is_array($value)) { + if (! isset($props[$key]) || ! in_array($props[$key], $value, strict: true)) { + return false; + } + } elseif (! isset($props[$key]) || $props[$key] !== $value) { + return false; + } + } + + return true; + } +} diff --git a/packages/class-variance/src/SeparatorClassMerger.php b/packages/class-variance/src/SeparatorClassMerger.php new file mode 100644 index 0000000000..bb32bac844 --- /dev/null +++ b/packages/class-variance/src/SeparatorClassMerger.php @@ -0,0 +1,69 @@ + $resolved group-key => class */ + $resolved = []; + + foreach ($classes as $class) { + if ($class === '') { + continue; + } + + $groupKey = $this->resolveGroup($class); + $resolved[$groupKey] = $class; + } + + return implode(' ', array_values($resolved)); + } + + private function resolveGroup(string $class): string + { + // Explicit group map takes priority over separator heuristic. + if ($this->classGroups instanceof Classmap) { + $group = $this->classGroups->findGroup($class); + + if ($group !== null) { + return $group; + } + } + + $pos = strpos($class, $this->separator); + + if ($pos === false) { + // No separator — class is its own group (won't conflict with others). + return $class; + } + + return substr($class, 0, $pos); + } +} diff --git a/packages/class-variance/src/TvMergerInitializer.php b/packages/class-variance/src/TvMergerInitializer.php new file mode 100644 index 0000000000..8085a8ba79 --- /dev/null +++ b/packages/class-variance/src/TvMergerInitializer.php @@ -0,0 +1,35 @@ +has(TailwindClassVarianceConfig::class) + ? $container->get(TailwindClassVarianceConfig::class) + : new TailwindClassVarianceConfig(); + + return $config->merger; + } +} diff --git a/packages/class-variance/src/functions.php b/packages/class-variance/src/functions.php new file mode 100644 index 0000000000..adef20fece --- /dev/null +++ b/packages/class-variance/src/functions.php @@ -0,0 +1,68 @@ +|array>>|list|string $base + * Base classes. A plain string or indexed array implicitly targets the 'base' + * slot. An associative array is a slot-keyed map. + * @param array|array>>> $variants + * @param array> $compoundVariants + * @param array $defaultVariants + */ +function cv( + array|string $base, + array $variants = [], + array $compoundVariants = [], + array $defaultVariants = [], + ?ClassVarianceConfig $config = null, +): ClassVariance { + $container = GenericContainer::instance(); + $config ??= $container?->has(GenericClassVarianceConfig::class) + ? $container->get(GenericClassVarianceConfig::class) + : new GenericClassVarianceConfig(); + + return new GenericClassVariance($base, $config->merger, $variants, $compoundVariants, $defaultVariants); +} + +/** + * Create a class variance authority with Tailwind-aware merging. + * + * Ships with the full Tailwind CSS class group definitions and conflict rules. + * Pass a TailwindClassVarianceConfig to extend/override groups for custom + * plugins or a non-standard Tailwind prefix / separator. + * + * @param array|array>>|list|string $base + * Base classes. A plain string or indexed array implicitly targets the 'base' + * slot. An associative array is a slot-keyed map. + * @param array|array>>> $variants + * @param array> $compoundVariants + * @param array $defaultVariants + */ +function tv( + array|string $base, + array $variants = [], + array $compoundVariants = [], + array $defaultVariants = [], + ?ClassVarianceConfig $config = null, +): ClassVariance { + $container = GenericContainer::instance(); + $config ??= $container?->has(TailwindClassVarianceConfig::class) + ? $container->get(TailwindClassVarianceConfig::class) + : new TailwindClassVarianceConfig(); + + return new GenericClassVariance($base, $config->merger, $variants, $compoundVariants, $defaultVariants); +} diff --git a/packages/class-variance/tests/ClassNamesBooleanTest.php b/packages/class-variance/tests/ClassNamesBooleanTest.php new file mode 100644 index 0000000000..2aa02af5d8 --- /dev/null +++ b/packages/class-variance/tests/ClassNamesBooleanTest.php @@ -0,0 +1,103 @@ + ['component']], + variants: [ + 'variant' => [ + 'primary' => ['base' => 'bg-blue-500'], + ], + ], + defaultVariants: ['variant' => 'primary'], + ); + + $this->assertSame('component bg-blue-500', $component(['class' => true], 'base')); + $this->assertSame('component bg-blue-500', $component(['class' => false], 'base')); + $this->assertSame('component bg-blue-500', $component(['className' => true], 'base')); + $this->assertSame('component bg-blue-500', $component(['className' => false], 'base')); + } + + #[Test] + public function class_prop_normal_strings_still_work(): void + { + $component = tv( + base: ['base' => ['component']], + variants: [ + 'variant' => [ + 'primary' => ['base' => 'bg-blue-500'], + ], + ], + defaultVariants: ['variant' => 'primary'], + ); + + $this->assertSame( + 'component bg-blue-500 custom-class', + $component(['class' => 'custom-class'], 'base'), + ); + + $this->assertSame( + 'component bg-blue-500 another-class', + $component(['className' => 'another-class'], 'base'), + ); + + // Two unknown classes with the same prefix: tv() keeps both (unknown classes are never deduplicated) + $this->assertSame( + 'component bg-blue-500 custom-1 custom-2', + $component(['class' => 'custom-1 custom-2'], 'base'), + ); + } + + #[Test] + public function slot_based_class_with_boolean_in_array(): void + { + $component = tv( + base: [ + 'base' => ['component-base'], + 'label' => ['component-label'], + ], + variants: [ + 'variant' => [ + 'primary' => [ + 'base' => ['bg-blue-500'], + 'label' => ['text-white'], + ], + ], + ], + defaultVariants: ['variant' => 'primary'], + ); + + // Boolean in slot class array ignored; only the matching slot's value applied + $this->assertSame( + 'component-base bg-blue-500', + $component(['class' => ['base' => true, 'label' => 'extra']], 'base'), + ); + + $this->assertSame( + 'component-label text-white extra', + $component(['class' => ['base' => true, 'label' => 'extra']], 'label'), + ); + } + + #[Test] + public function empty_string_and_whitespace_still_filtered(): void + { + $component = tv(base: ['base' => ['component']]); + + $this->assertSame('component', $component(['class' => ''], 'base')); + $this->assertSame('component', $component(['class' => ' '], 'base')); + $this->assertSame('component', $component(['class' => false], 'base')); + } +} diff --git a/packages/class-variance/tests/CompoundVariantsAdvancedTest.php b/packages/class-variance/tests/CompoundVariantsAdvancedTest.php new file mode 100644 index 0000000000..7d82b07233 --- /dev/null +++ b/packages/class-variance/tests/CompoundVariantsAdvancedTest.php @@ -0,0 +1,133 @@ + ['component'], + 'content' => ['content-base'], + 'leading' => ['leading-base'], + ], + variants: [ + 'variant' => [ + 'solid' => ['base' => ['bg-solid']], + 'outline' => ['base' => ['border-outline']], + 'soft' => ['base' => ['bg-soft']], + 'subtle' => ['base' => ['bg-subtle']], + ], + 'compact' => [ + 'true' => ['content' => ['p-2']], + 'false' => ['content' => ['p-4']], + ], + ], + compoundVariants: [ + [ + 'variant' => ['solid', 'outline', 'soft', 'subtle'], + 'compact' => 'false', + 'class' => [ + 'content' => 'px-4 py-3 rounded-lg min-h-12', + 'leading' => 'mt-2', + ], + ], + ], + defaultVariants: [ + 'variant' => 'solid', + 'compact' => 'false', + ], + ); + + // Default: variant=solid, compact=false — compound matches + $this->assertSame( + 'content-base p-4 px-4 py-3 rounded-lg min-h-12', + $component(slot: 'content'), + ); + + $this->assertSame( + 'leading-base mt-2', + $component(slot: 'leading'), + ); + + // variant=outline, compact=false — compound still matches + $this->assertSame( + 'content-base p-4 px-4 py-3 rounded-lg min-h-12', + $component(['variant' => 'outline', 'compact' => 'false'], 'content'), + ); + + // variant=solid, compact=true — compound does NOT match + $this->assertSame( + 'content-base p-2', + $component(['variant' => 'solid', 'compact' => 'true'], 'content'), + ); + } + + #[Test] + public function compound_variant_with_multiple_array_conditions(): void + { + $component = tv( + base: ['base' => ['component']], + variants: [ + 'color' => [ + 'neutral' => ['base' => 'text-neutral'], + 'primary' => ['base' => 'text-primary'], + ], + 'variant' => [ + 'outline' => ['base' => 'border'], + 'subtle' => ['base' => 'bg-subtle'], + ], + 'multiple' => [ + 'true' => ['base' => 'multiple'], + 'false' => ['base' => 'single'], + ], + ], + compoundVariants: [ + [ + 'color' => 'neutral', + 'multiple' => 'true', + 'variant' => ['outline', 'subtle'], + 'class' => ['base' => 'has-focus-visible:ring-2 has-focus-visible:ring-inverted'], + ], + ], + defaultVariants: [ + 'color' => 'neutral', + 'variant' => 'outline', + 'multiple' => 'false', + ], + ); + + // Default: multiple=false — compound does NOT match + $this->assertSame( + 'component text-neutral border single', + $component(slot: 'base'), + ); + + // color=neutral, variant=outline, multiple=true — compound matches + $this->assertSame( + 'component text-neutral border multiple has-focus-visible:ring-2 has-focus-visible:ring-inverted', + $component(['color' => 'neutral', 'variant' => 'outline', 'multiple' => 'true'], 'base'), + ); + + // color=neutral, variant=subtle, multiple=true — compound matches + $this->assertSame( + 'component text-neutral bg-subtle multiple has-focus-visible:ring-2 has-focus-visible:ring-inverted', + $component(['color' => 'neutral', 'variant' => 'subtle', 'multiple' => 'true'], 'base'), + ); + + // color=primary — compound does NOT match + $this->assertSame( + 'component text-primary border multiple', + $component(['color' => 'primary', 'variant' => 'outline', 'multiple' => 'true'], 'base'), + ); + } +} diff --git a/packages/class-variance/tests/CvTest.php b/packages/class-variance/tests/CvTest.php new file mode 100644 index 0000000000..edff7d8f1b --- /dev/null +++ b/packages/class-variance/tests/CvTest.php @@ -0,0 +1,109 @@ + [ + 'primary' => ['bg-blue-500', 'text-white', 'border-transparent', 'hover:bg-blue-600'], + 'secondary' => ['bg-white', 'text-gray-800', 'border-gray-400', 'hover:bg-gray-100'], + ], + 'size' => [ + 'small' => ['text-sm', 'py-1', 'px-2'], + 'medium' => ['text-base', 'py-2', 'px-4'], + ], + ], + compoundVariants: [ + [ + 'variant' => 'primary', + 'size' => 'medium', + 'class' => 'uppercase', + ], + ], + defaultVariants: [ + 'variant' => 'primary', + 'size' => 'medium', + ], + ); + + $this->assertSame( + 'font-semibold border rounded bg-blue-500 text-white border-transparent hover:bg-blue-600 text-base py-2 px-4 uppercase', + $button(), + ); + + $this->assertSame( + 'font-semibold border rounded bg-white text-gray-800 border-gray-400 hover:bg-gray-100 text-sm py-1 px-2', + $button(['variant' => 'secondary', 'size' => 'small']), + ); + } + + #[Test] + public function class_prop_appended_and_takes_priority_over_classname(): void + { + $button = tv( + base: ['font-semibold', 'border', 'rounded'], + variants: [ + 'variant' => [ + 'secondary' => ['bg-white', 'text-gray-800', 'border-gray-400', 'hover:bg-gray-100'], + ], + 'size' => [ + 'small' => ['text-sm', 'py-1', 'px-2'], + ], + ], + defaultVariants: ['variant' => 'secondary', 'size' => 'small'], + ); + + // When both class and className are provided, class takes priority (className ignored) + $this->assertSame( + 'font-semibold border rounded bg-white text-gray-800 border-gray-400 hover:bg-gray-100 text-sm py-1 px-2 focus:ring-2', + $button(['class' => 'focus:ring-2', 'className' => 'focus:ring-4', 'variant' => 'secondary', 'size' => 'small']), + ); + + // className used when class is absent + $this->assertSame( + 'font-semibold border rounded bg-white text-gray-800 border-gray-400 hover:bg-gray-100 text-sm py-1 px-2 focus:ring-2', + $button(['className' => 'focus:ring-2', 'variant' => 'secondary', 'size' => 'small']), + ); + } + + #[Test] + public function cv_separator_deduplication(): void + { + // cv() uses separator heuristic: classes sharing a prefix-before-'-' conflict. + // Last class in the group wins — this is the intentional cv() behaviour. + $component = cv( + base: 'btn', + variants: [ + 'size' => [ + 'sm' => 'size-sm', + 'lg' => 'size-lg', + ], + 'intent' => [ + 'primary' => 'intent-primary', + 'danger' => 'intent-danger', + ], + ], + defaultVariants: ['size' => 'sm', 'intent' => 'primary'], + ); + + $this->assertSame('btn size-sm intent-primary', $component()); + $this->assertSame('btn size-lg intent-danger', $component(['size' => 'lg', 'intent' => 'danger'])); + + // Passing class= with a class that shares prefix with a base class: last wins + $this->assertSame('btn size-lg intent-primary', $component(['class' => 'size-lg'])); + } +} diff --git a/packages/class-variance/tests/CvWithSlotTest.php b/packages/class-variance/tests/CvWithSlotTest.php new file mode 100644 index 0000000000..b1f659b4aa --- /dev/null +++ b/packages/class-variance/tests/CvWithSlotTest.php @@ -0,0 +1,168 @@ + ['font-semibold', 'border', 'rounded'], + 'label' => [''], + ], + variants: [ + 'color' => [ + 'primary' => [ + 'base' => ['bg-blue-500', 'border-transparent', 'hover:bg-blue-600'], + 'label' => ['text-white'], + ], + 'secondary' => [ + 'base' => ['bg-white', 'border-gray-400', 'hover:bg-gray-100'], + 'label' => ['text-black'], + ], + ], + 'size' => [ + 'small' => [ + 'base' => ['py-1', 'px-2'], + 'label' => ['text-sm'], + ], + 'medium' => [ + 'base' => ['py-2', 'px-4'], + 'label' => ['text-base'], + ], + ], + ], + compoundVariants: [ + [ + 'color' => 'primary', + 'size' => 'medium', + 'class' => ['label' => 'uppercase'], + ], + ], + defaultVariants: [ + 'color' => 'primary', + 'size' => 'medium', + ], + ); + + $this->assertSame( + 'font-semibold border rounded bg-blue-500 border-transparent hover:bg-blue-600 py-2 px-4', + $button(slot: 'base'), + ); + + $this->assertSame( + 'text-white text-base uppercase', + $button(slot: 'label'), + ); + + $this->assertSame( + 'font-semibold border rounded bg-white border-gray-400 hover:bg-gray-100 py-1 px-2', + $button(props: ['color' => 'secondary', 'size' => 'small'], slot: 'base'), + ); + + $this->assertSame( + 'text-black text-sm', + $button(props: ['color' => 'secondary', 'size' => 'small'], slot: 'label'), + ); + + // class takes priority over className for the requested slot + $this->assertSame( + 'font-semibold border rounded bg-white border-gray-400 hover:bg-gray-100 py-1 px-2 focus:ring-2', + $button(props: ['class' => 'focus:ring-2', 'className' => 'focus:ring-4', 'color' => 'secondary', 'size' => 'small'], slot: 'base'), + ); + + $this->assertSame( + 'font-semibold border rounded bg-white border-gray-400 hover:bg-gray-100 py-1 px-2 focus:ring-2', + $button(props: ['className' => 'focus:ring-2', 'color' => 'secondary', 'size' => 'small'], slot: 'base'), + ); + + // Unknown slot returns empty string + $this->assertSame('', $button(slot: 'fooBarBaz')); + } + + #[Test] + public function implicit_slot_inference_for_single_slot_base(): void + { + $component = tv(base: ['base' => 'rounded bg-white']); + + // No slot argument — single slot inferred automatically + $this->assertSame('rounded bg-white', $component()); + } + + #[Test] + public function plain_string_base_targets_base_slot(): void + { + $component = tv( + base: 'font-bold text-sm', + variants: ['size' => ['lg' => 'text-lg']], + ); + + // tv() is Tailwind-aware: text-sm and text-lg conflict (both font-size group), text-lg wins + $this->assertSame('font-bold text-lg', $component(['size' => 'lg'], 'base')); + $this->assertSame('font-bold text-lg', $component(['size' => 'lg'])); + // Non-base slot returns empty for plain-string base + $this->assertSame('', $component(['size' => 'lg'], 'label')); + } + + #[Test] + public function multi_slot_base_without_slot_throws(): void + { + $component = cv(base: ['base' => 'a', 'label' => 'b']); + + $this->expectException(InvalidArgumentException::class); + $component(); // no slot — ambiguous + } + + #[Test] + public function plain_string_variant_value_implicitly_targets_base_slot(): void + { + $component = tv( + base: [ + 'base' => 'component', + 'label' => 'label-base', + ], + variants: [ + 'size' => [ + // plain string — implicit base + 'sm' => 'text-sm', + // explicit slot map + 'lg' => ['base' => 'text-lg', 'label' => 'text-lg-label'], + ], + ], + ); + + // Plain 'sm' string only affects base slot + $this->assertSame('component text-sm', $component(['size' => 'sm'], 'base')); + $this->assertSame('label-base', $component(['size' => 'sm'], 'label')); + + // Explicit slot map affects both slots + $this->assertSame('component text-lg', $component(['size' => 'lg'], 'base')); + $this->assertSame('label-base text-lg-label', $component(['size' => 'lg'], 'label')); + } + + #[Test] + public function cv_plain_string_base_targets_base_slot(): void + { + // cv() with non-Tailwind class names — separator heuristic only + $component = cv( + base: 'btn size-base', + variants: ['size' => ['lg' => 'size-lg']], + ); + + // size-base and size-lg share 'size' prefix → size-lg replaces size-base + $this->assertSame('btn size-lg', $component(['size' => 'lg'], 'base')); + // Non-base slot returns empty for plain-string base + $this->assertSame('', $component(['size' => 'lg'], 'label')); + } +}