diff --git a/.changeset/sharp-pugs-sing.md b/.changeset/sharp-pugs-sing.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/sharp-pugs-sing.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/ceramic/button.ts b/packages/ui/src/ceramic/button.ts new file mode 100644 index 00000000000..21326c01cfe --- /dev/null +++ b/packages/ui/src/ceramic/button.ts @@ -0,0 +1,79 @@ +import { style, variants } from './variants'; + +export const buttonStyles = variants({ + base: style(theme => ({ + appearance: 'none', + boxSizing: 'border-box', + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: theme.spacing[1], + height: theme.spacing[7], + borderRadius: theme.spacing[1.5], + fontSize: theme.typography.label[3].fontSize, + fontWeight: theme.fontWeights.medium, + fontFamily: theme.fontFamilies.sans, + border: 'none', + outline: 'none', + margin: 0, + paddingBlock: 0, + paddingInline: theme.spacing[3], + cursor: 'pointer', + textDecoration: 'none', + ...theme.fontRendering, + isolation: 'isolate', + transition: '300ms cubic-bezier(0.4, 0.36, 0, 1)', + '&::before': { + content: '""', + position: 'absolute', + inset: 0, + zIndex: -1, + borderRadius: 'inherit', + pointerEvents: 'none', + background: `linear-gradient(180deg, ${theme.alpha(theme.colors.white, 20)} 0%, ${theme.alpha(theme.colors.white, 0)} 100%)`, + opacity: 0.5, + transition: 'opacity 300ms cubic-bezier(0.4, 0.36, 0, 1)', + }, + '&::after': { + content: '""', + position: 'absolute', + inset: 0, + zIndex: -1, + borderRadius: 'inherit', + pointerEvents: 'none', + background: `linear-gradient(180deg, ${theme.alpha(theme.colors.white, 10)} 46%, ${theme.alpha(theme.colors.white, 0)} 54%)`, + mixBlendMode: 'overlay', + }, + '@media (hover: hover)': { + '&:hover::before': { + opacity: 1, + }, + }, + '&:focus-visible': { + ...theme.focusRing(), + }, + })), + variants: { + variant: { + primary: style(theme => ({ + background: theme.colors.purple[700], + color: theme.colors.white, + boxShadow: `${theme.colors.white} 0px 0px 0px 0px, ${theme.colors.purple[700]} 0px 0px 0px 1px, ${theme.alpha(theme.colors.white, 7)} 0px 1px 0px 0px inset, ${theme.alpha(theme.colors.gray[1300], 20)} 0px 1px 3px 0px`, + })), + secondary: style(theme => ({ + background: theme.colors.gray[1100], + color: theme.colors.white, + boxShadow: `${theme.colors.white} 0px 0px 0px 0px, ${theme.colors.gray[1100]} 0px 0px 0px 1px, ${theme.alpha(theme.colors.white, 7)} 0px 1px 0px 0px inset, ${theme.alpha(theme.colors.gray[1300], 20)} 0px 1px 3px 0px`, + })), + }, + fullWidth: { + true: { width: '100%' }, + false: {}, + }, + }, + defaultVariants: { + variant: 'primary', + fullWidth: false, + }, +}); diff --git a/packages/ui/src/ceramic/text.ts b/packages/ui/src/ceramic/text.ts new file mode 100644 index 00000000000..f7442050ba5 --- /dev/null +++ b/packages/ui/src/ceramic/text.ts @@ -0,0 +1,48 @@ +import { style, variants } from './variants'; + +export const textStyles = variants({ + base: style(theme => ({ + boxSizing: 'border-box', + padding: 0, + margin: 0, + background: 'none', + border: 'none', + fontFamily: theme.fontFamilies.sans, + textDecoration: 'none', + ...theme.fontRendering, + })), + variants: { + variant: { + 'heading-1': style(theme => ({ ...theme.typography.heading[1] })), + 'heading-2': style(theme => ({ ...theme.typography.heading[2] })), + 'heading-3': style(theme => ({ ...theme.typography.heading[3] })), + 'heading-4': style(theme => ({ ...theme.typography.heading[4] })), + 'heading-5': style(theme => ({ ...theme.typography.heading[5] })), + 'heading-6': style(theme => ({ ...theme.typography.heading[6] })), + 'label-1': style(theme => ({ ...theme.typography.label[1] })), + 'label-2': style(theme => ({ ...theme.typography.label[2] })), + 'label-3': style(theme => ({ ...theme.typography.label[3] })), + 'label-4': style(theme => ({ ...theme.typography.label[4] })), + 'label-5': style(theme => ({ ...theme.typography.label[5] })), + 'body-1': style(theme => ({ ...theme.typography.body[1] })), + 'body-2': style(theme => ({ ...theme.typography.body[2] })), + 'body-3': style(theme => ({ ...theme.typography.body[3] })), + 'body-4': style(theme => ({ ...theme.typography.body[4] })), + }, + color: { + default: style(theme => ({ color: theme.colors.primary })), + muted: style(theme => ({ color: theme.colors.secondary })), + subtle: style(theme => ({ color: theme.colors.dimmed })), + accent: style(theme => ({ color: theme.colors.brand })), + }, + font: { + sans: style(theme => ({ fontFamily: theme.fontFamilies.sans })), + mono: style(theme => ({ fontFamily: theme.fontFamilies.mono })), + }, + }, + defaultVariants: { + variant: 'body-2', + color: 'default', + font: 'sans', + }, +}); diff --git a/packages/ui/src/ceramic/theme-provider.tsx b/packages/ui/src/ceramic/theme-provider.tsx new file mode 100644 index 00000000000..ddebc749b9c --- /dev/null +++ b/packages/ui/src/ceramic/theme-provider.tsx @@ -0,0 +1,19 @@ +// eslint-disable-next-line no-restricted-imports +import createCache from '@emotion/cache'; +// eslint-disable-next-line no-restricted-imports +import { CacheProvider, ThemeContext } from '@emotion/react'; +import React from 'react'; + +import { type CeramicTheme, ceramicTheme } from './theme'; + +const ceramicCache = createCache({ key: 'ceramic' }); + +export const CeramicThemeProvider = ({ children }: { children: React.ReactNode }) => { + return ( + + {children} + + ); +}; + +export const useCeramicTheme = () => React.useContext(ThemeContext) as CeramicTheme; diff --git a/packages/ui/src/ceramic/theme.ts b/packages/ui/src/ceramic/theme.ts new file mode 100644 index 00000000000..4a25a2a087c --- /dev/null +++ b/packages/ui/src/ceramic/theme.ts @@ -0,0 +1,296 @@ +// Ceramic Colors (from CSS variables) +const gray = { + 50: '#fafafb', + 100: '#f6f6f7', + 200: '#ececee', + 300: '#dbdbe0', + 400: '#c7c7cf', + 500: '#adadb7', + 600: '#90909d', + 700: '#767684', + 800: '#5f5f6f', + 900: '#4c4c5c', + 1000: '#3d3d4a', + 1100: '#33333e', + 1200: '#2b2b34', + 1300: '#232328', + 1400: '#1b1b1f', + 1500: '#111113', +} as const; + +const purple = { + 50: '#f5f3ff', + 100: '#e3e0ff', + 200: '#ccc8ff', + 300: '#bab0ff', + 400: '#a698ff', + 500: '#9280ff', + 600: '#846bff', + 700: '#6c47ff', + 800: '#5f15fe', + 900: '#4d06d1', + 1000: '#3707a6', + 1100: '#27057c', + 1200: '#1c045f', + 1300: '#16034b', +} as const; + +const green = { + 50: '#effdf1', + 100: '#aff9bf', + 200: '#65f088', + 300: '#49dc6e', + 400: '#31c854', + 500: '#1eb43c', + 600: '#199d34', + 700: '#15892b', + 800: '#107524', + 900: '#09661c', + 1000: '#0b5619', + 1100: '#0c4919', + 1200: '#0c3c18', + 1300: '#053211', +} as const; + +const red = { + 50: '#fef8f8', + 100: '#fedddd', + 200: '#fec4c4', + 300: '#fca9a9', + 400: '#f98a8a', + 500: '#f86969', + 600: '#f73d3d', + 700: '#e02e2e', + 800: '#c22a2a', + 900: '#aa1b1b', + 1000: '#921414', + 1100: '#7a1313', + 1200: '#651414', + 1300: '#550e0e', + 1400: '#3d0101', + 1500: '#2d0101', +} as const; + +const orange = { + 50: '#fff8f2', + 100: '#ffe4c4', + 200: '#fecc9f', + 300: '#feb166', + 400: '#fd9357', + 500: '#fd7224', + 600: '#e06213', + 700: '#c3540f', + 800: '#a8470c', + 900: '#9d3405', + 1000: '#8a2706', + 1100: '#75220b', + 1200: '#5f1e0c', + 1300: '#50170a', +} as const; + +const yellow = { + 50: '#fefbdc', + 100: '#f7ed55', + 200: '#e5d538', + 300: '#d7be35', + 400: '#c0aa18', + 500: '#bd9005', + 600: '#a47c04', + 700: '#8d6b03', + 800: '#775902', + 900: '#674401', + 1000: '#563202', + 1100: '#412303', + 1200: '#321904', + 1300: '#2a1203', +} as const; + +const blue = { + 50: '#f6faff', + 100: '#daeafe', + 200: '#b4d5fe', + 300: '#8dc2fd', + 400: '#73acfa', + 500: '#6694f8', + 600: '#307ff6', + 700: '#236dd7', + 800: '#1c5bb6', + 900: '#1744a6', + 1000: '#0f318e', + 1100: '#0e2369', + 1200: '#0b1c49', + 1300: '#0c1637', +} as const; + +// Typography - Labels +const label = { + 1: { fontSize: '1rem', lineHeight: '1.375rem', fontWeight: 500 }, + 2: { fontSize: '0.875rem', lineHeight: '1.25rem', fontWeight: 500 }, + 3: { fontSize: '0.75rem', lineHeight: '1rem', fontWeight: 500 }, + 4: { + fontSize: '0.6875rem', + lineHeight: '0.875rem', + fontWeight: 500, + letterSpacing: '0.015em', + }, + 5: { fontSize: '0.625rem', lineHeight: '0.8125rem', fontWeight: 500 }, +} as const; + +// Typography - Headings +const heading = { + 1: { + fontSize: '2.25rem', + lineHeight: '2.5rem', + fontWeight: 500, + letterSpacing: '-0.02em', + }, + 2: { + fontSize: '2rem', + lineHeight: '2.25rem', + fontWeight: 500, + letterSpacing: '-0.02em', + }, + 3: { + fontSize: '1.75rem', + lineHeight: '2.125rem', + fontWeight: 500, + letterSpacing: '-0.015em', + }, + 4: { + fontSize: '1.5rem', + lineHeight: '2rem', + fontWeight: 500, + letterSpacing: '-0.01em', + }, + 5: { + fontSize: '1.25rem', + lineHeight: '1.75rem', + fontWeight: 500, + letterSpacing: '-0.01em', + }, + 6: { fontSize: '1.0625rem', lineHeight: '1.5rem', fontWeight: 500 }, +} as const; + +// Typography - Body +const body = { + 1: { fontSize: '1rem', lineHeight: '1.375rem', fontWeight: 400 }, + 2: { fontSize: '0.875rem', lineHeight: '1.25rem', fontWeight: 400 }, + 3: { + fontSize: '0.75rem', + lineHeight: '1rem', + fontWeight: 400, + letterSpacing: '0.01em', + }, + 4: { fontSize: '0.6875rem', lineHeight: '0.875rem', fontWeight: 400 }, +} as const; + +const fontWeights = { + normal: 400, + medium: 500, + semibold: 600, + bold: 700, +} as const; + +const fontFamilies = { + sans: 'system-ui, sans-serif', + mono: 'ui-monospace, monospace', +} as const; + +// Spacing (Tailwind-compatible scale, 1 unit = 0.25rem = 4px) +const spacing = { + 0: '0', + px: '1px', + 0.5: '0.125rem', + 1: '0.25rem', + 1.5: '0.375rem', + 2: '0.5rem', + 2.5: '0.625rem', + 3: '0.75rem', + 3.5: '0.875rem', + 4: '1rem', + 5: '1.25rem', + 6: '1.5rem', + 7: '1.75rem', + 8: '2rem', + 9: '2.25rem', + 10: '2.5rem', + 11: '2.75rem', + 12: '3rem', + 14: '3.5rem', + 16: '4rem', + 20: '5rem', + 24: '6rem', + 28: '7rem', + 32: '8rem', + 36: '9rem', + 40: '10rem', + 44: '11rem', + 48: '12rem', + 52: '13rem', + 56: '14rem', + 60: '15rem', + 64: '16rem', + 72: '18rem', + 80: '20rem', + 96: '24rem', +} as const; + +/** + * Creates a transparent color using color-mix(). + * Browser support: Chrome 111+, Firefox 113+, Safari 16.4+ + * For older browsers, consider using rgba() fallback or CSS @supports queries. + */ +const alpha = (color: string, opacity: number) => `color-mix(in srgb, ${color} ${opacity}%, transparent)`; +const negative = (value: string) => `-${value}`; + +const baseTheme = { + colors: { + gray, + purple, + green, + red, + orange, + yellow, + blue, + white: '#fff', + black: '#000', + // Semantic tokens (using dark palette values) + primary: gray[100], + secondary: gray[500], + dimmed: gray[800], + positive: green[400], + negative: red[600], + warning: orange[500], + brand: purple[600], + info: blue[600], + }, + typography: { + label, + heading, + body, + }, + fontWeights, + fontFamilies, + spacing, + alpha, + negative, +} as const; + +const fontRendering = { + WebkitFontSmoothing: 'auto', + MozOsxFontSmoothing: 'auto', + textRendering: 'auto', + WebkitTextSizeAdjust: '100%', + textSizeAdjust: '100%', +} as const; + +export const ceramicTheme = { + ...baseTheme, + focusRing: () => ({ + outline: `2px solid ${baseTheme.colors.purple[700]}`, + outlineOffset: '2px', + }), + fontRendering, +} as const; + +export type CeramicTheme = typeof ceramicTheme; diff --git a/packages/ui/src/ceramic/variants.test.ts b/packages/ui/src/ceramic/variants.test.ts new file mode 100644 index 00000000000..65a9b136f93 --- /dev/null +++ b/packages/ui/src/ceramic/variants.test.ts @@ -0,0 +1,380 @@ +// eslint-disable-next-line no-restricted-imports +import type { Interpolation, Theme } from '@emotion/react'; +import { describe, expect, it } from 'vitest'; + +import { ceramicTheme } from './theme'; +import { style, variants } from './variants'; + +// Helper to extract the CSS object from Interpolation +function getStyles(interpolation: Interpolation): Record { + if (typeof interpolation === 'function') { + return interpolation(ceramicTheme as unknown as Theme) as Record; + } + if (interpolation == null) { + return {}; + } + return interpolation as Record; +} + +describe('variants', () => { + it('applies base styles', () => { + const buttonStyles = variants({ + base: { padding: '1rem', margin: '0.5rem' }, + variants: {}, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ padding: '1rem', margin: '0.5rem' }); + }); + + it('applies base styles from function', () => { + const buttonStyles = variants({ + base: style(theme => ({ + padding: theme.spacing[4], + color: theme.colors.white, + })), + variants: {}, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ + padding: ceramicTheme.spacing[4], + color: ceramicTheme.colors.white, + }); + }); + + it('applies variant styles based on props', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem', padding: '0.5rem' }, + md: { fontSize: '1rem', padding: '1rem' }, + lg: { fontSize: '1.25rem', padding: '1.5rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ fontSize: '0.75rem', padding: '0.5rem' }); + }); + + it('applies variant styles from function', () => { + const buttonStyles = variants({ + variants: { + variant: { + primary: style(theme => ({ + background: theme.colors.purple[700], + color: theme.colors.white, + })), + secondary: style(theme => ({ + background: theme.colors.gray[200], + color: theme.colors.gray[1200], + })), + }, + }, + }); + + const result = getStyles(buttonStyles({ variant: 'primary' })); + expect(result).toEqual({ + background: ceramicTheme.colors.purple[700], + color: ceramicTheme.colors.white, + }); + }); + + it('applies multiple variants', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' })); + expect(result).toEqual({ fontSize: '0.75rem', background: 'blue' }); + }); + + it('applies default variants when props are not provided', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + defaultVariants: { + size: 'md', + variant: 'primary', + }, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ fontSize: '1rem', background: 'blue' }); + }); + + it('overrides default variants with props', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + defaultVariants: { + size: 'md', + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ fontSize: '0.75rem' }); + }); + + it('applies boolean variants with true/false keys', () => { + const buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + disabled: { + true: { opacity: 0.5, cursor: 'not-allowed' }, + false: { opacity: 1, cursor: 'pointer' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ fullWidth: true, disabled: false })); + expect(result).toEqual({ width: '100%', opacity: 1, cursor: 'pointer' }); + }); + + it('applies boolean variants from default variants', () => { + const buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + fullWidth: false, + }, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ width: 'fit-content' }); + }); + + it('applies compound variants when conditions match', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: { borderRadius: '0.25rem' }, + }, + { + condition: { size: 'md', variant: 'secondary' }, + styles: { borderRadius: '0.5rem' }, + }, + ], + }); + + const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' })); + expect(result).toEqual({ + fontSize: '0.75rem', + background: 'blue', + borderRadius: '0.25rem', + }); + }); + + it('applies compound variants with function styles', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: style(theme => ({ + border: `1px solid ${theme.colors.purple[700]}`, + })), + }, + ], + }); + + const result = getStyles(buttonStyles({ size: 'sm', variant: 'primary' })); + expect(result).toEqual({ + fontSize: '0.75rem', + background: 'blue', + border: `1px solid ${ceramicTheme.colors.purple[700]}`, + }); + }); + + it('does not apply compound variants when conditions do not match', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: { borderRadius: '0.25rem' }, + }, + ], + }); + + const result = getStyles(buttonStyles({ size: 'md', variant: 'primary' })); + expect(result).toEqual({ + fontSize: '1rem', + background: 'blue', + }); + }); + + it('merges base styles with variant styles', () => { + const buttonStyles = variants({ + base: { padding: '1rem', margin: '0.5rem' }, + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ + padding: '1rem', + margin: '0.5rem', + fontSize: '0.75rem', + }); + }); + + it('merges styles with later variants overriding earlier ones', () => { + const buttonStyles = variants({ + base: { fontSize: '1rem' }, + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'sm' })); + expect(result).toEqual({ fontSize: '0.75rem' }); + }); + + it('handles nested pseudo-selectors', () => { + const buttonStyles = variants({ + base: { + '&:hover': { opacity: 0.8 }, + '&:active': { opacity: 0.6 }, + }, + variants: { + variant: { + primary: { + '&:hover': { background: 'darkblue' }, + }, + }, + }, + }); + + const result = getStyles(buttonStyles({ variant: 'primary' })); + expect(result).toEqual({ + '&:hover': { opacity: 0.8, background: 'darkblue' }, + '&:active': { opacity: 0.6 }, + }); + }); + + it('handles null and undefined props', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + defaultVariants: { + size: 'md', + }, + }); + + const result1 = getStyles(buttonStyles({ size: null as any })); + const result2 = getStyles(buttonStyles({ size: undefined as any })); + expect(result1).toEqual({ fontSize: '1rem' }); + expect(result2).toEqual({ fontSize: '1rem' }); + }); + + it('handles empty variants object', () => { + const buttonStyles = variants({ + base: { padding: '1rem' }, + variants: {}, + }); + + const result = getStyles(buttonStyles()); + expect(result).toEqual({ padding: '1rem' }); + }); + + it('handles missing variant definition gracefully', () => { + const buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + }, + }); + + const result = getStyles(buttonStyles({ size: 'nonexistent' as any })); + expect(result).toEqual({}); + }); +}); + +describe('style', () => { + it('returns the function unchanged', () => { + const fn = (theme: typeof ceramicTheme) => ({ color: theme.colors.white }); + const result = style(fn); + expect(result).toBe(fn); + }); + + it('provides CeramicTheme typing', () => { + const fn = style(theme => ({ + color: theme.colors.white, + padding: theme.spacing[4], + })); + + const result = fn(ceramicTheme); + expect(result).toEqual({ + color: ceramicTheme.colors.white, + padding: ceramicTheme.spacing[4], + }); + }); +}); diff --git a/packages/ui/src/ceramic/variants.ts b/packages/ui/src/ceramic/variants.ts new file mode 100644 index 00000000000..99a75721dc7 --- /dev/null +++ b/packages/ui/src/ceramic/variants.ts @@ -0,0 +1,183 @@ +import { fastDeepMergeAndReplace } from '@clerk/shared/utils'; +// eslint-disable-next-line no-restricted-imports +import type { Interpolation, Theme } from '@emotion/react'; + +import { type CeramicTheme } from './theme'; + +type CSSObject = Record; +// StyleFunction uses CeramicTheme to provide proper typing for theme parameter +type StyleFunction = (theme: CeramicTheme) => CSSObject; +type StyleRule = CSSObject | StyleFunction; + +// Convert string literal "true" | "false" to boolean (CVA's StringToBoolean) +type StringToBoolean = T extends 'true' | 'false' ? boolean : T; + +/** + * Extracts variant props type from a variant style function. + * + * @example + * ```ts + * const buttonStyles = variants({ + * variants: { + * size: { sm: {...}, md: {...} }, + * variant: { primary: {...}, secondary: {...} }, + * }, + * }); + * + * type ButtonProps = VariantProps; + * // { size?: 'sm' | 'md' | null | undefined; variant?: 'primary' | 'secondary' | null | undefined } + * + * function Button(props: ButtonProps) { + * return + * ``` + */ +export function variants(config: VariantsConfig) { + const { base, variants: variantDefinitions = {} as T, defaultVariants = {}, compoundVariants = [] } = config; + + return (props: ConfigVariants = {}): Interpolation => { + return ((theme: Theme) => { + const ceramicTheme = theme as unknown as CeramicTheme; + const computedStyles: CSSObject = {}; + const variantDefs = variantDefinitions as Record>; + + const baseStyles = resolveStyleRule(base, ceramicTheme); + if (baseStyles && typeof baseStyles === 'object') { + fastDeepMergeAndReplace(baseStyles, computedStyles); + } + + const variantsToApply = calculateVariantsToBeApplied( + variantDefs, + props as Record, + defaultVariants as Record, + ); + + for (const variantKey in variantsToApply) { + const variantValue = variantsToApply[variantKey]; + const variantDef = variantDefs[variantKey]; + const normalizedValue = normalizeVariantValue(variantValue); + if (variantDef && normalizedValue in variantDef) { + const variantStyle = resolveStyleRule(variantDef[normalizedValue], ceramicTheme); + fastDeepMergeAndReplace(variantStyle, computedStyles); + } + } + + for (const compoundVariant of compoundVariants) { + if (conditionMatches(compoundVariant, variantsToApply)) { + const compoundStyles = resolveStyleRule(compoundVariant.styles, ceramicTheme); + fastDeepMergeAndReplace(compoundStyles, computedStyles); + } + } + + return computedStyles; + }) as unknown as Interpolation; + }; +} diff --git a/packages/ui/src/ceramic/variants.type.test.ts b/packages/ui/src/ceramic/variants.type.test.ts new file mode 100644 index 00000000000..44c0bd5da36 --- /dev/null +++ b/packages/ui/src/ceramic/variants.type.test.ts @@ -0,0 +1,253 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { CeramicTheme } from './theme'; +import { style, variants } from './variants'; + +describe('variants type tests', () => { + describe('boolean variants', () => { + it('allows boolean values in defaultVariants for true/false keys', () => { + const _buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + fullWidth: false, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ fullWidth: false }>().toMatchTypeOf(); + expectTypeOf<{ fullWidth: true }>().toMatchTypeOf(); + }); + + it('allows boolean values in props for true/false keys', () => { + const _buttonStyles = variants({ + variants: { + disabled: { + true: { opacity: 0.5 }, + false: { opacity: 1 }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ disabled: true }>().toMatchTypeOf(); + expectTypeOf<{ disabled: false }>().toMatchTypeOf(); + }); + + it('does not allow string values for boolean variants', () => { + const _buttonStyles = variants({ + variants: { + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ fullWidth: 'true' }>().not.toMatchTypeOf(); + }); + }); + + describe('string variants', () => { + it('allows string literal values in defaultVariants', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + lg: { fontSize: '1.25rem' }, + }, + }, + defaultVariants: { + size: 'md', + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: 'sm' }>().toMatchTypeOf(); + expectTypeOf<{ size: 'md' }>().toMatchTypeOf(); + expectTypeOf<{ size: 'lg' }>().toMatchTypeOf(); + }); + + it('does not allow invalid string values', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: 'xl' }>().not.toMatchTypeOf(); + }); + }); + + describe('mixed variants', () => { + it('allows mixing boolean and string variants', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + defaultVariants: { + size: 'sm', + fullWidth: false, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: 'md'; fullWidth: true }>().toMatchTypeOf(); + }); + }); + + describe('style helper', () => { + it('provides CeramicTheme typing for theme parameter', () => { + const fn = style(theme => { + // Theme parameter should be typed as CeramicTheme + return { color: theme.colors.white }; + }); + + expectTypeOf(fn).toBeFunction(); + expectTypeOf[0]>().toEqualTypeOf(); + }); + + it('returns StyleFunction type', () => { + const _fn = style(theme => ({ color: theme.colors.white })); + type Return = ReturnType; + expectTypeOf().toMatchTypeOf>(); + }); + }); + + describe('props type inference', () => { + it('infers correct props type from variants', () => { + const _buttonStyles = variants({ + variants: { + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + // Props should allow optional variant and size + expectTypeOf<{ variant?: 'primary' | 'secondary'; size?: 'sm' | 'md' }>().toMatchTypeOf(); + }); + + it('allows partial props', () => { + const _buttonStyles = variants({ + variants: { + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ variant: 'primary' }>().toMatchTypeOf(); + expectTypeOf<{ size: 'md' }>().toMatchTypeOf(); + expectTypeOf>().toMatchTypeOf(); + }); + + it('allows null and undefined in props', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + }, + }); + + type Props = Parameters[0]; + expectTypeOf<{ size: null }>().toMatchTypeOf(); + expectTypeOf<{ size: undefined }>().toMatchTypeOf(); + }); + }); + + describe('compound variants', () => { + it('allows compound variants with correct condition types', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + md: { fontSize: '1rem' }, + }, + variant: { + primary: { background: 'blue' }, + secondary: { background: 'gray' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', variant: 'primary' }, + styles: { borderRadius: '0.25rem' }, + }, + ], + }); + + // Type should compile without errors + expectTypeOf(_buttonStyles).toBeFunction(); + }); + + it('allows boolean values in compound variant conditions', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + fullWidth: { + true: { width: '100%' }, + false: { width: 'fit-content' }, + }, + }, + compoundVariants: [ + { + condition: { size: 'sm', fullWidth: true }, + styles: { padding: '0.5rem' }, + }, + ], + }); + + // Type should compile without errors + expectTypeOf(_buttonStyles).toBeFunction(); + }); + }); + + describe('return type', () => { + it('returns Interpolation', () => { + const _buttonStyles = variants({ + variants: { + size: { + sm: { fontSize: '0.75rem' }, + }, + }, + }); + + // Verify the function can be called with props + expectTypeOf(_buttonStyles({ size: 'sm' })).toMatchTypeOf(); + }); + }); +});