From 911e6b228d9ead58d52b912890ec78aae87a5285 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Sat, 4 Apr 2026 19:34:09 +0100 Subject: [PATCH 1/8] wip --- composer.json | 3 + packages/class-variance/composer.json | 24 + packages/class-variance/phpunit.xml | 23 + packages/class-variance/src/ClassMerger.php | 18 + packages/class-variance/src/ClassNames.php | 108 +++++ packages/class-variance/src/ClassVariance.php | 22 + .../class-variance/src/Classmaps/Classmap.php | 131 +++++ .../src/Classmaps/GenericClassmap.php | 22 + .../src/Classmaps/TailwindClassmap.php | 450 ++++++++++++++++++ .../src/Config/ClassVarianceConfig.php | 19 + .../src/Config/GenericClassVarianceConfig.php | 30 ++ .../Config/TailwindClassVarianceConfig.php | 46 ++ .../src/CvMergerInitializer.php | 31 ++ .../src/GenericClassVariance.php | 35 ++ .../class-variance/src/GroupClassMerger.php | 117 +++++ .../class-variance/src/ResolvesVariants.php | 155 ++++++ .../src/SeparatorClassMerger.php | 69 +++ .../src/TvMergerInitializer.php | 35 ++ packages/class-variance/src/functions.php | 61 +++ 19 files changed, 1399 insertions(+) create mode 100644 packages/class-variance/composer.json create mode 100644 packages/class-variance/phpunit.xml create mode 100644 packages/class-variance/src/ClassMerger.php create mode 100644 packages/class-variance/src/ClassNames.php create mode 100644 packages/class-variance/src/ClassVariance.php create mode 100644 packages/class-variance/src/Classmaps/Classmap.php create mode 100644 packages/class-variance/src/Classmaps/GenericClassmap.php create mode 100644 packages/class-variance/src/Classmaps/TailwindClassmap.php create mode 100644 packages/class-variance/src/Config/ClassVarianceConfig.php create mode 100644 packages/class-variance/src/Config/GenericClassVarianceConfig.php create mode 100644 packages/class-variance/src/Config/TailwindClassVarianceConfig.php create mode 100644 packages/class-variance/src/CvMergerInitializer.php create mode 100644 packages/class-variance/src/GenericClassVariance.php create mode 100644 packages/class-variance/src/GroupClassMerger.php create mode 100644 packages/class-variance/src/ResolvesVariants.php create mode 100644 packages/class-variance/src/SeparatorClassMerger.php create mode 100644 packages/class-variance/src/TvMergerInitializer.php create mode 100644 packages/class-variance/src/functions.php diff --git a/composer.json b/composer.json index cd01adc93..121047d51 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/packages/class-variance/composer.json b/packages/class-variance/composer.json new file mode 100644 index 000000000..b839078e8 --- /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 000000000..94b26e80b --- /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 000000000..791096a4a --- /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|array|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)) { + 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 000000000..a908a50f1 --- /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 000000000..2870b06ff --- /dev/null +++ b/packages/class-variance/src/Classmaps/Classmap.php @@ -0,0 +1,131 @@ + ['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 (! is_array($matcher)) { + 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 = array_key_first($matcher); + $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) { + $groups[$groupId] = array_merge($groups[$groupId] ?? [], $matchers); + } + + $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 + { + return new self( + array_replace($this->classGroups, $replacements->classGroups), + array_replace($this->conflictingClassGroups, $replacements->conflictingClassGroups), + ); + } +} diff --git a/packages/class-variance/src/Classmaps/GenericClassmap.php b/packages/class-variance/src/Classmaps/GenericClassmap.php new file mode 100644 index 000000000..d3a2d82e8 --- /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/Config/ClassVarianceConfig.php b/packages/class-variance/src/Config/ClassVarianceConfig.php new file mode 100644 index 000000000..854f6eeb8 --- /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 000000000..06446c6ee --- /dev/null +++ b/packages/class-variance/src/Config/TailwindClassVarianceConfig.php @@ -0,0 +1,46 @@ +extend !== null) { + $groups = $groups->extend($this->extend); + } + + if ($this->override !== null) { + $groups = $groups->override($this->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 000000000..0d6f97ff1 --- /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 000000000..3da788227 --- /dev/null +++ b/packages/class-variance/src/GenericClassVariance.php @@ -0,0 +1,35 @@ +>|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>> $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 000000000..711b9d4d0 --- /dev/null +++ b/packages/class-variance/src/GroupClassMerger.php @@ -0,0 +1,117 @@ + $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]'] + */ + 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 000000000..dbb22a020 --- /dev/null +++ b/packages/class-variance/src/ResolvesVariants.php @@ -0,0 +1,155 @@ +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 */ + private function applyDefaultVariants(array $props): array + { + foreach ($this->defaultVariants 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); + } + + if (count($this->base) > 1) { + 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 + { + $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(); + + foreach ($props as $key => $value) { + $entry = $this->variants[$key][$value] ?? 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(); + + foreach ($this->compoundVariants as $compound) { + if (! $this->compoundMatches($props, $compound)) { + continue; + } + + $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' || $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 000000000..cdcd7c2d5 --- /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 !== null) { + $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 000000000..8085a8ba7 --- /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 000000000..4f5ce5ba4 --- /dev/null +++ b/packages/class-variance/src/functions.php @@ -0,0 +1,61 @@ +>>|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>> $variants + * @param array> $compoundVariants + * @param array $defaultVariants + */ +function cv( + array|string $base, + array $variants = [], + array $compoundVariants = [], + array $defaultVariants = [], + ?ClassVarianceConfig $config = null, +): ClassVariance { + $config ??= 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>>|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>> $variants + * @param array> $compoundVariants + * @param array $defaultVariants + */ +function tv( + array|string $base, + array $variants = [], + array $compoundVariants = [], + array $defaultVariants = [], + ?ClassVarianceConfig $config = null, +): ClassVariance { + $config ??= new TailwindClassVarianceConfig(); + + return new GenericClassVariance($base, $config->merger, $variants, $compoundVariants, $defaultVariants); +} From aa0f655d7bc2f76307849142411ae5cc7501b97b Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Sat, 4 Apr 2026 19:48:40 +0100 Subject: [PATCH 2/8] tests(class-variance): added new testsuite --- .../tests/ClassNamesBooleanTest.php | 103 +++++++++++ .../tests/CompoundVariantsAdvancedTest.php | 133 ++++++++++++++ packages/class-variance/tests/CvTest.php | 109 ++++++++++++ .../class-variance/tests/CvWithSlotTest.php | 168 ++++++++++++++++++ 4 files changed, 513 insertions(+) create mode 100644 packages/class-variance/tests/ClassNamesBooleanTest.php create mode 100644 packages/class-variance/tests/CompoundVariantsAdvancedTest.php create mode 100644 packages/class-variance/tests/CvTest.php create mode 100644 packages/class-variance/tests/CvWithSlotTest.php diff --git a/packages/class-variance/tests/ClassNamesBooleanTest.php b/packages/class-variance/tests/ClassNamesBooleanTest.php new file mode 100644 index 000000000..2aa02af5d --- /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 000000000..7d82b0723 --- /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 000000000..edff7d8f1 --- /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 000000000..b1f659b4a --- /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')); + } +} From 12e1d174dec4ca452c7c03a423b49ad5fcc39416 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Sat, 4 Apr 2026 19:49:04 +0100 Subject: [PATCH 3/8] refactor(class-variance): fixing mago errors --- packages/class-variance/src/ClassNames.php | 2 +- .../class-variance/src/Classmaps/Classmap.php | 21 +++++----- .../Config/TailwindClassVarianceConfig.php | 10 +++-- .../class-variance/src/GroupClassMerger.php | 2 + .../class-variance/src/ResolvesVariants.php | 38 +++++++++++++++---- .../src/SeparatorClassMerger.php | 2 +- 6 files changed, 52 insertions(+), 23 deletions(-) diff --git a/packages/class-variance/src/ClassNames.php b/packages/class-variance/src/ClassNames.php index 07d70ca30..438245b97 100644 --- a/packages/class-variance/src/ClassNames.php +++ b/packages/class-variance/src/ClassNames.php @@ -29,7 +29,7 @@ private function __construct( * Passing $slot = '' is the passthrough context (e.g. extra $props['class']) * and always emits the classes regardless of slot. * - * @param string|array|bool $input + * @param string|list|bool>|array|bool>|bool $input */ public static function of(string|array|bool $input, string $slot = ''): self { diff --git a/packages/class-variance/src/Classmaps/Classmap.php b/packages/class-variance/src/Classmaps/Classmap.php index 2870b06ff..2eafa6e9a 100644 --- a/packages/class-variance/src/Classmaps/Classmap.php +++ b/packages/class-variance/src/Classmaps/Classmap.php @@ -60,10 +60,6 @@ public function findGroup(string $class): ?string continue; } - if (! is_array($matcher)) { - continue; - } - if (array_is_list($matcher)) { // ['prefix'] — wildcard: matches prefix itself or prefix-{anything} $prefix = $matcher[0]; @@ -73,7 +69,8 @@ public function findGroup(string $class): ?string } } else { // ['prefix' => ['suffix1', 'suffix2']] — constrained suffix list - $prefix = array_key_first($matcher); + $prefix = (string) array_key_first($matcher); + /** @var list $suffixes */ $suffixes = $matcher[$prefix]; foreach ($suffixes as $suffix) { @@ -102,7 +99,9 @@ public function extend(self $additions): self $groups = $this->classGroups; foreach ($additions->classGroups as $groupId => $matchers) { - $groups[$groupId] = array_merge($groups[$groupId] ?? [], $matchers); + /** @var list>|array{0: string}> $merged */ + $merged = array_merge($groups[$groupId] ?? [], $matchers); + $groups[$groupId] = $merged; } $conflicts = $this->conflictingClassGroups; @@ -123,9 +122,11 @@ public function extend(self $additions): self */ public function override(self $replacements): self { - return new self( - array_replace($this->classGroups, $replacements->classGroups), - array_replace($this->conflictingClassGroups, $replacements->conflictingClassGroups), - ); + /** @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/Config/TailwindClassVarianceConfig.php b/packages/class-variance/src/Config/TailwindClassVarianceConfig.php index 06446c6ee..05f81a50a 100644 --- a/packages/class-variance/src/Config/TailwindClassVarianceConfig.php +++ b/packages/class-variance/src/Config/TailwindClassVarianceConfig.php @@ -32,12 +32,14 @@ public function __construct( get { $groups = TailwindClassmap::default(); - if ($this->extend !== null) { - $groups = $groups->extend($this->extend); + $extend = $this->extend; + if ($extend instanceof Classmap) { + $groups = $groups->extend($extend); } - if ($this->override !== null) { - $groups = $groups->override($this->override); + $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/GroupClassMerger.php b/packages/class-variance/src/GroupClassMerger.php index 711b9d4d0..d0e1ae91a 100644 --- a/packages/class-variance/src/GroupClassMerger.php +++ b/packages/class-variance/src/GroupClassMerger.php @@ -74,6 +74,8 @@ public function merge(string ...$classes): string * * 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 { diff --git a/packages/class-variance/src/ResolvesVariants.php b/packages/class-variance/src/ResolvesVariants.php index dbb22a020..cbc44124a 100644 --- a/packages/class-variance/src/ResolvesVariants.php +++ b/packages/class-variance/src/ResolvesVariants.php @@ -9,17 +9,24 @@ /** * Core variant resolution engine, shared by all ClassVariance implementations. * - * The class using this trait must declare the following public readonly properties: + * The class using this trait must declare the following public readable properties: * - array|string $base * - ClassMerger $merger * - array $variants * - array $compoundVariants * - array $defaultVariants * + * @property-read array|string $base + * @property-read ClassMerger $merger + * @property-read array $variants + * @property-read array $compoundVariants + * @property-read array $defaultVariants + * * @internal */ trait ResolvesVariants { + /** @param array $props */ public function __invoke(array $props = [], string $slot = ''): string { $props = $this->applyDefaultVariants($props); @@ -33,10 +40,16 @@ public function __invoke(array $props = [], string $slot = ''): string return $this->merger->merge(...$classes->toArray()); } - /** @param array $props */ + /** + * @param array $props + * @return array + */ private function applyDefaultVariants(array $props): array { - foreach ($this->defaultVariants as $key => $value) { + /** @var array $defaults */ + $defaults = $this->defaultVariants; + + foreach ($defaults as $key => $value) { $props[$key] ??= $value; } @@ -76,6 +89,7 @@ private function resolveSlot(string $slot): string */ private function resolvePassthrough(array $props, string $slot): ClassNames { + /** @var string|array|bool $value */ $value = $props['class'] ?? $props['className'] ?? ''; if (is_bool($value) || $value === '') { @@ -98,8 +112,12 @@ private function resolveVariants(array $props, string $slot): ClassNames { $classes = ClassNames::empty(); + /** @var array>> $variants */ + $variants = $this->variants; + foreach ($props as $key => $value) { - $entry = $this->variants[$key][$value] ?? null; + $lookupKey = is_bool($value) ? ($value ? 'true' : 'false') : $value; + $entry = $variants[$key][$lookupKey] ?? null; if ($entry === null) { continue; @@ -118,11 +136,15 @@ private function resolveCompoundVariants(array $props, string $slot): ClassNames { $classes = ClassNames::empty(); - foreach ($this->compoundVariants as $compound) { + /** @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)); } @@ -137,10 +159,12 @@ private function resolveCompoundVariants(array $props, string $slot): ClassNames private function compoundMatches(array $props, array $compound): bool { foreach ($compound as $key => $value) { - if ($key === 'class' || $key === 'className') { + 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; diff --git a/packages/class-variance/src/SeparatorClassMerger.php b/packages/class-variance/src/SeparatorClassMerger.php index cdcd7c2d5..bb32bac84 100644 --- a/packages/class-variance/src/SeparatorClassMerger.php +++ b/packages/class-variance/src/SeparatorClassMerger.php @@ -49,7 +49,7 @@ public function merge(string ...$classes): string private function resolveGroup(string $class): string { // Explicit group map takes priority over separator heuristic. - if ($this->classGroups !== null) { + if ($this->classGroups instanceof Classmap) { $group = $this->classGroups->findGroup($class); if ($group !== null) { From f58be751aff86f73ea81432a71de9f82689a7064 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Sat, 4 Apr 2026 20:50:36 +0100 Subject: [PATCH 4/8] feat(class-variance): wip --- .../src/Commands/PublishConfigCommand.php | 102 ++++++++++++++++++ packages/class-variance/src/functions.php | 11 +- 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 packages/class-variance/src/Commands/PublishConfigCommand.php diff --git a/packages/class-variance/src/Commands/PublishConfigCommand.php b/packages/class-variance/src/Commands/PublishConfigCommand.php new file mode 100644 index 000000000..860436165 --- /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/functions.php b/packages/class-variance/src/functions.php index 4f5ce5ba4..6a55f15a6 100644 --- a/packages/class-variance/src/functions.php +++ b/packages/class-variance/src/functions.php @@ -7,6 +7,7 @@ use Tempest\ClassVariance\Config\ClassVarianceConfig; use Tempest\ClassVariance\Config\GenericClassVarianceConfig; use Tempest\ClassVariance\Config\TailwindClassVarianceConfig; +use Tempest\Container\GenericContainer; /** * Create a class variance authority with generic (non-Tailwind) merging. @@ -29,7 +30,10 @@ function cv( array $defaultVariants = [], ?ClassVarianceConfig $config = null, ): ClassVariance { - $config ??= new GenericClassVarianceConfig(); + $container = GenericContainer::instance(); + $config ??= $container?->has(GenericClassVarianceConfig::class) + ? $container->get(GenericClassVarianceConfig::class) + : new GenericClassVarianceConfig(); return new GenericClassVariance($base, $config->merger, $variants, $compoundVariants, $defaultVariants); } @@ -55,7 +59,10 @@ function tv( array $defaultVariants = [], ?ClassVarianceConfig $config = null, ): ClassVariance { - $config ??= new TailwindClassVarianceConfig(); + $container = GenericContainer::instance(); + $config ??= $container?->has(TailwindClassVarianceConfig::class) + ? $container->get(TailwindClassVarianceConfig::class) + : new TailwindClassVarianceConfig(); return new GenericClassVariance($base, $config->merger, $variants, $compoundVariants, $defaultVariants); } From 62c931e5411d99c08d230a9a6f4d146ecb468489 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Sat, 4 Apr 2026 20:50:57 +0100 Subject: [PATCH 5/8] docs(class-variance): docs for class-variance --- docs/3-packages/03-class-variance.md | 345 +++++++++++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 docs/3-packages/03-class-variance.md diff --git a/docs/3-packages/03-class-variance.md b/docs/3-packages/03-class-variance.md new file mode 100644 index 000000000..b6072a01f --- /dev/null +++ b/docs/3-packages/03-class-variance.md @@ -0,0 +1,345 @@ +--- +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 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 From ba6a46253ef5d43b2148a674053798c983679d2a Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Sat, 4 Apr 2026 20:58:30 +0100 Subject: [PATCH 6/8] docs(class-variance): docs tweaks --- docs/3-packages/03-class-variance.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/3-packages/03-class-variance.md b/docs/3-packages/03-class-variance.md index b6072a01f..9b117db02 100644 --- a/docs/3-packages/03-class-variance.md +++ b/docs/3-packages/03-class-variance.md @@ -4,15 +4,13 @@ description: "The Class Variance package can be used standalone or with Tempest --- ## Introduction -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. +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 From 34cac615dcfa5f076449e71a66cd142a540e7cbb Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Sat, 4 Apr 2026 21:02:52 +0100 Subject: [PATCH 7/8] chore(class-variance): add LICENSE and gitattributes --- packages/class-variance/.gitattributes | 14 ++++++++++++++ packages/class-variance/LICENSE.md | 9 +++++++++ 2 files changed, 23 insertions(+) create mode 100644 packages/class-variance/.gitattributes create mode 100644 packages/class-variance/LICENSE.md diff --git a/packages/class-variance/.gitattributes b/packages/class-variance/.gitattributes new file mode 100644 index 000000000..3f7775660 --- /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 000000000..54215b726 --- /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. From 96cc97b4d6819ddc64c374eb867f17c1c136c948 Mon Sep 17 00:00:00 2001 From: iamdadmin Date: Sat, 4 Apr 2026 21:22:12 +0100 Subject: [PATCH 8/8] style(class-variance): fixes from phpstan --- packages/class-variance/src/ClassNames.php | 6 +++-- packages/class-variance/src/ClassVariance.php | 2 +- .../src/GenericClassVariance.php | 4 +-- .../class-variance/src/ResolvesVariants.php | 26 ++++++++++++------- packages/class-variance/src/functions.php | 8 +++--- 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/class-variance/src/ClassNames.php b/packages/class-variance/src/ClassNames.php index 438245b97..7d51a2f4b 100644 --- a/packages/class-variance/src/ClassNames.php +++ b/packages/class-variance/src/ClassNames.php @@ -54,9 +54,11 @@ public static function of(string|array|bool $input, string $slot = ''): self $items = []; foreach ($input as $entry) { - if (is_string($entry)) { - array_push($items, ...self::tokenise($entry)); + if (! is_string($entry)) { + continue; } + + array_push($items, ...self::tokenise($entry)); } return new self($items); diff --git a/packages/class-variance/src/ClassVariance.php b/packages/class-variance/src/ClassVariance.php index a908a50f1..6c25f5e8e 100644 --- a/packages/class-variance/src/ClassVariance.php +++ b/packages/class-variance/src/ClassVariance.php @@ -14,7 +14,7 @@ interface ClassVariance /** * Resolve the class string for the given props and slot. * - * @param array $props Active variant prop values. + * @param array> $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. */ diff --git a/packages/class-variance/src/GenericClassVariance.php b/packages/class-variance/src/GenericClassVariance.php index 3da788227..b8cf975b4 100644 --- a/packages/class-variance/src/GenericClassVariance.php +++ b/packages/class-variance/src/GenericClassVariance.php @@ -13,10 +13,10 @@ use ResolvesVariants; /** - * @param array>|string $base + * @param array|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>> $variants + * @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 diff --git a/packages/class-variance/src/ResolvesVariants.php b/packages/class-variance/src/ResolvesVariants.php index cbc44124a..29e59b1bb 100644 --- a/packages/class-variance/src/ResolvesVariants.php +++ b/packages/class-variance/src/ResolvesVariants.php @@ -26,7 +26,7 @@ */ trait ResolvesVariants { - /** @param array $props */ + /** @param array> $props */ public function __invoke(array $props = [], string $slot = ''): string { $props = $this->applyDefaultVariants($props); @@ -41,8 +41,8 @@ public function __invoke(array $props = [], string $slot = ''): string } /** - * @param array $props - * @return array + * @param array> $props + * @return array> */ private function applyDefaultVariants(array $props): array { @@ -66,9 +66,7 @@ private function resolveSlot(string $slot): string if (is_array($this->base) && ! array_is_list($this->base)) { if (count($this->base) === 1) { return (string) array_key_first($this->base); - } - - if (count($this->base) > 1) { + } else { throw new InvalidArgumentException( 'Multiple slots defined but no slot specified. Available slots: ' . implode(', ', array_keys($this->base)), ); @@ -106,7 +104,7 @@ private function resolvePassthrough(array $props, string $slot): ClassNames } /** - * @param array $props + * @param array> $props */ private function resolveVariants(array $props, string $slot): ClassNames { @@ -116,7 +114,15 @@ private function resolveVariants(array $props, string $slot): ClassNames $variants = $this->variants; foreach ($props as $key => $value) { - $lookupKey = is_bool($value) ? ($value ? 'true' : 'false') : $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) { @@ -130,7 +136,7 @@ private function resolveVariants(array $props, string $slot): ClassNames } /** - * @param array $props + * @param array> $props */ private function resolveCompoundVariants(array $props, string $slot): ClassNames { @@ -153,7 +159,7 @@ private function resolveCompoundVariants(array $props, string $slot): ClassNames } /** - * @param array $props + * @param array> $props * @param array $compound */ private function compoundMatches(array $props, array $compound): bool diff --git a/packages/class-variance/src/functions.php b/packages/class-variance/src/functions.php index 6a55f15a6..adef20fec 100644 --- a/packages/class-variance/src/functions.php +++ b/packages/class-variance/src/functions.php @@ -16,10 +16,10 @@ * Pass a ClassGroupMap via GenericClassVarianceConfig to declare explicit * conflict groups for classes that share no common prefix. * - * @param array>>|string $base + * @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>> $variants + * @param array|array>>> $variants * @param array> $compoundVariants * @param array $defaultVariants */ @@ -45,10 +45,10 @@ function cv( * Pass a TailwindClassVarianceConfig to extend/override groups for custom * plugins or a non-standard Tailwind prefix / separator. * - * @param array>>|string $base + * @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>> $variants + * @param array|array>>> $variants * @param array> $compoundVariants * @param array $defaultVariants */