diff --git a/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx b/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx index 13cd609898c..7382ede8a21 100644 --- a/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx +++ b/packages/ui/src/components/devPrompts/EnableOrganizationsPrompt/index.tsx @@ -1,8 +1,6 @@ import { createContextAndHook, useClerk } from '@clerk/shared/react'; import type { __internal_EnableOrganizationsPromptProps, EnableEnvironmentSettingParams } from '@clerk/shared/types'; // eslint-disable-next-line no-restricted-imports -import type { SerializedStyles } from '@emotion/react'; -// eslint-disable-next-line no-restricted-imports import { css } from '@emotion/react'; import React, { forwardRef, useId, useLayoutEffect, useRef, useState } from 'react'; @@ -10,13 +8,14 @@ import { useEnvironment } from '@/ui/contexts'; import { Modal } from '@/ui/elements/Modal'; import { InternalThemeProvider } from '@/ui/styledSystem'; -import { Flex } from '../../../customizables'; import { Portal } from '../../../elements/Portal'; -import { basePromptElementStyles, ClerkLogoIcon, PromptContainer, PromptSuccessIcon } from '../shared'; +import { buttonStyles } from '../../../mosaic/button'; +import { textStyles } from '../../../mosaic/text'; +import { MosaicThemeProvider, useMosaicTheme } from '../../../mosaic/theme-provider'; const organizationsDashboardUrl = 'https://dashboard.clerk.com/~/organizations-settings'; -const EnableOrganizationsPromptInternal = ({ +const EnableOrganizationsPromptContent = ({ caller, onSuccess, onClose, @@ -30,6 +29,7 @@ const EnableOrganizationsPromptInternal = ({ const initialFocusRef = useRef(null); const environment = useEnvironment(); const radioGroupLabelId = useId(); + const theme = useMosaicTheme(); const isComponent = !caller.startsWith('use'); @@ -64,220 +64,327 @@ const EnableOrganizationsPromptInternal = ({ }; return ( - - ({ alignItems: 'center' })} - initialFocusRef={initialFocusRef} +
+
- ({ - display: 'flex', - flexDirection: 'column', - width: '30rem', - maxWidth: 'calc(100vw - 2rem)', - })} +
+ + + + + +

Enable Organizations

