Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/cva-optional-variants.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty changeset for a feature PR.

This changeset file contains only the --- delimiters with no package entries or summary. Based on learnings, empty changesets are acceptable only for documentation-only or internal-tooling PRs that do not affect published packages.

This PR adds a new cx export and makes the variants field optional—a feature addition to packages/ui. A proper changeset should include:

  1. The package name (@clerk/ui or equivalent) with a minor bump (new feature)
  2. A summary describing the optional variants and the new cx helper
📝 Example changeset content
 ---
+'`@clerk/ui`': minor
 ---
+
+Make the `variants` field optional in `cva` config and add a new `cx` helper for base-only styles without variants. The `cx` function is a thin wrapper around `cva({ base })` that returns a full `CvaFn` compatible with tooling.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/cva-optional-variants.md around lines 1 - 2, The changeset file
is empty; replace the bare `---` contents with a proper changeset entry that
lists the affected package (e.g., `@clerk/ui: minor`) and a short summary
describing the change (make `variants` optional and add the new `cx`
export/helper), so the release tooling will create a minor bump for the UI
package and document the feature; ensure the summary mentions both "optional
variants" and "new cx helper/export" and save the file with the same filename.

Source: Learnings

38 changes: 37 additions & 1 deletion packages/ui/src/mosaic/__tests__/cva.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest';

import { cva } from '../cva';
import { cva, cx } from '../cva';
import type { SxProp, VariantProps } from '../cva';
import { defaultMosaicVariables, resolveVariables } from '../variables';
import type { MosaicTheme } from '../variables';
Expand Down Expand Up @@ -719,3 +719,39 @@ describe('type safety', () => {
});
});
});

