diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index d3168940d15..aee36f53958 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -4,7 +4,7 @@ import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react- import type { Appearance as StripeAppearance, SetupIntent } from '@stripe/stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import type { PropsWithChildren } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import useSWR from 'swr'; import useSWRMutation from 'swr/mutation'; @@ -14,12 +14,12 @@ import { Form } from '@/ui/elements/Form'; import { FormButtons } from '@/ui/elements/FormButtons'; import { FormContainer } from '@/ui/elements/FormContainer'; import { handleError } from '@/ui/utils/errorHandler'; -import { normalizeColorString } from '@/ui/utils/normalizeColorString'; import { clerkUnsupportedEnvironmentWarning } from '../../../core/errors'; import { useEnvironment, useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts'; import { descriptors, Flex, localizationKeys, Spinner, useAppearance, useLocalizations } from '../../customizables'; import type { LocalizationKey } from '../../localization'; +import { resolveComputedCSSColor, resolveComputedCSSProperty } from '../../utils/cssVariables'; type AddPaymentSourceProps = { onSuccess: (context: { stripeSetupIntent?: SetupIntent }) => Promise; @@ -84,30 +84,72 @@ const AddPaymentSourceRoot = ({ children, ...rest }: PropsWithChildren(undefined); const [submitLabel, setSubmitLabel] = useState(undefined); + const { colors, fontWeights, fontSizes, radii, space } = useAppearance().parsedInternalTheme; + const [elementsAppearance, setElementsAppearance] = useState({}); + useEffect(() => { void initializePaymentSource(); - }, []); + }, [initializePaymentSource]); - return ( - { + if (!node) { + return; + } + + const appearance: StripeAppearance = { + variables: { + colorPrimary: resolveComputedCSSColor(node, colors.$primary500, colors.$colorBackground), + colorBackground: resolveComputedCSSColor(node, colors.$colorInputBackground, colors.$colorBackground), + colorText: resolveComputedCSSColor(node, colors.$colorText, colors.$colorBackground), + colorTextSecondary: resolveComputedCSSColor(node, colors.$colorTextSecondary, colors.$colorBackground), + colorSuccess: resolveComputedCSSColor(node, colors.$success500, colors.$colorBackground), + colorDanger: resolveComputedCSSColor(node, colors.$danger500, colors.$colorBackground), + colorWarning: resolveComputedCSSColor(node, colors.$warning500, colors.$colorBackground), + fontWeightNormal: resolveComputedCSSProperty(node, 'font-weight', fontWeights.$normal.toString()), + fontWeightMedium: resolveComputedCSSProperty(node, 'font-weight', fontWeights.$medium.toString()), + fontWeightBold: resolveComputedCSSProperty(node, 'font-weight', fontWeights.$bold.toString()), + fontSizeXl: resolveComputedCSSProperty(node, 'font-size', fontSizes.$xl), + fontSizeLg: resolveComputedCSSProperty(node, 'font-size', fontSizes.$lg), + fontSizeSm: resolveComputedCSSProperty(node, 'font-size', fontSizes.$md), + fontSizeXs: resolveComputedCSSProperty(node, 'font-size', fontSizes.$sm), + borderRadius: resolveComputedCSSProperty(node, 'border-radius', radii.$lg), + spacingUnit: resolveComputedCSSProperty(node, 'padding', space.$1), }, - }} - > - {children} - + }; + + setElementsAppearance(appearance); + }, + [colors, fontWeights, fontSizes, radii, space], + ); + + return ( + <> +
+ + {children} + + ); }; @@ -122,29 +164,7 @@ const AddPaymentSourceLoading = (props: PropsWithChildren) => { }; const AddPaymentSourceReady = (props: PropsWithChildren) => { - const { externalClientSecret, stripe } = useAddPaymentSourceContext(); - - const { colors, fontWeights, fontSizes, radii, space } = useAppearance().parsedInternalTheme; - const elementsAppearance: StripeAppearance = { - variables: { - colorPrimary: normalizeColorString(colors.$primary500), - colorBackground: normalizeColorString(colors.$colorInputBackground), - colorText: normalizeColorString(colors.$colorText), - colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), - colorSuccess: normalizeColorString(colors.$success500), - colorDanger: normalizeColorString(colors.$danger500), - colorWarning: normalizeColorString(colors.$warning500), - fontWeightNormal: fontWeights.$normal.toString(), - fontWeightMedium: fontWeights.$medium.toString(), - fontWeightBold: fontWeights.$bold.toString(), - fontSizeXl: fontSizes.$xl, - fontSizeLg: fontSizes.$lg, - fontSizeSm: fontSizes.$md, - fontSizeXs: fontSizes.$sm, - borderRadius: radii.$md, - spacingUnit: space.$1, - }, - }; + const { externalClientSecret, stripe, elementsAppearance } = useAddPaymentSourceContext(); if (!stripe || !externalClientSecret) { return null; diff --git a/packages/clerk-js/src/ui/customizables/parseVariables.ts b/packages/clerk-js/src/ui/customizables/parseVariables.ts index db660335073..ae67908752b 100644 --- a/packages/clerk-js/src/ui/customizables/parseVariables.ts +++ b/packages/clerk-js/src/ui/customizables/parseVariables.ts @@ -50,12 +50,11 @@ export const createRadiiUnits = (theme: Theme) => { } const md = borderRadius === 'none' ? '0' : borderRadius; - const { numericValue, unit = 'rem' } = splitCssUnit(md); return { - sm: (numericValue * 0.66).toString() + unit, + sm: `calc(${md} * 0.66)`, md, - lg: (numericValue * 1.33).toString() + unit, - xl: (numericValue * 2).toString() + unit, + lg: `calc(${md} * 1.33)`, + xl: `calc(${md} * 2)`, }; }; @@ -64,12 +63,11 @@ export const createSpaceScale = (theme: Theme) => { if (spacingUnit === undefined) { return; } - const { numericValue, unit } = splitCssUnit(spacingUnit); return fromEntries( spaceScaleKeys.map(k => { const num = Number.parseFloat(k.replace('x', '.')); const percentage = (num / 0.5) * 0.125; - return [k, `${numericValue * percentage}${unit}`]; + return [k, `calc(${spacingUnit} * ${percentage})`]; }), ); }; @@ -82,13 +80,12 @@ export const createFontSizeScale = (theme: Theme): Record { const { fontFamily, fontFamilyButtons } = theme.variables || {}; return removeUndefinedProps({ main: fontFamily, buttons: fontFamilyButtons }); }; - -const splitCssUnit = (str: string) => { - const numericValue = Number.parseFloat(str); - const unit = str.replace(numericValue.toString(), '') || undefined; - return { numericValue, unit }; -}; diff --git a/packages/clerk-js/src/ui/utils/__tests__/cssVariables.spec.ts b/packages/clerk-js/src/ui/utils/__tests__/cssVariables.spec.ts new file mode 100644 index 00000000000..83a57ed898a --- /dev/null +++ b/packages/clerk-js/src/ui/utils/__tests__/cssVariables.spec.ts @@ -0,0 +1,522 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + extractCSSVariableValue, + extractCSSVariableValueWithFallback, + extractMultipleCSSVariables, + isCSSVariable, + resolveComputedCSSColor, + resolveComputedCSSProperty, + resolveCSSVariable, +} from '../cssVariables'; + +// Mock DOM APIs +const mockGetComputedStyle = vi.fn(); +const mockGetPropertyValue = vi.fn(); + +// Setup DOM mocks +Object.defineProperty(window, 'getComputedStyle', { + value: mockGetComputedStyle, + writable: true, +}); + +// Mock document.documentElement +Object.defineProperty(document, 'documentElement', { + value: { + style: {}, + }, + writable: true, +}); + +describe('CSS Variable Utilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Setup getComputedStyle mock + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: mockGetPropertyValue, + }); + mockGetPropertyValue.mockReturnValue(''); + }); + + describe('isCSSVariable', () => { + it('should return true for valid CSS variables', () => { + expect(isCSSVariable('var(--color)')).toBe(true); + expect(isCSSVariable('var(--primary-color)')).toBe(true); + expect(isCSSVariable('var(--color, red)')).toBe(true); + expect(isCSSVariable('var(--color, rgba(255, 0, 0, 0.5))')).toBe(true); + expect(isCSSVariable('var( --color )')).toBe(true); // with spaces + }); + + it('should return false for invalid CSS variables', () => { + expect(isCSSVariable('--color')).toBe(false); + expect(isCSSVariable('color')).toBe(false); + expect(isCSSVariable('red')).toBe(false); + expect(isCSSVariable('#ff0000')).toBe(false); + expect(isCSSVariable('rgb(255, 0, 0)')).toBe(false); + expect(isCSSVariable('var(color)')).toBe(false); // missing -- + expect(isCSSVariable('var(--)')).toBe(false); // empty variable name + }); + + it('should handle edge cases', () => { + expect(isCSSVariable('')).toBe(false); + expect(isCSSVariable(' ')).toBe(false); + // @ts-expect-error Testing runtime behavior + expect(isCSSVariable(null)).toBe(false); + // @ts-expect-error Testing runtime behavior + expect(isCSSVariable(undefined)).toBe(false); + }); + }); + + describe('extractCSSVariableValue', () => { + it('should extract values from different variable name formats', () => { + mockGetPropertyValue.mockReturnValue('red'); + + expect(extractCSSVariableValue('var(--color)')).toBe('red'); + expect(extractCSSVariableValue('--color')).toBe('red'); + expect(extractCSSVariableValue('color')).toBe('red'); + + expect(mockGetPropertyValue).toHaveBeenCalledWith('--color'); + }); + + it('should return null for non-existent variables', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(extractCSSVariableValue('--nonexistent')).toBe(null); + }); + + it('should trim whitespace from values', () => { + mockGetPropertyValue.mockReturnValue(' red '); + + expect(extractCSSVariableValue('--color')).toBe('red'); + }); + + it('should use custom element when provided', () => { + const mockElement = document.createElement('div'); + mockGetPropertyValue.mockReturnValue('blue'); + + extractCSSVariableValue('--color', mockElement); + + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockElement); + }); + + it('should use document.documentElement by default', () => { + mockGetPropertyValue.mockReturnValue('green'); + + extractCSSVariableValue('--color'); + + expect(mockGetComputedStyle).toHaveBeenCalledWith(document.documentElement); + }); + }); + + describe('extractCSSVariableValueWithFallback', () => { + it('should return variable value when found', () => { + mockGetPropertyValue.mockReturnValue('red'); + + expect(extractCSSVariableValueWithFallback('--color', 'blue')).toBe('red'); + }); + + it('should return fallback when variable not found', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(extractCSSVariableValueWithFallback('--color', 'blue')).toBe('blue'); + expect(extractCSSVariableValueWithFallback('--color', 42)).toBe(42); + expect(extractCSSVariableValueWithFallback('--color', null)).toBe(null); + }); + }); + + describe('extractMultipleCSSVariables', () => { + it('should extract multiple variables', () => { + mockGetPropertyValue.mockReturnValueOnce('red').mockReturnValueOnce('blue').mockReturnValueOnce(''); + + const result = extractMultipleCSSVariables(['--primary-color', '--secondary-color', '--nonexistent-color']); + + expect(result).toEqual({ + '--primary-color': 'red', + '--secondary-color': 'blue', + '--nonexistent-color': null, + }); + }); + + it('should handle empty array', () => { + const result = extractMultipleCSSVariables([]); + expect(result).toEqual({}); + }); + }); + + describe('resolveCSSVariable', () => { + it('should resolve CSS variables with values', () => { + mockGetPropertyValue.mockReturnValue('red'); + + expect(resolveCSSVariable('var(--color)')).toBe('red'); + }); + + it('should return fallback when variable not found', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(resolveCSSVariable('var(--color, blue)')).toBe('blue'); + expect(resolveCSSVariable('var(--color, rgba(255, 0, 0, 0.5))')).toBe('rgba(255, 0, 0, 0.5)'); + }); + + it('should return null when variable not found and no fallback', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(resolveCSSVariable('var(--color)')).toBe(null); + }); + + it('should return null for non-CSS variables', () => { + expect(resolveCSSVariable('red')).toBe(null); + expect(resolveCSSVariable('#ff0000')).toBe(null); + expect(resolveCSSVariable('--color')).toBe(null); + }); + + it('should handle whitespace in fallback values', () => { + mockGetPropertyValue.mockReturnValue(''); + + expect(resolveCSSVariable('var(--color, blue )')).toBe('blue'); + }); + + it('should use custom element when provided', () => { + const mockElement = document.createElement('div'); + mockGetPropertyValue.mockReturnValue('purple'); + + const result = resolveCSSVariable('var(--color)', mockElement); + + expect(result).toBe('purple'); + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockElement); + }); + }); + + describe('resolveComputedCSSProperty', () => { + const mockElement = { + appendChild: vi.fn(), + removeChild: vi.fn(), + } as any; + + const mockCreatedElement = { + style: { + setProperty: vi.fn(), + }, + } as any; + + const mockGetComputedStyle = vi.fn(); + const mockCreateElement = vi.fn(); + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Mock document.createElement + Object.defineProperty(document, 'createElement', { + value: mockCreateElement, + writable: true, + }); + + // Mock window.getComputedStyle + Object.defineProperty(window, 'getComputedStyle', { + value: mockGetComputedStyle, + writable: true, + }); + + // Setup createElement to return our mock element + mockCreateElement.mockReturnValue(mockCreatedElement); + + // Setup getComputedStyle to return mock styles + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('resolved-value'), + }); + }); + + it('should resolve a basic CSS property', () => { + const result = resolveComputedCSSProperty(mockElement, 'font-weight', '400'); + + expect(mockCreateElement).toHaveBeenCalledWith('div'); + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('font-weight', '400'); + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.removeChild).toHaveBeenCalledWith(mockCreatedElement); + expect(result).toBe('resolved-value'); + }); + + it('should resolve CSS variables', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('16px'), + }); + + const result = resolveComputedCSSProperty(mockElement, 'font-size', 'var(--font-size-base)'); + + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('font-size', 'var(--font-size-base)'); + expect(result).toBe('16px'); + }); + + it('should handle font-weight properties', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('700'), + }); + + const result = resolveComputedCSSProperty(mockElement, 'font-weight', 'var(--font-weight-bold)'); + + expect(result).toBe('700'); + }); + + it('should handle border-radius properties', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('8px'), + }); + + const result = resolveComputedCSSProperty(mockElement, 'border-radius', 'var(--border-radius-lg)'); + + expect(result).toBe('8px'); + }); + + it('should handle spacing/padding properties', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('4px'), + }); + + const result = resolveComputedCSSProperty(mockElement, 'padding', 'var(--space-1)'); + + expect(result).toBe('4px'); + }); + + it('should trim whitespace from resolved values', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue(' 500 '), + }); + + const result = resolveComputedCSSProperty(mockElement, 'font-weight', 'var(--font-weight-medium)'); + + expect(result).toBe('500'); + }); + + it('should handle multiple property types in sequence', () => { + const properties = [ + { name: 'font-size', value: 'var(--text-lg)', expected: '18px' }, + { name: 'font-weight', value: 'var(--font-bold)', expected: '700' }, + { name: 'border-radius', value: 'var(--radius-md)', expected: '6px' }, + ]; + + properties.forEach(({ name, value, expected }) => { + mockGetComputedStyle.mockReturnValueOnce({ + getPropertyValue: vi.fn().mockReturnValue(expected), + }); + + const result = resolveComputedCSSProperty(mockElement, name, value); + expect(result).toBe(expected); + }); + + expect(mockCreateElement).toHaveBeenCalledTimes(3); + }); + + it('should properly clean up DOM elements', () => { + resolveComputedCSSProperty(mockElement, 'font-size', '14px'); + + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.removeChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.appendChild).toHaveBeenCalledBefore(mockElement.removeChild); + }); + }); + + describe('resolveComputedCSSColor', () => { + const mockElement = { + appendChild: vi.fn(), + removeChild: vi.fn(), + } as any; + + let mockCanvasInstance: any; + + const createMockCanvas = () => ({ + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue(mockCanvasContext), + }); + + const mockCanvasContext = { + fillStyle: '', + fillRect: vi.fn(), + getImageData: vi.fn(), + } as any; + + const mockCreatedElement = { + style: { + setProperty: vi.fn(), + }, + } as any; + + const mockCreateElement = vi.fn(); + const mockGetComputedStyle = vi.fn(); + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + + // Mock document.createElement + Object.defineProperty(document, 'createElement', { + value: mockCreateElement, + writable: true, + }); + + // Mock window.getComputedStyle + Object.defineProperty(window, 'getComputedStyle', { + value: mockGetComputedStyle, + writable: true, + }); + + // Setup createElement to return appropriate mocks + mockCreateElement.mockImplementation((tagName: string) => { + if (tagName === 'div') { + return mockCreatedElement; + } + if (tagName === 'canvas') { + mockCanvasInstance = createMockCanvas(); + return mockCanvasInstance; + } + return {}; + }); + + // Setup getComputedStyle to return mock styles + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(255, 0, 0)'), + }); + + // Setup canvas context + mockCanvasContext.getImageData.mockReturnValue({ + data: [255, 0, 0, 255], // Red color + }); + }); + + it('should resolve a basic color to hex format', () => { + const result = resolveComputedCSSColor(mockElement, 'red'); + + expect(mockCreateElement).toHaveBeenCalledWith('div'); + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('color', 'red'); + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.removeChild).toHaveBeenCalledWith(mockCreatedElement); + expect(result).toBe('#ff0000'); + }); + + it('should handle CSS variables', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(0, 128, 255)'), + }); + mockCanvasContext.getImageData.mockReturnValue({ + data: [0, 128, 255, 255], + }); + + const result = resolveComputedCSSColor(mockElement, 'var(--primary-color)'); + + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('color', 'var(--primary-color)'); + expect(result).toBe('#0080ff'); + }); + + it('should use custom background color', () => { + const result = resolveComputedCSSColor(mockElement, 'blue', 'black'); + + expect(mockCanvasContext.fillRect).toHaveBeenCalledWith(0, 0, 1, 1); + expect(result).toBe('#ff0000'); + }); + + it('should default to white background when not specified', () => { + const result = resolveComputedCSSColor(mockElement, 'green'); + + expect(mockCanvasContext.fillRect).toHaveBeenCalledWith(0, 0, 1, 1); + expect(result).toBe('#ff0000'); + }); + + it('should handle canvas context creation failure', () => { + mockCanvasInstance = createMockCanvas(); + mockCanvasInstance.getContext.mockReturnValue(null); + mockCreateElement.mockImplementation((tagName: string) => { + if (tagName === 'div') { + return mockCreatedElement; + } + if (tagName === 'canvas') { + return mockCanvasInstance; + } + return {}; + }); + + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(255, 0, 0)'), + }); + + const result = resolveComputedCSSColor(mockElement, 'red'); + + expect(result).toBe('rgb(255, 0, 0)'); + }); + + it('should properly format single-digit hex values', () => { + mockCanvasContext.getImageData.mockReturnValue({ + data: [15, 5, 10, 255], // RGB values that would be single digit in hex + }); + + const result = resolveComputedCSSColor(mockElement, 'red'); + + // Should pad single digits with 0 + expect(result).toBe('#0f050a'); + }); + + it('should handle rgba colors with transparency', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgba(255, 128, 64, 0.5)'), + }); + mockCanvasContext.getImageData.mockReturnValue({ + data: [255, 128, 64, 128], // With alpha + }); + + const result = resolveComputedCSSColor(mockElement, 'rgba(255, 128, 64, 0.5)'); + + expect(result).toBe('#ff8040'); + }); + + it('should handle complex CSS color functions', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(120, 60, 180)'), + }); + mockCanvasContext.getImageData.mockReturnValue({ + data: [120, 60, 180, 255], + }); + + const result = resolveComputedCSSColor(mockElement, 'hsl(270, 50%, 47%)'); + + expect(result).toBe('#783cb4'); + }); + + it('should create canvas with correct dimensions', () => { + resolveComputedCSSColor(mockElement, 'red'); + + const canvasCall = mockCreateElement.mock.calls.find(call => call[0] === 'canvas'); + expect(canvasCall).toBeDefined(); + + // Verify canvas setup + expect(mockCanvasInstance.width).toBe(1); + expect(mockCanvasInstance.height).toBe(1); + }); + + it('should call getImageData with correct parameters', () => { + resolveComputedCSSColor(mockElement, 'blue'); + + expect(mockCanvasContext.getImageData).toHaveBeenCalledWith(0, 0, 1, 1); + }); + + it('should properly clean up DOM element', () => { + resolveComputedCSSColor(mockElement, 'purple'); + + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.removeChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockElement.appendChild).toHaveBeenCalledBefore(mockElement.removeChild); + }); + + it('should set color style on temporary element', () => { + const testColor = 'var(--test-color)'; + + resolveComputedCSSColor(mockElement, testColor); + + expect(mockCreateElement).toHaveBeenCalledWith('div'); + expect(mockCreatedElement.style.setProperty).toHaveBeenCalledWith('color', testColor); + expect(mockElement.appendChild).toHaveBeenCalledWith(mockCreatedElement); + expect(mockGetComputedStyle).toHaveBeenCalledWith(mockCreatedElement); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/__tests__/normalizeColorString.spec.ts b/packages/clerk-js/src/ui/utils/__tests__/normalizeColorString.spec.ts deleted file mode 100644 index cc2ab505544..00000000000 --- a/packages/clerk-js/src/ui/utils/__tests__/normalizeColorString.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; - -import { normalizeColorString } from '../normalizeColorString'; - -describe('normalizeColorString', () => { - beforeEach(() => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) as vi.Mock; - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - // Hex color tests - test('should keep 3-char hex colors unchanged', () => { - expect(normalizeColorString('#123')).toBe('#123'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should keep 6-char hex colors unchanged', () => { - expect(normalizeColorString('#123456')).toBe('#123456'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should remove alpha from 4-char hex colors', () => { - expect(normalizeColorString('#123F')).toBe('#123'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should remove alpha from 8-char hex colors', () => { - expect(normalizeColorString('#12345678')).toBe('#123456'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should warn for invalid hex formats but return the original', () => { - expect(normalizeColorString('#12')).toBe('#12'); - expect(console.warn).toHaveBeenCalledTimes(1); - - (console.warn as vi.Mock).mockClear(); - expect(normalizeColorString('#12345')).toBe('#12345'); - expect(console.warn).toHaveBeenCalledTimes(1); - }); - - // RGB color tests - test('should keep rgb format unchanged but normalize whitespace', () => { - expect(normalizeColorString('rgb(255, 0, 0)')).toBe('rgb(255, 0, 0)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should convert rgba to rgb', () => { - expect(normalizeColorString('rgba(255, 0, 0, 0.5)')).toBe('rgb(255, 0, 0)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should handle rgb with whitespace variations', () => { - expect(normalizeColorString('rgb(255,0,0)')).toBe('rgb(255, 0, 0)'); - expect(normalizeColorString('rgb( 255 , 0 , 0 )')).toBe('rgb(255, 0, 0)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - // HSL color tests - test('should keep hsl format unchanged but normalize whitespace', () => { - expect(normalizeColorString('hsl(120, 100%, 50%)')).toBe('hsl(120, 100%, 50%)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should convert hsla to hsl', () => { - expect(normalizeColorString('hsla(120, 100%, 50%, 0.8)')).toBe('hsl(120, 100%, 50%)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - test('should handle hsl with whitespace variations', () => { - expect(normalizeColorString('hsl(120,100%,50%)')).toBe('hsl(120, 100%, 50%)'); - expect(normalizeColorString('hsl( 120 , 100% , 50% )')).toBe('hsl(120, 100%, 50%)'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - // Warning tests for invalid inputs - test('should warn for invalid color formats but return the original', () => { - expect(normalizeColorString('')).toBe(''); - expect(console.warn).toHaveBeenCalledTimes(1); - - (console.warn as vi.Mock).mockClear(); - expect(normalizeColorString('invalid')).toBe('invalid'); - expect(console.warn).toHaveBeenCalledTimes(1); - - (console.warn as vi.Mock).mockClear(); - expect(normalizeColorString('rgb(255,0)')).toBe('rgb(255,0)'); - expect(console.warn).toHaveBeenCalledTimes(1); - }); - - test('should warn for non-string inputs but return the original or empty string', () => { - expect(normalizeColorString(null as any)).toBe(''); - expect(console.warn).toHaveBeenCalledTimes(1); - - (console.warn as vi.Mock).mockClear(); - expect(normalizeColorString(123 as any)).toBe(123 as any); - expect(console.warn).toHaveBeenCalledTimes(1); - }); - - // Edge cases - test('should handle trimming whitespace', () => { - expect(normalizeColorString(' #123 ')).toBe('#123'); - expect(normalizeColorString('\n rgb(255, 0, 0) \t')).toBe('rgb(255, 0, 0)'); - expect(console.warn).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts index 8d1c92227ee..94f41fe9f20 100644 --- a/packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts @@ -6,15 +6,10 @@ import { createColorMixString, createEmptyColorScale, createRelativeColorString, - extractCSSVariableValue, - extractCSSVariableValueWithFallback, - extractMultipleCSSVariables, generateAlphaColorMix, generateColorMixSyntax, generateRelativeColorSyntax, getSupportedColorVariant, - isCSSVariable, - resolveCSSVariable, } from '../utils'; // Mock cssSupports @@ -190,154 +185,4 @@ describe('Color Utils', () => { expect(result).toBe('red'); }); }); - - describe('CSS Variable Utilities', () => { - describe('isCSSVariable', () => { - it('should return true for valid CSS variables', () => { - expect(isCSSVariable('var(--color)')).toBe(true); - expect(isCSSVariable('var(--primary-color)')).toBe(true); - expect(isCSSVariable('var(--color, red)')).toBe(true); - expect(isCSSVariable('var(--color, rgba(255, 0, 0, 0.5))')).toBe(true); - expect(isCSSVariable('var( --color )')).toBe(true); // with spaces - }); - - it('should return false for invalid CSS variables', () => { - expect(isCSSVariable('--color')).toBe(false); - expect(isCSSVariable('color')).toBe(false); - expect(isCSSVariable('red')).toBe(false); - expect(isCSSVariable('#ff0000')).toBe(false); - expect(isCSSVariable('rgb(255, 0, 0)')).toBe(false); - expect(isCSSVariable('var(color)')).toBe(false); // missing -- - expect(isCSSVariable('var(--)')).toBe(false); // empty variable name - }); - - it('should handle edge cases', () => { - expect(isCSSVariable('')).toBe(false); - expect(isCSSVariable(' ')).toBe(false); - // @ts-expect-error Testing runtime behavior - expect(isCSSVariable(null)).toBe(false); - // @ts-expect-error Testing runtime behavior - expect(isCSSVariable(undefined)).toBe(false); - }); - }); - - describe('extractCSSVariableValue', () => { - it('should extract values from different variable name formats', () => { - mockGetPropertyValue.mockReturnValue('red'); - - expect(extractCSSVariableValue('var(--color)')).toBe('red'); - expect(extractCSSVariableValue('--color')).toBe('red'); - expect(extractCSSVariableValue('color')).toBe('red'); - - expect(mockGetPropertyValue).toHaveBeenCalledWith('--color'); - }); - - it('should return null for non-existent variables', () => { - mockGetPropertyValue.mockReturnValue(''); - - expect(extractCSSVariableValue('--nonexistent')).toBe(null); - }); - - it('should trim whitespace from values', () => { - mockGetPropertyValue.mockReturnValue(' red '); - - expect(extractCSSVariableValue('--color')).toBe('red'); - }); - - it('should use custom element when provided', () => { - const mockElement = document.createElement('div'); - mockGetPropertyValue.mockReturnValue('blue'); - - extractCSSVariableValue('--color', mockElement); - - expect(mockGetComputedStyle).toHaveBeenCalledWith(mockElement); - }); - - it('should use document.documentElement by default', () => { - mockGetPropertyValue.mockReturnValue('green'); - - extractCSSVariableValue('--color'); - - expect(mockGetComputedStyle).toHaveBeenCalledWith(document.documentElement); - }); - }); - - describe('extractCSSVariableValueWithFallback', () => { - it('should return variable value when found', () => { - mockGetPropertyValue.mockReturnValue('red'); - - expect(extractCSSVariableValueWithFallback('--color', 'blue')).toBe('red'); - }); - - it('should return fallback when variable not found', () => { - mockGetPropertyValue.mockReturnValue(''); - - expect(extractCSSVariableValueWithFallback('--color', 'blue')).toBe('blue'); - expect(extractCSSVariableValueWithFallback('--color', 42)).toBe(42); - expect(extractCSSVariableValueWithFallback('--color', null)).toBe(null); - }); - }); - - describe('extractMultipleCSSVariables', () => { - it('should extract multiple variables', () => { - mockGetPropertyValue.mockReturnValueOnce('red').mockReturnValueOnce('blue').mockReturnValueOnce(''); - - const result = extractMultipleCSSVariables(['--primary-color', '--secondary-color', '--nonexistent-color']); - - expect(result).toEqual({ - '--primary-color': 'red', - '--secondary-color': 'blue', - '--nonexistent-color': null, - }); - }); - - it('should handle empty array', () => { - const result = extractMultipleCSSVariables([]); - expect(result).toEqual({}); - }); - }); - - describe('resolveCSSVariable', () => { - it('should resolve CSS variables with values', () => { - mockGetPropertyValue.mockReturnValue('red'); - - expect(resolveCSSVariable('var(--color)')).toBe('red'); - }); - - it('should return fallback when variable not found', () => { - mockGetPropertyValue.mockReturnValue(''); - - expect(resolveCSSVariable('var(--color, blue)')).toBe('blue'); - expect(resolveCSSVariable('var(--color, rgba(255, 0, 0, 0.5))')).toBe('rgba(255, 0, 0, 0.5)'); - }); - - it('should return null when variable not found and no fallback', () => { - mockGetPropertyValue.mockReturnValue(''); - - expect(resolveCSSVariable('var(--color)')).toBe(null); - }); - - it('should return null for non-CSS variables', () => { - expect(resolveCSSVariable('red')).toBe(null); - expect(resolveCSSVariable('#ff0000')).toBe(null); - expect(resolveCSSVariable('--color')).toBe(null); - }); - - it('should handle whitespace in fallback values', () => { - mockGetPropertyValue.mockReturnValue(''); - - expect(resolveCSSVariable('var(--color, blue )')).toBe('blue'); - }); - - it('should use custom element when provided', () => { - const mockElement = document.createElement('div'); - mockGetPropertyValue.mockReturnValue('purple'); - - const result = resolveCSSVariable('var(--color)', mockElement); - - expect(result).toBe('purple'); - expect(mockGetComputedStyle).toHaveBeenCalledWith(mockElement); - }); - }); - }); }); diff --git a/packages/clerk-js/src/ui/utils/colors/legacy.ts b/packages/clerk-js/src/ui/utils/colors/legacy.ts index 384d01892c4..5e59377c60b 100644 --- a/packages/clerk-js/src/ui/utils/colors/legacy.ts +++ b/packages/clerk-js/src/ui/utils/colors/legacy.ts @@ -10,7 +10,7 @@ import type { HslaColor, HslaColorString } from '@clerk/types'; -import { resolveCSSVariable } from './utils'; +import { resolveCSSVariable } from '../cssVariables'; const abbrRegex = /^#([a-f0-9]{3,4})$/i; const hexRegex = /^#([a-f0-9]{6})([a-f0-9]{2})?$/i; diff --git a/packages/clerk-js/src/ui/utils/colors/utils.ts b/packages/clerk-js/src/ui/utils/colors/utils.ts index 9f6abf9a91a..fb386487fb2 100644 --- a/packages/clerk-js/src/ui/utils/colors/utils.ts +++ b/packages/clerk-js/src/ui/utils/colors/utils.ts @@ -135,188 +135,3 @@ export function getSupportedColorVariant(color: string, shade: ColorShade): stri return color; } - -/** - * CSS Variable Utilities - */ - -/** - * Extracts the computed value of a CSS custom property (CSS variable) - * @param variableName - The CSS variable name in any of these formats: - * - 'var(--color)' - * - '--color' - * - 'color' (will be prefixed with --) - * @param element - Optional element to get the variable from (defaults to document.documentElement) - * @returns The computed CSS variable value as a string, or null if not found - * @example - * const colorValue = extractCSSVariableValue('var(--color)'); // "red" - * const colorValue2 = extractCSSVariableValue('--color'); // "red" - * const colorValue3 = extractCSSVariableValue('color'); // "red" - * const colorValue4 = extractCSSVariableValue('--nonexistent'); // null - * const colorValue5 = extractCSSVariableValue('--nonexistent', document.body); // null - * const colorValue6 = extractCSSVariableValue('--nonexistent', document.body, '#000000'); // "#000000" - */ -export function extractCSSVariableValue(variableName: string, element?: Element): string | null { - // Handle both browser and server environments - if (typeof window === 'undefined' || typeof getComputedStyle === 'undefined') { - return null; - } - - // Handle different input formats - let cleanVariableName: string; - - if (variableName.startsWith('var(') && variableName.endsWith(')')) { - // Extract from 'var(--color)' format - cleanVariableName = variableName.slice(4, -1).trim(); - } else if (variableName.startsWith('--')) { - // Already in '--color' format - cleanVariableName = variableName; - } else { - // Add -- prefix to 'color' format - cleanVariableName = `--${variableName}`; - } - - // Use provided element or default to document root - // Handle cases where document might not be available or element might be null - let targetElement: Element; - try { - if (element) { - targetElement = element; - } else if (typeof document !== 'undefined' && document.documentElement) { - targetElement = document.documentElement; - } else { - return null; - } - } catch { - return null; - } - - // Get computed style and extract the variable value - try { - const computedStyle = getComputedStyle(targetElement); - const value = computedStyle.getPropertyValue(cleanVariableName).trim(); - return value || null; - } catch { - return null; - } -} - -/** - * Alternative version that also accepts fallback values - * @param variableName - The CSS variable name - * @param fallback - Fallback value if variable is not found - * @param element - Optional element to get the variable from - * @returns The CSS variable value or fallback - */ -export function extractCSSVariableValueWithFallback( - variableName: string, - fallback: T, - element?: Element, -): string | T { - const value = extractCSSVariableValue(variableName, element); - return value || fallback; -} - -/** - * Gets multiple CSS variables at once - * @param variableNames - Array of CSS variable names - * @param element - Optional element to get variables from - * @returns Object mapping variable names to their values - * @example - * const variables = extractMultipleCSSVariables([ - * '--primary-color', - * '--secondary-color', - * '--font-size' - * ]); - */ -export function extractMultipleCSSVariables(variableNames: string[], element?: Element): Record { - return variableNames.reduce( - (acc, varName) => { - acc[varName] = extractCSSVariableValue(varName, element); - return acc; - }, - {} as Record, - ); -} - -/** - * Checks if a given value represents a CSS variable (var() function) - * @param value - The value to check - * @returns True if the value is a CSS variable, false otherwise - * @example - * isCSSVariable('var(--color)'); // true - * isCSSVariable('var(--color, red)'); // true - * isCSSVariable('--color'); // false - * isCSSVariable('red'); // false - * isCSSVariable('#ff0000'); // false - */ -export function isCSSVariable(value: string): boolean { - if (!value || typeof value !== 'string') { - return false; - } - - const trimmed = value.trim(); - - // Must start with var( and end with ) - if (!trimmed.startsWith('var(') || !trimmed.endsWith(')')) { - return false; - } - - // Extract content between var( and ) - const content = trimmed.slice(4, -1).trim(); - - // Must start with -- - if (!content.startsWith('--')) { - return false; - } - - // Find the variable name (everything before the first comma, if any) - const commaIndex = content.indexOf(','); - const variableName = commaIndex === -1 ? content : content.slice(0, commaIndex).trim(); - - // Variable name must be valid (--something) - return /^--[a-zA-Z0-9-_]+$/.test(variableName); -} - -/** - * Resolves a CSS variable to its computed value, with fallback support - * Handles var() syntax and extracts variable name and fallback value - * @param value - The CSS variable string (e.g., 'var(--color, red)') - * @param element - Optional element to get the variable from - * @returns The resolved value or null if not found and no fallback provided - * @example - * resolveCSSVariable('var(--primary-color)'); // "blue" (if --primary-color is blue) - * resolveCSSVariable('var(--missing-color, red)'); // "red" (fallback) - * resolveCSSVariable('var(--missing-color)'); // null - * resolveCSSVariable('red'); // null (not a CSS variable) - */ -export function resolveCSSVariable(value: string, element?: Element): string | null { - if (!isCSSVariable(value)) { - return null; - } - - // Extract content between var( and ) - const content = value.trim().slice(4, -1).trim(); - - // Find the variable name and fallback value - const commaIndex = content.indexOf(','); - let variableName: string; - let fallbackValue: string | null = null; - - if (commaIndex === -1) { - variableName = content; - } else { - variableName = content.slice(0, commaIndex).trim(); - fallbackValue = content.slice(commaIndex + 1).trim(); - } - - // Try to get the resolved variable value - const resolvedValue = extractCSSVariableValue(variableName, element); - - if (resolvedValue) { - return resolvedValue; - } - - // If variable couldn't be resolved, return the fallback value if provided - return fallbackValue; -} diff --git a/packages/clerk-js/src/ui/utils/cssVariables.ts b/packages/clerk-js/src/ui/utils/cssVariables.ts new file mode 100644 index 00000000000..40ce87f6353 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/cssVariables.ts @@ -0,0 +1,235 @@ +/** + * Extracts the computed value of a CSS custom property (CSS variable) + * @param variableName - The CSS variable name in any of these formats: + * - 'var(--color)' + * - '--color' + * - 'color' (will be prefixed with --) + * @param element - Optional element to get the variable from (defaults to document.documentElement) + * @returns The computed CSS variable value as a string, or null if not found + * @example + * const colorValue = extractCSSVariableValue('var(--color)'); // "red" + * const colorValue2 = extractCSSVariableValue('--color'); // "red" + * const colorValue3 = extractCSSVariableValue('color'); // "red" + * const colorValue4 = extractCSSVariableValue('--nonexistent'); // null + * const colorValue5 = extractCSSVariableValue('--nonexistent', document.body); // null + * const colorValue6 = extractCSSVariableValue('--nonexistent', document.body, '#000000'); // "#000000" + */ +export function extractCSSVariableValue(variableName: string, element?: Element): string | null { + // Handle both browser and server environments + if (typeof window === 'undefined' || typeof getComputedStyle === 'undefined') { + return null; + } + + // Handle different input formats + let cleanVariableName: string; + + if (variableName.startsWith('var(') && variableName.endsWith(')')) { + // Extract from 'var(--color)' format + cleanVariableName = variableName.slice(4, -1).trim(); + } else if (variableName.startsWith('--')) { + // Already in '--color' format + cleanVariableName = variableName; + } else { + // Add -- prefix to 'color' format + cleanVariableName = `--${variableName}`; + } + + // Use provided element or default to document root + // Handle cases where document might not be available or element might be null + let targetElement: Element; + try { + if (element) { + targetElement = element; + } else if (typeof document !== 'undefined' && document.documentElement) { + targetElement = document.documentElement; + } else { + return null; + } + } catch { + return null; + } + + // Get computed style and extract the variable value + try { + const computedStyle = getComputedStyle(targetElement); + const value = computedStyle.getPropertyValue(cleanVariableName).trim(); + return value || null; + } catch { + return null; + } +} + +/** + * Alternative version that also accepts fallback values + * @param variableName - The CSS variable name + * @param fallback - Fallback value if variable is not found + * @param element - Optional element to get the variable from + * @returns The CSS variable value or fallback + */ +export function extractCSSVariableValueWithFallback( + variableName: string, + fallback: T, + element?: Element, +): string | T { + const value = extractCSSVariableValue(variableName, element); + return value || fallback; +} + +/** + * Gets multiple CSS variables at once + * @param variableNames - Array of CSS variable names + * @param element - Optional element to get variables from + * @returns Object mapping variable names to their values + * @example + * const variables = extractMultipleCSSVariables([ + * '--primary-color', + * '--secondary-color', + * '--font-size' + * ]); + */ +export function extractMultipleCSSVariables(variableNames: string[], element?: Element): Record { + return variableNames.reduce( + (acc, varName) => { + acc[varName] = extractCSSVariableValue(varName, element); + return acc; + }, + {} as Record, + ); +} + +/** + * Checks if a given value represents a CSS variable (var() function) + * @param value - The value to check + * @returns True if the value is a CSS variable, false otherwise + * @example + * isCSSVariable('var(--color)'); // true + * isCSSVariable('var(--color, red)'); // true + * isCSSVariable('--color'); // false + * isCSSVariable('red'); // false + * isCSSVariable('#ff0000'); // false + */ +export function isCSSVariable(value: string): boolean { + if (!value || typeof value !== 'string') { + return false; + } + + const trimmed = value.trim(); + + // Must start with var( and end with ) + if (!trimmed.startsWith('var(') || !trimmed.endsWith(')')) { + return false; + } + + // Extract content between var( and ) + const content = trimmed.slice(4, -1).trim(); + + // Must start with -- + if (!content.startsWith('--')) { + return false; + } + + // Find the variable name (everything before the first comma, if any) + const commaIndex = content.indexOf(','); + const variableName = commaIndex === -1 ? content : content.slice(0, commaIndex).trim(); + + // Variable name must be valid (--something) + return /^--[a-zA-Z0-9-_]+$/.test(variableName); +} + +/** + * Resolves a CSS variable to its computed value, with fallback support + * Handles var() syntax and extracts variable name and fallback value + * @param value - The CSS variable string (e.g., 'var(--color, red)') + * @param element - Optional element to get the variable from + * @returns The resolved value or null if not found and no fallback provided + * @example + * resolveCSSVariable('var(--primary-color)'); // "blue" (if --primary-color is blue) + * resolveCSSVariable('var(--missing-color, red)'); // "red" (fallback) + * resolveCSSVariable('var(--missing-color)'); // null + * resolveCSSVariable('red'); // null (not a CSS variable) + */ +export function resolveCSSVariable(value: string, element?: Element): string | null { + if (!isCSSVariable(value)) { + return null; + } + + // Extract content between var( and ) + const content = value.trim().slice(4, -1).trim(); + + // Find the variable name and fallback value + const commaIndex = content.indexOf(','); + let variableName: string; + let fallbackValue: string | null = null; + + if (commaIndex === -1) { + variableName = content; + } else { + variableName = content.slice(0, commaIndex).trim(); + fallbackValue = content.slice(commaIndex + 1).trim(); + } + + // Try to get the resolved variable value + const resolvedValue = extractCSSVariableValue(variableName, element); + + if (resolvedValue) { + return resolvedValue; + } + + // If variable couldn't be resolved, return the fallback value if provided + return fallbackValue; +} + +/** + * Resolves a CSS property to its computed value, in the context of a DOM element + * This is used to resolve CSS variables to their computed values, in the context of a DOM element. + * + * @param parentElement - The parent element to resolve the property in the context of + * @param propertyName - The CSS property name (e.g., 'color', 'font-weight', 'font-size') + * @param propertyValue - The property value to resolve (can be a CSS variable) + * @returns The resolved property value as a string + */ +export function resolveComputedCSSProperty( + parentElement: HTMLElement, + propertyName: string, + propertyValue: string, +): string { + const element = document.createElement('div'); + element.style.setProperty(propertyName, propertyValue); + parentElement.appendChild(element); + const computedStyle = window.getComputedStyle(element); + const computedValue = computedStyle.getPropertyValue(propertyName); + parentElement.removeChild(element); + return computedValue.trim(); +} + +/** + * Resolves a color to its computed value, in the context of a DOM element + * This is used to resolve CSS variables to their computed values, in the context of a DOM element to support passing + * CSS variables to Stripe Elements. + * + * @param parentElement - The parent element to resolve the color in the context of + * @param color - The color to resolve + * @param backgroundColor - The background color to use for the canvas, this is used to ensure colors that + * contain an alpha value mix together correctly. So the output matches the alpha usage in the CSS. + * @returns The resolved color as a hex string + */ +export function resolveComputedCSSColor(parentElement: HTMLElement, color: string, backgroundColor: string = 'white') { + const computedColor = resolveComputedCSSProperty(parentElement, 'color', color); + const computedBackgroundColor = resolveComputedCSSProperty(parentElement, 'color', backgroundColor); + + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + return computedColor; + } + + ctx.fillStyle = computedBackgroundColor; + ctx.fillRect(0, 0, 1, 1); + ctx.fillStyle = computedColor; + ctx.fillRect(0, 0, 1, 1); + const { data } = ctx.getImageData(0, 0, 1, 1); + return `#${data[0].toString(16).padStart(2, '0')}${data[1].toString(16).padStart(2, '0')}${data[2].toString(16).padStart(2, '0')}`; +} diff --git a/packages/clerk-js/src/ui/utils/normalizeColorString.ts b/packages/clerk-js/src/ui/utils/normalizeColorString.ts deleted file mode 100644 index 69602749402..00000000000 --- a/packages/clerk-js/src/ui/utils/normalizeColorString.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Normalizes color format strings by removing alpha values if present - * Handles conversions between: - * - Hex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA → #RGB or #RRGGBB - * - RGB: rgb(r, g, b), rgba(r, g, b, a) → rgb(r, g, b) - * - HSL: hsl(h, s%, l%), hsla(h, s%, l%, a) → hsl(h, s%, l%) - * - * @param colorString - The color string to normalize - * @returns The normalized color string without alpha components, or the original string if invalid - */ -export function normalizeColorString(colorString: string): string { - if (!colorString || typeof colorString !== 'string') { - console.warn('Invalid input: color string must be a non-empty string'); - return colorString || ''; - } - - const trimmed = colorString.trim(); - - // Handle empty strings - if (trimmed === '') { - console.warn('Invalid input: color string cannot be empty'); - return ''; - } - - // Handle hex colors - if (trimmed.startsWith('#')) { - // Validate hex format - if (!/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(trimmed)) { - console.warn(`Invalid hex color format: ${colorString}`); - return trimmed; - } - - // #RGBA format (4 chars) - if (trimmed.length === 5) { - return '#' + trimmed.slice(1, 4); - } - // #RRGGBBAA format (9 chars) - if (trimmed.length === 9) { - return '#' + trimmed.slice(1, 7); - } - // Regular hex formats (#RGB, #RRGGBB) - return trimmed; - } - - // Handle rgb/rgba - if (/^rgba?\(/.test(trimmed)) { - // Extract and normalize rgb values - const rgbMatch = trimmed.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); - if (rgbMatch) { - // Already in rgb format, normalize whitespace - return `rgb(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]})`; - } - - // Extract and normalize rgba values - const rgbaMatch = trimmed.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/); - if (rgbaMatch) { - // Convert rgba to rgb, normalize whitespace - return `rgb(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]})`; - } - - console.warn(`Invalid RGB/RGBA format: ${colorString}`); - return trimmed; - } - - // Handle hsl/hsla - if (/^hsla?\(/.test(trimmed)) { - // Extract and normalize hsl values - const hslMatch = trimmed.match(/^hsl\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)$/); - if (hslMatch) { - // Already in hsl format, normalize whitespace - return `hsl(${hslMatch[1]}, ${hslMatch[2]}%, ${hslMatch[3]}%)`; - } - - // Extract and normalize hsla values - const hslaMatch = trimmed.match(/^hsla\(\s*(\d+)\s*,\s*(\d+)%\s*,\s*(\d+)%\s*,\s*([\d.]+)\s*\)$/); - if (hslaMatch) { - // Convert hsla to hsl, normalize whitespace - return `hsl(${hslaMatch[1]}, ${hslaMatch[2]}%, ${hslaMatch[3]}%)`; - } - - console.warn(`Invalid HSL/HSLA format: ${colorString}`); - return trimmed; - } - - // If we reach here, the input is not a recognized color format - console.warn(`Unrecognized color format: ${colorString}`); - return trimmed; -}