+
+
- ({ - padding: `${t.sizes.$4} ${t.sizes.$6}`, - paddingBottom: t.sizes.$4, - gap: t.sizes.$2, +

- ({ - gap: t.sizes.$2, - })} - > - - -

+

+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Nisi, earum? +

+

+
+
+ + +
+
+ ); + + return ( +
+
+
+ + +

+ {isEnabled ? 'Organizations feature enabled' : 'Organizations feature required'} +

+
+ +
+ {isEnabled ? ( +

+ {clerk.user && defaultOrganizationName + ? `The Organizations feature has been enabled for your application. A default organization named "${defaultOrganizationName}" was created automatically. You can manage or rename it in your` + : `The Organizations feature has been enabled for your application. You can manage it in your`}{' '} + - {isEnabled ? 'Organizations feature enabled' : 'Organizations feature required'} - - - - ({ - gap: t.sizes.$0x5, - })} - > - {isEnabled ? ( -

- {clerk.user && defaultOrganizationName - ? `The Organizations feature has been enabled for your application. A default organization named "${defaultOrganizationName}" was created automatically. You can manage or rename it in your` - : `The Organizations feature has been enabled for your application. You can manage it in your`}{' '} - - dashboard - - . -

- ) : ( - <> -

- Enable Organizations to use{' '} - - {isComponent ? `<${caller} />` : caller} - {' '} -

- - - Learn more - - - )} - - - {hasPersonalAccountsEnabled && !isEnabled && ( - ({ marginTop: t.sizes.$2 })} - direction='col' + dashboard + + . +

+ ) : ( + <> +

+ Enable Organizations to use{' '} + + {isComponent ? `<${caller} />` : caller} + {' '} +

+ + - setAllowPersonalAccount(value === 'optional')} - labelledBy={radioGroupLabelId} - > - ({ columnGap: t.sizes.$2, rowGap: t.sizes.$1 })} - > - Membership required - Standard -
- } - description={ - <> - Users need to belong to at least one organization. - Common for most B2B SaaS applications - - } - /> - - - - )} - - - + + )} +
+ + {hasPersonalAccountsEnabled && !isEnabled && ( +
+ > + setAllowPersonalAccount(value === 'optional')} + labelledBy={radioGroupLabelId} + > + + Membership required + Standard + + } + description={ + <> + + Users need to belong to at least one organization. + + Common for most B2B SaaS applications + + } + /> + + +
+ )} +
- ({ - padding: `${t.sizes.$4} ${t.sizes.$6}`, - gap: t.sizes.$3, - justifyContent: 'flex-end', - })} +
+ {isEnabled ? ( + + ) : ( + <> + + + + + )} +
+
+ ); +}; + +const EnableOrganizationsPromptInternal = (props: __internal_EnableOrganizationsPromptProps): JSX.Element => { + const initialFocusRef = useRef(null); + + return ( + + ({ alignItems: 'center' })} + initialFocusRef={initialFocusRef} + > + + + ); @@ -295,112 +402,27 @@ export const EnableOrganizationsPrompt = (props: __internal_EnableOrganizationsP ); }; -const baseButtonStyles = css` - ${basePromptElementStyles}; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - height: 1.75rem; - padding: 0.375rem 0.625rem; - border-radius: 0.375rem; - font-size: 0.75rem; - font-weight: 500; - letter-spacing: 0.12px; - color: white; - text-shadow: 0px 1px 2px rgba(0, 0, 0, 0.32); - white-space: nowrap; - user-select: none; - color: white; - outline: none; - - &:not(:disabled) { - transition: 120ms ease-in-out; - transition-property: background-color, border-color, box-shadow, color; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - &:focus-visible:not(:disabled) { - outline: 2px solid white; - outline-offset: 2px; - } -`; - -const buttonSolidStyles = css` - background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30.5%, rgba(0, 0, 0, 0.05) 100%), #454545; - box-shadow: - 0 0 3px 0 rgba(253, 224, 71, 0) inset, - 0 0 0 1px rgba(255, 255, 255, 0.04) inset, - 0 1px 0 0 rgba(255, 255, 255, 0.04) inset, - 0 0 0 1px rgba(0, 0, 0, 0.12), - 0 1.5px 2px 0 rgba(0, 0, 0, 0.48); - - &:hover:not(:disabled) { - background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30.5%, rgba(0, 0, 0, 0.15) 100%), #5f5f5f; - box-shadow: - 0 0 3px 0 rgba(253, 224, 71, 0) inset, - 0 0 0 1px rgba(255, 255, 255, 0.04) inset, - 0 1px 0 0 rgba(255, 255, 255, 0.04) inset, - 0 0 0 1px rgba(0, 0, 0, 0.12), - 0 1.5px 2px 0 rgba(0, 0, 0, 0.48); - } -`; - -const buttonOutlineStyles = css` - border: 1px solid rgba(118, 118, 132, 0.25); - background: rgba(69, 69, 69, 0.1); - - &:hover:not(:disabled) { - border-color: rgba(118, 118, 132, 0.5); - } -`; - -const buttonVariantStyles = { - solid: buttonSolidStyles, - outline: buttonOutlineStyles, -} as const; - -type PromptButtonVariant = keyof typeof buttonVariantStyles; - -type PromptButtonProps = Pick, 'onClick' | 'children' | 'disabled'> & { - variant?: PromptButtonVariant; -}; - -const PromptButton = forwardRef(({ variant = 'solid', ...props }, ref) => { - return ( - ) : ( - - {claimed ? 'Get API keys' : 'Claim application'} - + {claimed ? 'Get API keys' : 'Configure your application'} +function getStyles(interpolation: Interpolation): Record { + if (typeof interpolation === 'function') { + return interpolation(mosaicTheme 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: mosaicTheme.spacing[4], + color: mosaicTheme.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: mosaicTheme.colors.purple[700], + color: mosaicTheme.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 ${mosaicTheme.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 mosaicTheme) => ({ color: theme.colors.white }); + const result = style(fn); + expect(result).toBe(fn); + }); + + it('provides MosaicTheme typing', () => { + const fn = style(theme => ({ + color: theme.colors.white, + padding: theme.spacing[4], + })); + + const result = fn(mosaicTheme); + expect(result).toEqual({ + color: mosaicTheme.colors.white, + padding: mosaicTheme.spacing[4], + }); + }); +}); diff --git a/packages/ui/src/mosaic/__tests__/variants.type.test.ts b/packages/ui/src/mosaic/__tests__/variants.type.test.ts new file mode 100644 index 00000000000..6db8bb23b2a --- /dev/null +++ b/packages/ui/src/mosaic/__tests__/variants.type.test.ts @@ -0,0 +1,253 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { MosaicTheme } 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 MosaicTheme typing for theme parameter', () => { + const fn = style(theme => { + // Theme parameter should be typed as MosaicTheme + 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(); + }); + }); +}); diff --git a/packages/ui/src/mosaic/button.ts b/packages/ui/src/mosaic/button.ts new file mode 100644 index 00000000000..21326c01cfe --- /dev/null +++ b/packages/ui/src/mosaic/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/mosaic/index.ts b/packages/ui/src/mosaic/index.ts new file mode 100644 index 00000000000..4f4209f7c55 --- /dev/null +++ b/packages/ui/src/mosaic/index.ts @@ -0,0 +1,4 @@ +export { variants } from './variants'; +export { buttonStyles } from './button'; +export { MosaicThemeProvider, useMosaicTheme } from './theme-provider'; +export { mosaicTheme, type MosaicTheme } from './theme'; diff --git a/packages/ui/src/mosaic/text.ts b/packages/ui/src/mosaic/text.ts new file mode 100644 index 00000000000..4dd2939aa46 --- /dev/null +++ b/packages/ui/src/mosaic/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.white })), + muted: style(theme => ({ color: theme.colors.gray[500] })), + subtle: style(theme => ({ color: theme.colors.gray[400] })), + accent: style(theme => ({ color: theme.colors.purple[400] })), + }, + 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/mosaic/theme.ts b/packages/ui/src/mosaic/theme.ts index 97a6a2bef71..281d8494306 100644 --- a/packages/ui/src/mosaic/theme.ts +++ b/packages/ui/src/mosaic/theme.ts @@ -235,10 +235,15 @@ const spacing = { 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}`; -export const mosaicTheme = { +const baseTheme = { colors: { gray, purple, @@ -262,4 +267,25 @@ export const mosaicTheme = { negative, } as const; +const fontRendering = { + WebkitFontSmoothing: 'auto', + MozOsxFontSmoothing: 'auto', + textRendering: 'auto', + WebkitTextSizeAdjust: '100%', + textSizeAdjust: '100%', +} as const; + +export const mosaicTheme = { + ...baseTheme, + shadows: { + card: () => + `0px 0px 0px 0.5px ${baseTheme.colors.gray[1200]} inset, 0px 1px 0px 0px ${baseTheme.alpha(baseTheme.colors.white, 8)} inset, 0px 0px 0.8px 0.8px ${baseTheme.alpha(baseTheme.colors.white, 20)} inset, 0px 0px 0px 0px ${baseTheme.alpha(baseTheme.colors.white, 72)}, 0px 16px 36px -6px ${baseTheme.alpha(baseTheme.colors.black, 36)}, 0px 6px 16px -2px ${baseTheme.alpha(baseTheme.colors.black, 20)}`, + }, + focusRing: () => ({ + outline: `2px solid ${baseTheme.colors.purple[700]}`, + outlineOffset: '2px', + }), + fontRendering, +} as const; + export type MosaicTheme = typeof mosaicTheme; diff --git a/packages/ui/src/mosaic/variants.ts b/packages/ui/src/mosaic/variants.ts new file mode 100644 index 00000000000..45fb2623c6f --- /dev/null +++ b/packages/ui/src/mosaic/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 MosaicTheme } from './theme'; + +type CSSObject = Record; +// StyleFunction uses MosaicTheme to provide proper typing for theme parameter +type StyleFunction = (theme: MosaicTheme) => 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 type VariantProps any> = Parameters[0]; + +// Maps variant names to their allowed values (with boolean conversion) +// This is the key type that allows boolean values for variants with true/false keys +type ConfigVariants = { + [K in keyof T]?: StringToBoolean | null | undefined; +}; + +// Config type - no constraint on T to preserve literal types +// NoInfer prevents TypeScript from using defaultVariants/compoundVariants for T inference +type VariantsConfig = { + base?: StyleRule; + variants?: T; + defaultVariants?: ConfigVariants>; + compoundVariants?: Array<{ + condition: ConfigVariants>; + styles?: StyleRule; + }>; +}; + +/** + * Identity function that provides MosaicTheme typing for style functions. + * Use this to get autocomplete and type checking for theme properties. + * + * @example + * ```ts + * const buttonStyles = variants({ + * base: style(theme => ({ padding: theme.spacing[2] })), + * variants: { + * variant: { + * primary: style(theme => ({ background: theme.colors.purple[700] })), + * }, + * }, + * }); + * ``` + */ +export const style = (fn: StyleFunction): StyleFunction => fn; + +function resolveStyleRule(rule: StyleRule | undefined, theme: MosaicTheme): CSSObject { + if (!rule) { + return {}; + } + if (typeof rule === 'function') { + return rule(theme); + } + return rule; +} + +function normalizeVariantValue(value: unknown): string { + return String(value); +} + +function calculateVariantsToBeApplied( + variants: Record>, + props: Record, + defaultVariants: Record, +): Record { + const variantsToApply: Record = {}; + for (const key in variants) { + const value = props[key] ?? defaultVariants[key]; + if (value != null) { + variantsToApply[key] = value; + } + } + return variantsToApply; +} + +function conditionMatches( + compoundVariant: { condition: Record; styles?: StyleRule }, + variantsToApply: Record, +): boolean { + const { condition } = compoundVariant; + for (const key in condition) { + if (condition[key] !== variantsToApply[key]) { + return false; + } + } + return true; +} + +/** + * Creates a variant-based style function for Emotion CSS objects. + * Similar to CVA but works with CSS-in-JS instead of class names. + * + * @example + * ```ts + * const buttonStyles = variants({ + * base: (theme) => ({ padding: theme.spacing[2] }), + * variants: { + * variant: { + * primary: (theme) => ({ background: theme.colors.purple[700] }), + * secondary: (theme) => ({ background: theme.colors.gray[1200] }), + * }, + * size: { + * sm: { fontSize: '0.75rem' }, + * md: { fontSize: '1rem' }, + * }, + * }, + * defaultVariants: { + * variant: 'primary', + * size: 'md', + * }, + * }); + * + * // Usage: + * + * ``` + */ +export function variants(config: VariantsConfig) { + const { base, variants: variantDefinitions = {} as T, defaultVariants = {}, compoundVariants = [] } = config; + + return (props: ConfigVariants = {}): Interpolation => { + return ((theme: Theme) => { + const mosaicTheme = theme as unknown as MosaicTheme; + const computedStyles: CSSObject = {}; + const variantDefs = variantDefinitions as Record>; + + const baseStyles = resolveStyleRule(base, mosaicTheme); + 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], mosaicTheme); + fastDeepMergeAndReplace(variantStyle, computedStyles); + } + } + + for (const compoundVariant of compoundVariants) { + if (conditionMatches(compoundVariant, variantsToApply)) { + const compoundStyles = resolveStyleRule(compoundVariant.styles, mosaicTheme); + fastDeepMergeAndReplace(compoundStyles, computedStyles); + } + } + + return computedStyles; + }) as unknown as Interpolation; + }; +}