describe('cva without variants', () => {
it('applies base styles and merges sx', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cva({ base: { color: 'red' } });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});

describe('cx', () => {
it('resolves a static style object', () => {
const styles = cx({ color: 'red' });
expect(styles()(mockTheme)).toEqual({ color: 'red' });
});

it('resolves a theme function', () => {
const styles = cx(theme => ({ color: theme.color.primary }));
expect(styles()(mockTheme)).toEqual({ color: mockTheme.color.primary });
});

it('merges sx over base styles', () => {
const styles = cx({ color: 'red', opacity: 1 });
expect(styles({ sx: { opacity: 0.5 } })(mockTheme)).toEqual({ color: 'red', opacity: 0.5 });
});

it('exposes empty variant metadata for tooling', () => {
const styles = cx({ color: 'red' });
expect(styles._variants).toEqual({});
expect(styles._defaultVariants).toEqual({});
});
});
74 changes: 48 additions & 26 deletions packages/ui/src/mosaic/cva.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ type CompoundVariant<V extends Variants> = VariantPropsOf<V> & { css: StyleRule

type CvaConfig<V extends Variants> = {
base?: StyleRule;
variants: V;
variants?: V;
compoundVariants?: Array<CompoundVariant<V>>;
defaultVariants?: VariantPropsOf<V>;
};
Expand Down Expand Up @@ -78,9 +78,13 @@ type CvaFn<V extends Variants> = {
* // In a component:
* css={buttonStyles({ size: 'sm', sx: { opacity: 0.8 } })(theme)}
*/
export function cva<V extends Variants>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configFn: (theme: MosaicTheme) => CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>)): CvaFn<V> {
export function cva<V extends Variants = Record<never, never>>(config: CvaConfig<V>): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configFn: (theme: MosaicTheme) => CvaConfig<V>,
): CvaFn<V>;
export function cva<V extends Variants = Record<never, never>>(
configOrFn: CvaConfig<V> | ((theme: MosaicTheme) => CvaConfig<V>),
): CvaFn<V> {
const configCache = typeof configOrFn === 'function' ? new WeakMap<MosaicTheme, CvaConfig<V>>() : null;
const fn = ((props: VariantPropsOf<V> & { sx?: SxProp } = {} as VariantPropsOf<V>) =>
(theme: MosaicTheme): StyleRule => {
Expand All @@ -95,17 +99,30 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa
} else {
config = configOrFn as CvaConfig<V>;
}
const { base, variants = {} as V, compoundVariants = [], defaultVariants = {} } = config;
const resolved = resolveVariants(variants, props, defaultVariants);
const { base, variants, compoundVariants, defaultVariants = EMPTY } = config;
const computedStyles: StyleRule = {};
if (base) fastDeepMergeAndReplace(base, computedStyles);
for (const key in resolved) {
const rule = variants[key]?.[resolved[key]];

// Resolve and merge each variant axis in a single pass. The `resolved` map is only
// needed to match compound variants, so it's built lazily — components without
// compound variants (the common case) skip the allocation entirely.
const hasCompounds = !!compoundVariants && compoundVariants.length > 0;
const resolved: Record<string, string> | null = hasCompounds ? {} : null;
for (const key in variants) {
const propValue = (props as Record<string, any>)[key];
const raw = propValue !== undefined ? propValue : defaultVariants[key];
if (raw === undefined) continue;
const value = typeof raw === 'boolean' ? String(raw) : raw;
const rule = variants[key][value];
if (rule) fastDeepMergeAndReplace(rule, computedStyles);
if (resolved) resolved[key] = value;
}
for (const cv of compoundVariants) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
if (resolved) {
for (const cv of compoundVariants!) {
if (compoundMatches(cv, resolved)) fastDeepMergeAndReplace(cv.css, computedStyles);
}
}

if (props.sx) {
const sxStyles = typeof props.sx === 'function' ? props.sx(theme) : props.sx;
fastDeepMergeAndReplace(sxStyles, computedStyles);
Expand All @@ -115,30 +132,35 @@ export function cva<V extends Variants>(configOrFn: CvaConfig<V> | ((theme: Mosa

const resolvedConfig =
typeof configOrFn === 'function' ? configOrFn(resolveVariables(defaultMosaicVariables)) : configOrFn;
fn._variants = resolvedConfig.variants;
fn._variants = (resolvedConfig.variants ?? {}) as V;
fn._defaultVariants = (resolvedConfig.defaultVariants ?? {}) as VariantPropsOf<V>;

return fn;
}

// ─── Internal ─────────────────────────────────────────────────────────────────
// ─── cx ─────────────────────────────────────────────────────────────────────--

/** Resolves each variant axis to a string key, preferring explicit props over defaults. Booleans are stringified to match variant map keys. */
function resolveVariants(
variants: Variants,
props: Record<string, any>,
defaults: Record<string, any>,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const key in variants) {
const value = props[key] !== undefined ? props[key] : defaults[key];
if (value !== undefined) {
resolved[key] = typeof value === 'boolean' ? String(value) : value;
}
}
return resolved;
/**
* Sugar over `cva` for styles with no variants — just base rules plus `sx`.
*
* Equivalent to `cva({ base })` but skips the `base:` wrapper. The result is a
* full `cva` function (carries empty `_variants`/`_defaultVariants`), so it stays
* compatible with tooling that reads variant metadata.
*
* @example
* const boxStyles = cx(theme => ({ color: theme.color.primary }));
* // In a component:
* css={boxStyles({ sx: { opacity: 0.8 } })(theme)}
*/
export function cx(styles: StyleRule | ((theme: MosaicTheme) => StyleRule)): CvaFn<Record<never, never>> {
return typeof styles === 'function' ? cva(theme => ({ base: styles(theme) })) : cva({ base: styles });
}

// ─── Internal ─────────────────────────────────────────────────────────────────

/** Shared empty defaults object — avoids allocating one per resolve when a config omits `defaultVariants`. */
const EMPTY: Record<string, any> = {};

/** Returns true when all conditions in a compound variant entry match the resolved variant set. */
function compoundMatches(cv: Record<string, any>, resolved: Record<string, string>): boolean {
for (const key in cv) {
Expand Down
Loading