From f42c4fedfdab873129b876eba38b3677f190b460 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 7 Jul 2025 13:44:01 +0300 Subject: [PATCH 01/18] chore(types): Add jsdoc for `payer_type` (#6257) --- .changeset/bumpy-jobs-flow.md | 5 +++++ packages/types/src/commerce.ts | 16 ++++++++++++++++ packages/types/src/json.ts | 16 ++++++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 .changeset/bumpy-jobs-flow.md diff --git a/.changeset/bumpy-jobs-flow.md b/.changeset/bumpy-jobs-flow.md new file mode 100644 index 00000000000..a799d38b477 --- /dev/null +++ b/.changeset/bumpy-jobs-flow.md @@ -0,0 +1,5 @@ +--- +'@clerk/types': patch +--- + +Add jsdoc comments for `payerType` in `CommercePlanResource`. diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index ea041db90d9..e8d36d86573 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -57,6 +57,22 @@ export interface CommercePlanResource extends ClerkResource { isDefault: boolean; isRecurring: boolean; hasBaseFee: boolean; + /** + * Specifies the subscriber type this plan is designed for. + * + * Each plan is exclusively created for either individual users or organizations, + * and cannot be used interchangeably. + * + * @type {['user'] | ['org']} + * @example + * ```ts + * // For a user plan + * payerType: ['user'] + * + * // For an organization plan + * payerType: ['org'] + * ``` + */ payerType: string[]; publiclyVisible: boolean; slug: string; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 779d0807f0c..89917b5d2a9 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -626,6 +626,22 @@ export interface CommercePlanJSON extends ClerkResourceJSON { is_default: boolean; is_recurring: boolean; has_base_fee: boolean; + /** + * Specifies the subscriber type this plan is designed for. + * + * Each plan is exclusively created for either individual users or organizations, + * and cannot be used interchangeably. + * + * @type {['user'] | ['org']} + * @example + * ```ts + * // For a user plan + * payer_type: ['user'] + * + * // For an organization plan + * payer_type: ['org'] + * ``` + */ payer_type: string[]; publicly_visible: boolean; slug: string; From 6b4b27e9d78417218280ef08b376883711eff47e Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 7 Jul 2025 10:16:22 -0400 Subject: [PATCH 02/18] feat(clerk-js): Password manager autofill OTP codes (#6247) Co-authored-by: Alex Carpenter --- .changeset/polite-pants-talk.md | 5 + .../elements-next/src/app/otp/page.tsx | 1 - integration/tests/elements/otp.test.ts | 7 - packages/clerk-js/bundlewatch.config.json | 1 + .../ui/customizables/elementDescriptors.ts | 1 + .../clerk-js/src/ui/elements/CodeControl.tsx | 126 +++- .../elements/__tests__/CodeControl.spec.tsx | 592 ++++++++++++++++++ packages/types/src/appearance.ts | 1 + 8 files changed, 695 insertions(+), 39 deletions(-) create mode 100644 .changeset/polite-pants-talk.md create mode 100644 packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx diff --git a/.changeset/polite-pants-talk.md b/.changeset/polite-pants-talk.md new file mode 100644 index 00000000000..8de871f29cd --- /dev/null +++ b/.changeset/polite-pants-talk.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +Password managers will now autofill OTP code verifications. diff --git a/integration/templates/elements-next/src/app/otp/page.tsx b/integration/templates/elements-next/src/app/otp/page.tsx index 57dac7018e8..60447b7dc6f 100644 --- a/integration/templates/elements-next/src/app/otp/page.tsx +++ b/integration/templates/elements-next/src/app/otp/page.tsx @@ -87,7 +87,6 @@ export default function OTP() { className='segmented-otp-with-props-wrapper flex justify-center has-[:disabled]:opacity-50' type='otp' data-testid='segmented-otp-with-props' - passwordManagerOffset={4} length={4} render={({ value, status }) => { return ( diff --git a/integration/tests/elements/otp.test.ts b/integration/tests/elements/otp.test.ts index 47b6da387f4..59f63f3414f 100644 --- a/integration/tests/elements/otp.test.ts +++ b/integration/tests/elements/otp.test.ts @@ -221,12 +221,5 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('OTP @elem // Check that only 4 segments are rendered await expect(otpSegmentsWrapper.locator('> div')).toHaveCount(4); }); - - test('passwordManagerOffset', async ({ page }) => { - const otp = page.getByTestId(otpTypes.segmentedOtpWithProps); - - // The computed styles are different on CI/local etc. so it's not use to check the exact value - await expect(otp).toHaveCSS('clip-path', /inset\(0px \d+\.\d+px 0px 0px\)/i); - }); }); }); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 038d748798e..23508f6f68f 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -5,6 +5,7 @@ { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "111.8KB" }, + { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "112.1KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" }, diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 690efe896a6..0dec7cc9338 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -98,6 +98,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'otpCodeField', 'otpCodeFieldInputs', 'otpCodeFieldInput', + 'otpCodeFieldInputContainer', 'otpCodeFieldErrorText', 'formResendCodeLink', diff --git a/packages/clerk-js/src/ui/elements/CodeControl.tsx b/packages/clerk-js/src/ui/elements/CodeControl.tsx index c777ae69a36..7e5118ed273 100644 --- a/packages/clerk-js/src/ui/elements/CodeControl.tsx +++ b/packages/clerk-js/src/ui/elements/CodeControl.tsx @@ -3,7 +3,7 @@ import type { PropsWithChildren } from 'react'; import React, { useCallback } from 'react'; import type { LocalizationKey } from '../customizables'; -import { descriptors, Flex, Input } from '../customizables'; +import { Box, descriptors, Flex, Input } from '../customizables'; import { useCardState } from '../elements/contexts'; import { useLoadingStatus } from '../hooks'; import type { PropsOfComponent } from '../styledSystem'; @@ -160,6 +160,7 @@ export const OTPResendButton = () => { export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { const [disabled, setDisabled] = React.useState(false); const refs = React.useRef>([]); + const hiddenInputRef = React.useRef(null); const firstClickRef = React.useRef(false); const { otpControl, isLoading, isDisabled, centerAlign = true } = useOTPInputContext(); @@ -169,6 +170,11 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { reset: () => { setValues(values.map(() => '')); setDisabled(false); + + if (hiddenInputRef.current) { + hiddenInputRef.current.value = ''; + } + setTimeout(() => focusInputAt(0), 0); }, })); @@ -183,6 +189,13 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { } }, [feedback]); + // Update hidden input when values change + React.useEffect(() => { + if (hiddenInputRef.current) { + hiddenInputRef.current.value = values.join(''); + } + }, [values]); + const handleMultipleCharValue = ({ eventValue, inputPosition }: { eventValue: string; inputPosition: number }) => { const eventValues = (eventValue || '').split(''); @@ -274,40 +287,91 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { } }; + // Handle hidden input changes (for password manager autofill) + const handleHiddenInputChange = (e: React.ChangeEvent) => { + const value = e.target.value.replace(/\D/g, '').slice(0, length); + const newValues = value.split('').concat(Array.from({ length: length - value.length }, () => '')); + setValues(newValues); + + // Focus the appropriate visible input + if (value.length > 0) { + focusInputAt(Math.min(value.length - 1, length - 1)); + } + }; + const centerSx = centerAlign ? { justifyContent: 'center', alignItems: 'center' } : {}; return ( - ({ direction: 'ltr', padding: t.space.$1, marginLeft: `-${t.space.$1}`, ...centerSx })} + - {values.map((value, index: number) => ( - (refs.current[index] = node)} - autoFocus={index === 0 || undefined} - autoComplete='one-time-code' - aria-label={`${index === 0 ? 'Enter verification code. ' : ''}Digit ${index + 1}`} - isDisabled={isDisabled || isLoading || disabled || feedbackType === 'success'} - hasError={feedbackType === 'error'} - isSuccessfullyFilled={feedbackType === 'success'} - type='text' - inputMode='numeric' - name={`codeInput-${index}`} - /> - ))} - + {/* Hidden input for password manager compatibility */} + { + // When password manager focuses the hidden input, focus the first visible input + focusInputAt(0); + }} + sx={() => ({ + ...common.visuallyHidden(), + left: '-9999px', + pointerEvents: 'none', + })} + /> + + ({ direction: 'ltr', padding: t.space.$1, marginLeft: `-${t.space.$1}`, ...centerSx })} + role='group' + aria-label='Verification code input' + > + {values.map((value: string, index: number) => ( + (refs.current[index] = node)} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={index === 0 || undefined} + autoComplete='off' + aria-label={`${index === 0 ? 'Enter verification code. ' : ''}Digit ${index + 1}`} + isDisabled={isDisabled || isLoading || disabled || feedbackType === 'success'} + hasError={feedbackType === 'error'} + isSuccessfullyFilled={feedbackType === 'success'} + type='text' + inputMode='numeric' + name={`codeInput-${index}`} + data-otp-segment + data-1p-ignore + data-lpignore='true' + maxLength={1} + pattern='[0-9]' + /> + ))} + + ); }); diff --git a/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx b/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx new file mode 100644 index 00000000000..41ffea4d0ad --- /dev/null +++ b/packages/clerk-js/src/ui/elements/__tests__/CodeControl.spec.tsx @@ -0,0 +1,592 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useFormControl } from '@/ui/utils/useFormControl'; + +import { bindCreateFixtures } from '../../utils/vitest/createFixtures'; +import { OTPCodeControl, OTPRoot, useFieldOTP } from '../CodeControl'; +import { withCardStateProvider } from '../contexts'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +// Mock the sleep utility +vi.mock('@/ui/utils/sleep', () => ({ + sleep: vi.fn(() => Promise.resolve()), +})); + +// Helper to create a test component with OTP functionality +const createOTPComponent = ( + onCodeEntryFinished: (code: string, resolve: any, reject: any) => void, + onResendCodeClicked?: () => void, + _options?: { length?: number }, +) => { + const MockOTPWrapper = withCardStateProvider(() => { + const otpField = useFieldOTP({ + onCodeEntryFinished, + onResendCodeClicked, + }); + + return ( + + + + ); + }); + + return MockOTPWrapper; +}; + +describe('CodeControl', () => { + describe('OTPCodeControl', () => { + it('renders 6 input fields by default', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + expect(inputs).toHaveLength(6); + }); + + it('renders hidden input for password manager compatibility', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + expect(hiddenInput).toBeInTheDocument(); + expect(hiddenInput).toHaveAttribute('type', 'text'); + expect(hiddenInput).toHaveAttribute('autoComplete', 'one-time-code'); + expect(hiddenInput).toHaveAttribute('inputMode', 'numeric'); + expect(hiddenInput).toHaveAttribute('pattern', '[0-9]{6}'); + expect(hiddenInput).toHaveAttribute('minLength', '6'); + expect(hiddenInput).toHaveAttribute('maxLength', '6'); + expect(hiddenInput).toHaveAttribute('aria-hidden', 'true'); + expect(hiddenInput).toHaveAttribute('tabIndex', '-1'); + }); + + it('autofocuses the first input field', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + // Wait for autofocus to take effect + await waitFor(() => { + const firstInput = container.querySelector('[name="codeInput-0"]'); + expect(firstInput).toHaveFocus(); + }); + }); + + it('allows typing single digits in sequence', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Type digits sequentially + await user.type(inputs[0], '1'); + await user.type(inputs[1], '2'); + await user.type(inputs[2], '3'); + + expect(inputs[0]).toHaveValue('1'); + expect(inputs[1]).toHaveValue('2'); + expect(inputs[2]).toHaveValue('3'); + }); + + it('calls onCodeEntryFinished when all 6 digits are entered', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Type all 6 digits + for (let i = 0; i < 6; i++) { + await user.type(inputs[i], `${i + 1}`); + } + + await waitFor(() => { + expect(onCodeEntryFinished).toHaveBeenCalledWith('123456', expect.any(Function), expect.any(Function)); + }); + }); + + it('handles paste operations correctly', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const firstInput = container.querySelector('[name="codeInput-0"]'); + + if (firstInput) { + await user.click(firstInput); + await user.paste('123456'); + } + + await waitFor(() => { + const inputs = container.querySelectorAll('[data-otp-segment]'); + expect(inputs[0]).toHaveValue('1'); + expect(inputs[1]).toHaveValue('2'); + expect(inputs[2]).toHaveValue('3'); + expect(inputs[3]).toHaveValue('4'); + expect(inputs[4]).toHaveValue('5'); + expect(inputs[5]).toHaveValue('6'); + }); + }); + + it('handles partial paste operations', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const secondInput = container.querySelector('[name="codeInput-1"]'); + + if (secondInput) { + await user.click(secondInput); + await user.paste('234'); + } + + await waitFor(() => { + const inputs = container.querySelectorAll('[data-otp-segment]'); + // Based on the actual behavior, paste fills from position 0 when using userEvent + expect(inputs[0]).toHaveValue('2'); + expect(inputs[1]).toHaveValue('3'); + expect(inputs[2]).toHaveValue('4'); + expect(inputs[3]).toHaveValue(''); + expect(inputs[4]).toHaveValue(''); + expect(inputs[5]).toHaveValue(''); + }); + }); + + it('handles keyboard navigation with arrow keys', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Start at first input + await user.click(inputs[0]); + + // Move right with arrow key + await user.keyboard('{ArrowRight}'); + expect(inputs[1]).toHaveFocus(); + + // Move left with arrow key + await user.keyboard('{ArrowLeft}'); + expect(inputs[0]).toHaveFocus(); + }); + + it('handles backspace to clear current field and move to previous', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Type some digits + await user.type(inputs[0], '1'); + await user.type(inputs[1], '2'); + await user.type(inputs[2], '3'); + + // Focus on third input and press backspace + await user.click(inputs[2]); + await user.keyboard('{Backspace}'); + + expect(inputs[2]).toHaveValue(''); + expect(inputs[1]).toHaveFocus(); + }); + + it('prevents space input', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const firstInput = container.querySelector('[name="codeInput-0"]'); + + if (firstInput) { + await user.click(firstInput); + await user.keyboard(' '); + + expect(firstInput).toHaveValue(''); + } + }); + + it('only accepts numeric characters', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const firstInput = container.querySelector('[name="codeInput-0"]'); + + if (firstInput) { + await user.click(firstInput); + await user.keyboard('a'); + + expect(firstInput).toHaveValue(''); + + await user.keyboard('1'); + expect(firstInput).toHaveValue('1'); + } + }); + + it('handles password manager autofill through hidden input', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const visibleInputs = container.querySelectorAll('[data-otp-segment]'); + + // Simulate password manager filling the hidden input + if (hiddenInput) { + fireEvent.change(hiddenInput, { target: { value: '654321' } }); + } + + await waitFor(() => { + expect(visibleInputs[0]).toHaveValue('6'); + expect(visibleInputs[1]).toHaveValue('5'); + expect(visibleInputs[2]).toHaveValue('4'); + expect(visibleInputs[3]).toHaveValue('3'); + expect(visibleInputs[4]).toHaveValue('2'); + expect(visibleInputs[5]).toHaveValue('1'); + }); + }); + + it('handles partial autofill through hidden input', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const visibleInputs = container.querySelectorAll('[data-otp-segment]'); + + // Simulate partial autofill + if (hiddenInput) { + fireEvent.change(hiddenInput, { target: { value: '123' } }); + } + + await waitFor(() => { + expect(visibleInputs[0]).toHaveValue('1'); + expect(visibleInputs[1]).toHaveValue('2'); + expect(visibleInputs[2]).toHaveValue('3'); + expect(visibleInputs[3]).toHaveValue(''); + expect(visibleInputs[4]).toHaveValue(''); + expect(visibleInputs[5]).toHaveValue(''); + }); + }); + + it('filters non-numeric characters in autofill', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const visibleInputs = container.querySelectorAll('[data-otp-segment]'); + + // Simulate autofill with mixed characters + if (hiddenInput) { + fireEvent.change(hiddenInput, { target: { value: '1a2b3c4d5e6f' } }); + } + + await waitFor(() => { + expect(visibleInputs[0]).toHaveValue('1'); + expect(visibleInputs[1]).toHaveValue('2'); + expect(visibleInputs[2]).toHaveValue('3'); + expect(visibleInputs[3]).toHaveValue('4'); + expect(visibleInputs[4]).toHaveValue('5'); + expect(visibleInputs[5]).toHaveValue('6'); + }); + }); + + it('focuses first visible input when hidden input is focused', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const firstVisibleInput = container.querySelector('[name="codeInput-0"]'); + + // Focus hidden input + if (hiddenInput) { + fireEvent.focus(hiddenInput); + } + + await waitFor(() => { + expect(firstVisibleInput).toHaveFocus(); + }); + }); + + it('handles disabled state correctly', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + + const MockOTPWrapper = withCardStateProvider(() => { + const otpField = useFieldOTP({ + onCodeEntryFinished, + }); + + return ( + + + + ); + }); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + inputs.forEach(input => { + expect(input).toBeDisabled(); + }); + }); + + it('handles loading state correctly', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + + const MockOTPWrapper = withCardStateProvider(() => { + const otpField = useFieldOTP({ + onCodeEntryFinished, + }); + + return ( + + + + ); + }); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + inputs.forEach(input => { + expect(input).toBeDisabled(); + }); + }); + + it('handles error state correctly', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + + const MockOTPWrapper = withCardStateProvider(() => { + const formControl = useFormControl('code', ''); + + // Set error after initial render to avoid infinite re-renders + React.useEffect(() => { + formControl.setError('Invalid code'); + }, []); // Empty dependency array to run only once + + const otpField = useFieldOTP({ + onCodeEntryFinished, + }); + + return ( + + + + ); + }); + + const { container } = render(, { wrapper }); + + const otpGroup = container.querySelector('[role="group"]'); + expect(otpGroup).toHaveAttribute('aria-label', 'Verification code input'); + }); + + it('handles first click on mobile devices', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // First click should focus the first input regardless of which input was clicked + await user.click(inputs[3]); + + await waitFor(() => { + expect(inputs[0]).toHaveFocus(); + }); + + // Second click should focus the clicked input + await user.click(inputs[3]); + + await waitFor(() => { + expect(inputs[3]).toHaveFocus(); + }); + }); + + it('updates hidden input when visible inputs change', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const hiddenInput = container.querySelector('[data-otp-hidden-input]'); + const visibleInputs = container.querySelectorAll('[data-otp-segment]'); + + // Type some digits + await user.type(visibleInputs[0], '1'); + await user.type(visibleInputs[1], '2'); + await user.type(visibleInputs[2], '3'); + + await waitFor(() => { + expect(hiddenInput).toHaveValue('123'); + }); + }); + + it('has correct accessibility attributes', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + const group = container.querySelector('[role="group"]'); + + expect(group).toHaveAttribute('aria-label', 'Verification code input'); + + inputs.forEach((input, index) => { + expect(input).toHaveAttribute( + 'aria-label', + index === 0 ? 'Enter verification code. Digit 1' : `Digit ${index + 1}`, + ); + expect(input).toHaveAttribute('inputMode', 'numeric'); + expect(input).toHaveAttribute('pattern', '[0-9]'); + expect(input).toHaveAttribute('maxLength', '1'); + }); + }); + + it('prevents password manager data attributes', async () => { + const { wrapper } = await createFixtures(); + const onCodeEntryFinished = vi.fn(); + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + inputs.forEach(input => { + expect(input).toHaveAttribute('data-1p-ignore'); + expect(input).toHaveAttribute('data-lpignore', 'true'); + }); + }); + }); + + describe('useFieldOTP hook', () => { + it('handles successful code entry', async () => { + const { wrapper } = await createFixtures(); + const _onResolve = vi.fn(); + const onCodeEntryFinished = vi.fn((code, resolve) => { + resolve('success'); + }); + + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Enter complete code + for (let i = 0; i < 6; i++) { + await user.type(inputs[i], `${i + 1}`); + } + + await waitFor(() => { + expect(onCodeEntryFinished).toHaveBeenCalledWith('123456', expect.any(Function), expect.any(Function)); + }); + }); + + it('handles code entry errors', async () => { + const { wrapper } = await createFixtures(); + + const onCodeEntryFinished = vi.fn((_, __, reject) => { + // Simulate synchronous error handling - just call reject + const error = new ClerkRuntimeError('Invalid code', { code: 'invalid_code' }); + reject(error); + }); + + const Component = createOTPComponent(onCodeEntryFinished); + + const { container } = render(, { wrapper }); + const user = userEvent.setup(); + + const inputs = container.querySelectorAll('[data-otp-segment]'); + + // Enter complete code + for (let i = 0; i < 6; i++) { + await user.type(inputs[i], `${i + 1}`); + } + + await waitFor(() => { + expect(onCodeEntryFinished).toHaveBeenCalledWith('123456', expect.any(Function), expect.any(Function)); + }); + }); + }); +}); diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index dc647795bee..a4e427dfd3b 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -218,6 +218,7 @@ export type ElementsConfig = { otpCodeField: WithOptions; otpCodeFieldInputs: WithOptions; otpCodeFieldInput: WithOptions; + otpCodeFieldInputContainer: WithOptions; otpCodeFieldErrorText: WithOptions; dividerRow: WithOptions; From f5f8ddd1cfad58283eaeb882a82943473a7987e9 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 7 Jul 2025 17:39:29 +0300 Subject: [PATCH 03/18] fix(clerk-js, shared): Propagate stripeAppearance to payment element (#6263) --- .changeset/quick-items-jog.md | 2 ++ packages/shared/src/react/commerce.tsx | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 .changeset/quick-items-jog.md diff --git a/.changeset/quick-items-jog.md b/.changeset/quick-items-jog.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quick-items-jog.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/commerce.tsx index 291912b41d1..3a3ab81d7d8 100644 --- a/packages/shared/src/react/commerce.tsx +++ b/packages/shared/src/react/commerce.tsx @@ -173,12 +173,10 @@ const PropsProvider = ({ children, ...props }: PropsWithChildren From 7f341dc7ed4982324e6dae59e803f42e794f2e2f Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 7 Jul 2025 08:39:06 -0700 Subject: [PATCH 04/18] feat(clerk-js): Remove dummy default free plan subscription (#6109) --- .changeset/lemon-walls-brush.md | 3 + .../src/ui/contexts/components/Plans.tsx | 72 +++---------------- 2 files changed, 13 insertions(+), 62 deletions(-) create mode 100644 .changeset/lemon-walls-brush.md diff --git a/.changeset/lemon-walls-brush.md b/.changeset/lemon-walls-brush.md new file mode 100644 index 00000000000..e2d82dd4e22 --- /dev/null +++ b/.changeset/lemon-walls-brush.md @@ -0,0 +1,3 @@ +--- +'@clerk/clerk-js': patch +--- diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index debc44894c5..f4fb6710a20 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -2,6 +2,7 @@ import { __experimental_usePaymentAttempts, __experimental_usePaymentMethods, __experimental_useStatements, + __experimental_useSubscriptionItems, useClerk, useOrganization, useSession, @@ -16,7 +17,6 @@ import type { import { useCallback, useMemo } from 'react'; import useSWR from 'swr'; -import { CommerceSubscription } from '@/core/resources/CommerceSubscription'; import { getClosestProfileScrollBox } from '@/ui/utils/getClosestProfileScrollBox'; import type { LocalizationKey } from '../../localization'; @@ -72,58 +72,14 @@ export const useStatements = (params?: { mode: 'cache' }) => { }; export const useSubscriptions = () => { - const { billing } = useClerk(); - const { organization } = useOrganization(); - const { user, isSignedIn } = useUser(); const subscriberType = useSubscriberTypeContext(); - const { data: plans } = usePlans(); - - const { data: _subscriptions, ...rest } = useSWR( - { - key: `commerce-subscriptions`, - userId: user?.id, - args: { orgId: subscriberType === 'org' ? organization?.id : undefined }, - }, - ({ args, userId }) => (userId ? billing.getSubscriptions(args) : undefined), - dedupeOptions, - ); - - const subscriptions = useMemo(() => { - if (!_subscriptions) { - return []; - } - const defaultFreePlan = plans?.find(plan => plan.hasBaseFee === false && plan.amount === 0); - - // are we signed in, is there a default free plan, and should it be shown as active or upcoming? then add an implicit subscription - if ( - isSignedIn && - defaultFreePlan && - (_subscriptions.data.length === 0 || !_subscriptions.data.some(subscription => !subscription.canceledAtDate)) - ) { - const canceledSubscription = _subscriptions.data.find(subscription => subscription.canceledAtDate); - return [ - ..._subscriptions.data, - new CommerceSubscription({ - object: 'commerce_subscription', - id: '__implicit_default_plan_subscription__', - payment_source_id: '', - plan: defaultFreePlan.__internal_toSnapshot(), - plan_period: 'month', - canceled_at: null, - status: _subscriptions.data.length === 0 ? 'active' : 'upcoming', - created_at: canceledSubscription?.periodEndDate?.getTime() || 0, - period_start: canceledSubscription?.periodEndDate?.getTime() || 0, - period_end: 0, - }), - ]; - } - return _subscriptions.data; - }, [_subscriptions, plans, isSignedIn]); - return { - data: subscriptions, - ...rest, - }; + return __experimental_useSubscriptionItems({ + for: subscriberType === 'org' ? 'organization' : 'user', + initialPage: 1, + pageSize: 10, + keepPreviousData: true, + }); }; export const usePlans = () => { @@ -167,7 +123,7 @@ export const usePlansContext = () => { return false; }, [clerk, subscriberType]); - const { data: subscriptions, mutate: mutateSubscriptions } = useSubscriptions(); + const { data: subscriptions, revalidate: revalidateSubscriptions } = useSubscriptions(); // Invalidates cache but does not fetch immediately const { data: plans, mutate: mutatePlans } = useSWR>>({ @@ -182,11 +138,11 @@ export const usePlansContext = () => { const revalidateAll = useCallback(() => { // Revalidate the plans and subscriptions - void mutateSubscriptions(); + void revalidateSubscriptions(); void mutatePlans(); void revalidateStatements(); void revalidatePaymentSources(); - }, [mutateSubscriptions, mutatePlans, revalidateStatements, revalidatePaymentSources]); + }, [revalidateSubscriptions, mutatePlans, revalidateStatements, revalidatePaymentSources]); // should the default plan be shown as active const isDefaultPlanImplicitlyActiveOrUpcoming = useMemo(() => { @@ -245,13 +201,6 @@ export const usePlansContext = () => { [activeOrUpcomingSubscription], ); - // should the default plan be shown as active - const upcomingSubscriptionsExist = useMemo(() => { - return ( - subscriptions.some(subscription => subscription.status === 'upcoming') || isDefaultPlanImplicitlyActiveOrUpcoming - ); - }, [subscriptions, isDefaultPlanImplicitlyActiveOrUpcoming]); - // return the CTA button props for a plan const buttonPropsForPlan = useCallback( ({ @@ -409,7 +358,6 @@ export const usePlansContext = () => { buttonPropsForPlan, canManageSubscription, captionForSubscription, - upcomingSubscriptionsExist, defaultFreePlan, revalidateAll, }; From 2fbdad0bd93618bcf3e9a1aa94383eb2785c157c Mon Sep 17 00:00:00 2001 From: Laura Beatris <48022589+LauraBeatris@users.noreply.github.com> Date: Mon, 7 Jul 2025 16:07:00 -0300 Subject: [PATCH 05/18] fix(clerk-js): Fix layout shift when navigating after task resolution (#6265) --- .changeset/ripe-months-stay.md | 5 +++++ .../src/ui/components/SessionTasks/index.tsx | 13 +++++++++---- .../tasks/ForceOrganizationSelection.tsx | 4 +++- packages/clerk-js/src/ui/types.ts | 1 + 4 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 .changeset/ripe-months-stay.md diff --git a/.changeset/ripe-months-stay.md b/.changeset/ripe-months-stay.md new file mode 100644 index 00000000000..8628a563620 --- /dev/null +++ b/.changeset/ripe-months-stay.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix layout shift when navigating after task resolution diff --git a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx index ab7f9191a06..2ab37fa68b3 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/index.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/index.tsx @@ -1,6 +1,6 @@ import { useClerk } from '@clerk/shared/react'; import { eventComponentMounted } from '@clerk/shared/telemetry'; -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { Card } from '@/ui/elements/Card'; import { withCardStateProvider } from '@/ui/elements/contexts'; @@ -57,6 +57,7 @@ export const SessionTask = withCardStateProvider(() => { const signInContext = useContext(SignInContext); const signUpContext = useContext(SignUpContext); const [isNavigatingToTask, setIsNavigatingToTask] = useState(false); + const currentTaskContainer = useRef(null); const redirectUrlComplete = signInContext?.afterSignInUrl ?? signUpContext?.afterSignUpUrl ?? clerk?.buildAfterSignInUrl(); @@ -88,8 +89,12 @@ export const SessionTask = withCardStateProvider(() => { if (!clerk.session?.currentTask) { return ( - - + ({ + minHeight: currentTaskContainer ? currentTaskContainer.current?.offsetHeight : undefined, + })} + > + ({ flex: 1 })}> @@ -98,7 +103,7 @@ export const SessionTask = withCardStateProvider(() => { } return ( - + ); diff --git a/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx b/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx index 04b2f4a769e..5f98dba91eb 100644 --- a/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx +++ b/packages/clerk-js/src/ui/components/SessionTasks/tasks/ForceOrganizationSelection.tsx @@ -3,6 +3,7 @@ import type { PropsWithChildren } from 'react'; import { useEffect, useRef, useState } from 'react'; import { OrganizationListContext } from '@/ui/contexts'; +import { useSessionTasksContext } from '@/ui/contexts/components/SessionTasks'; import { Card } from '@/ui/elements/Card'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; @@ -103,9 +104,10 @@ const CreateOrganizationPage = ({ currentFlow }: CommonPageProps) => { const FlowCard = ({ children }: PropsWithChildren) => { const card = useCardState(); + const { currentTaskContainer } = useSessionTasksContext(); return ( - + ({ padding: `${t.space.$8} ${t.space.$none} ${t.space.$none}` })}> ({ margin: `${t.space.$none} ${t.space.$5}` })}>{card.error} {children} diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 04cf9ee6366..5c13694a8b8 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -132,6 +132,7 @@ export type CheckoutCtx = __internal_CheckoutProps & { export type SessionTasksCtx = { nextTask: () => Promise; redirectUrlComplete?: string; + currentTaskContainer: React.RefObject | null; }; export type OAuthConsentCtx = __internal_OAuthConsentProps & { From 50fc006180519ffbdb6dfb5b9c6925482fb33849 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 7 Jul 2025 16:23:15 -0700 Subject: [PATCH 06/18] fix(clerk-js): Browser back/forward nav for tabs (#6264) --- .changeset/all-doors-sing.md | 5 +++++ packages/clerk-js/src/ui/hooks/useTabState.ts | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 .changeset/all-doors-sing.md diff --git a/.changeset/all-doors-sing.md b/.changeset/all-doors-sing.md new file mode 100644 index 00000000000..c6e02eb4f18 --- /dev/null +++ b/.changeset/all-doors-sing.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Fix browser back / forward navigation for tabs diff --git a/packages/clerk-js/src/ui/hooks/useTabState.ts b/packages/clerk-js/src/ui/hooks/useTabState.ts index e6706058f0f..ddaef03d384 100644 --- a/packages/clerk-js/src/ui/hooks/useTabState.ts +++ b/packages/clerk-js/src/ui/hooks/useTabState.ts @@ -1,4 +1,4 @@ -import React from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from '../router'; @@ -15,7 +15,15 @@ export const useTabState = (tabMap: TabMap, defaultTab = 0) => { return tabIndex ? parseInt(tabIndex, 10) : defaultTab; }; - const [selectedTab, setSelectedTab] = React.useState(getInitialTab()); + const [selectedTab, setSelectedTab] = useState(getInitialTab()); + + // Listen for URL changes (browser back/forward) + useEffect(() => { + const currentTab = getInitialTab(); + if (currentTab !== selectedTab) { + setSelectedTab(currentTab); + } + }, [router.queryParams.tab]); const handleTabChange = (index: number) => { setSelectedTab(index); From 0e0cc1fa85347d727a4fd3718fe45b0f0244ddd9 Mon Sep 17 00:00:00 2001 From: Jacek Radko Date: Mon, 7 Jul 2025 22:10:35 -0500 Subject: [PATCH 07/18] feat(shared): Enhance publishable key validation (#6266) --- .changeset/smooth-papayas-try.md | 5 + .../__snapshots__/file-structure.test.ts.snap | 9 ++ packages/shared/src/__tests__/keys.test.ts | 85 ++++++++-- packages/shared/src/keys.ts | 147 +++++++++++++++++- 4 files changed, 223 insertions(+), 23 deletions(-) create mode 100644 .changeset/smooth-papayas-try.md diff --git a/.changeset/smooth-papayas-try.md b/.changeset/smooth-papayas-try.md new file mode 100644 index 00000000000..74913a22f21 --- /dev/null +++ b/.changeset/smooth-papayas-try.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Enhancing publishable key parsing and validation logic to validate expected format diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index 3e9d7c11042..f683de4a0d3 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -76,9 +76,11 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "types/without.mdx", "shared/api-url-from-publishable-key.mdx", "shared/build-clerk-js-script-attributes.mdx", + "shared/build-publishable-key.mdx", "shared/camel-to-snake.mdx", "shared/clerk-js-script-url.mdx", "shared/clerk-runtime-error.mdx", + "shared/create-dev-or-staging-url-cache.mdx", "shared/create-path-matcher.mdx", "shared/deep-camel-to-snake.mdx", "shared/deep-snake-to-camel.mdx", @@ -87,14 +89,20 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "shared/extract-dev-browser-jwt-from-url.mdx", "shared/fast-deep-merge-and-replace.mdx", "shared/get-clerk-js-major-version-or-tag.mdx", + "shared/get-cookie-suffix.mdx", "shared/get-env-variable.mdx", "shared/get-non-undefined-values.mdx", "shared/get-script-url.mdx", + "shared/get-suffixed-cookie-name.mdx", "shared/icon-image-url.mdx", "shared/in-browser.mdx", "shared/is-browser-online.mdx", "shared/is-clerk-runtime-error.mdx", + "shared/is-development-from-publishable-key.mdx", + "shared/is-development-from-secret-key.mdx", "shared/is-ipv4-address.mdx", + "shared/is-production-from-publishable-key.mdx", + "shared/is-production-from-secret-key.mdx", "shared/is-publishable-key.mdx", "shared/is-staging.mdx", "shared/is-truthy.mdx", @@ -105,6 +113,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "shared/pages-or-infinite-options.mdx", "shared/paginated-hook-config.mdx", "shared/paginated-resources.mdx", + "shared/parse-publishable-key.mdx", "shared/read-json-file.mdx", "shared/set-clerk-js-loading-error-package-name.mdx", "shared/snake-to-camel.mdx", diff --git a/packages/shared/src/__tests__/keys.test.ts b/packages/shared/src/__tests__/keys.test.ts index 1d06321b34e..3ce71504786 100644 --- a/packages/shared/src/__tests__/keys.test.ts +++ b/packages/shared/src/__tests__/keys.test.ts @@ -12,7 +12,7 @@ import { describe('buildPublishableKey(frontendApi)', () => { const cases = [ - ['example.clerk.accounts.dev', 'pk_live_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk'], + ['fake-clerk-test.clerk.accounts.dev', 'pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ='], ['foo-bar-13.clerk.accounts.dev', 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk'], ['clerk.boring.sawfly-91.lcl.dev', 'pk_test_Y2xlcmsuYm9yaW5nLnNhd2ZseS05MS5sY2wuZGV2JA=='], ['clerk.boring.sawfly-91.lclclerk.com', 'pk_test_Y2xlcmsuYm9yaW5nLnNhd2ZseS05MS5sY2xjbGVyay5jb20k'], @@ -34,12 +34,8 @@ describe('parsePublishableKey(key)', () => { ['', null], ['whatever', null], [ - 'pk_live_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk', - { instanceType: 'production', frontendApi: 'example.clerk.accounts.dev' }, - ], - [ - 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', - { instanceType: 'development', frontendApi: 'foo-bar-13.clerk.accounts.dev' }, + 'pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', + { instanceType: 'production', frontendApi: 'fake-clerk-test.clerk.accounts.dev' }, ], [ 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', @@ -54,6 +50,30 @@ describe('parsePublishableKey(key)', () => { expect(result).toEqual(expectedPublishableKey); }); + it('returns null for keys with extra characters after $', () => { + expect(parsePublishableKey('pk_live_ZmFrZS1jbGVyay1tYWxmb3JtZWQuY2xlcmsuYWNjb3VudHMuZGV2JGV4dHJh')).toBeNull(); + }); + + it('throws an error for keys with extra characters after $ when fatal: true', () => { + expect(() => + parsePublishableKey('pk_live_ZmFrZS1jbGVyay1tYWxmb3JtZWQuY2xlcmsuYWNjb3VudHMuZGV2JGV4dHJh', { fatal: true }), + ).toThrowError('Publishable key not valid.'); + }); + + it('returns null for keys with multiple $ characters', () => { + expect(parsePublishableKey('pk_live_ZmFrZS1jbGVyay1tdWx0aXBsZS5jbGVyay5hY2NvdW50cy5kZXYkJA==')).toBeNull(); + }); + + it('returns null for keys without proper domain format', () => { + expect(parsePublishableKey('pk_live_aW52YWxpZGtleSQ=')).toBeNull(); + }); + + it('throws an error if the key cannot be decoded when fatal: true', () => { + expect(() => parsePublishableKey('pk_live_invalid!@#$', { fatal: true })).toThrowError( + 'Publishable key not valid.', + ); + }); + it('throws an error if the key is not a valid publishable key, when fatal: true', () => { expect(() => parsePublishableKey('fake_pk', { fatal: true })).toThrowError('Publishable key not valid.'); }); @@ -65,7 +85,11 @@ describe('parsePublishableKey(key)', () => { }); it('applies the proxyUrl if provided', () => { - expect(parsePublishableKey('pk_live_Y2xlcmsuY2xlcmsuZGV2JA==', { proxyUrl: 'example.com/__clerk' })).toEqual({ + expect( + parsePublishableKey('pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', { + proxyUrl: 'example.com/__clerk', + }), + ).toEqual({ frontendApi: 'example.com/__clerk', instanceType: 'production', }); @@ -73,7 +97,10 @@ describe('parsePublishableKey(key)', () => { it('applies the domain if provided for production keys and isSatellite is true', () => { expect( - parsePublishableKey('pk_live_Y2xlcmsuY2xlcmsuZGV2JA==', { domain: 'example.com', isSatellite: true }), + parsePublishableKey('pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', { + domain: 'example.com', + isSatellite: true, + }), ).toEqual({ frontendApi: 'clerk.example.com', instanceType: 'production', @@ -82,9 +109,12 @@ describe('parsePublishableKey(key)', () => { it('ignores domain for production keys when isSatellite is false', () => { expect( - parsePublishableKey('pk_live_Y2xlcmsuY2xlcmsuZGV2JA==', { domain: 'example.com', isSatellite: false }), + parsePublishableKey('pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', { + domain: 'example.com', + isSatellite: false, + }), ).toEqual({ - frontendApi: 'clerk.clerk.dev', + frontendApi: 'fake-clerk-test.clerk.accounts.dev', instanceType: 'production', }); }); @@ -101,12 +131,33 @@ describe('parsePublishableKey(key)', () => { describe('isPublishableKey(key)', () => { it('returns true if the key is a valid publishable key', () => { - expect(isPublishableKey('pk_live_Y2xlcmsuY2xlcmsuZGV2JA==')).toBe(true); + expect(isPublishableKey('pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=')).toBe(true); + expect(isPublishableKey('pk_test_Y2xlcmsuY2xlcmsuZGV2JA==')).toBe(true); }); it('returns false if the key is not a valid publishable key', () => { expect(isPublishableKey('clerk.clerk.com')).toBe(false); }); + + it('returns false if the key has invalid structure', () => { + expect(isPublishableKey('pk_live')).toBe(false); + expect(isPublishableKey('pk_live_')).toBe(false); + expect(isPublishableKey('pk_live_invalid')).toBe(false); + }); + + it('returns false if the decoded key has extra characters after $', () => { + expect(isPublishableKey('pk_live_ZmFrZS1jbGVyay1tYWxmb3JtZWQuY2xlcmsuYWNjb3VudHMuZGV2JGV4dHJh')).toBe(false); + expect(isPublishableKey('pk_test_Y2xlcmsuY2xlcmsuZGV2JGV4dHJh')).toBe(false); + }); + + it('returns false if the decoded key has multiple $ characters', () => { + expect(isPublishableKey('pk_live_ZmFrZS1jbGVyay1tdWx0aXBsZS5jbGVyay5hY2NvdW50cy5kZXYkJA==')).toBe(false); + expect(isPublishableKey('pk_live_JGZha2UtY2xlcmstcHJlZml4LmNsZXJrLmFjY291bnRzLmRldiQ=')).toBe(false); + }); + + it('returns false if the decoded key does not look like a domain', () => { + expect(isPublishableKey('pk_live_aW52YWxpZGtleSQ=')).toBe(false); + }); }); describe('isDevOrStagingUrl(url)', () => { @@ -138,9 +189,9 @@ describe('isDevOrStagingUrl(url)', () => { describe('isDevelopmentFromPublishableKey(key)', () => { const cases: Array<[string, boolean]> = [ - ['pk_live_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk', false], + ['pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', false], ['pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', true], - ['live_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk', false], + ['live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', false], ['test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', true], ]; @@ -152,9 +203,9 @@ describe('isDevelopmentFromPublishableKey(key)', () => { describe('isProductionFromPublishableKey(key)', () => { const cases: Array<[string, boolean]> = [ - ['pk_live_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk', true], + ['pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', true], ['pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', false], - ['live_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk', true], + ['live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', true], ['test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk', false], ]; @@ -194,7 +245,7 @@ describe('isProductionFromSecretKey(key)', () => { describe('getCookieSuffix(publishableKey, subtle?)', () => { const cases: Array<[string, string]> = [ - ['pk_live_Y2xlcmsuY2xlcmsuZGV2JA', '1Z8AzTQD'], + ['pk_live_ZmFrZS1jbGVyay10ZXN0LmNsZXJrLmFjY291bnRzLmRldiQ=', 'qReyu04C'], ['pk_test_Y2xlcmsuY2xlcmsuZGV2JA', 'QvfNY2dr'], ]; diff --git a/packages/shared/src/keys.ts b/packages/shared/src/keys.ts index 956b8488fc3..c327cc0a96d 100644 --- a/packages/shared/src/keys.ts +++ b/packages/shared/src/keys.ts @@ -4,19 +4,38 @@ import { DEV_OR_STAGING_SUFFIXES, LEGACY_DEV_INSTANCE_SUFFIXES } from './constan import { isomorphicAtob } from './isomorphicAtob'; import { isomorphicBtoa } from './isomorphicBtoa'; +/** + * Configuration options for parsing publishable keys. + */ type ParsePublishableKeyOptions = { + /** Whether to throw an error if parsing fails */ fatal?: boolean; + /** Custom domain to use for satellite instances */ domain?: string; + /** Proxy URL to use instead of the decoded frontend API */ proxyUrl?: string; + /** Whether this is a satellite instance */ isSatellite?: boolean; }; +/** Prefix used for production publishable keys */ const PUBLISHABLE_KEY_LIVE_PREFIX = 'pk_live_'; + +/** Prefix used for development publishable keys */ const PUBLISHABLE_KEY_TEST_PREFIX = 'pk_test_'; -// This regex matches the publishable like frontend API keys (e.g. foo-bar-13.clerk.accounts.dev) +/** + * Regular expression that matches development frontend API keys. + * Matches patterns like: foo-bar-13.clerk.accounts.dev. + */ const PUBLISHABLE_FRONTEND_API_DEV_REGEX = /^(([a-z]+)-){2}([0-9]{1,2})\.clerk\.accounts([a-z.]*)(dev|com)$/i; +/** + * Converts a frontend API URL into a base64-encoded publishable key. + * + * @param frontendApi - The frontend API URL (e.g., 'clerk.example.com'). + * @returns A base64-encoded publishable key with appropriate prefix (pk_live_ or pk_test_). + */ export function buildPublishableKey(frontendApi: string): string { const isDevKey = PUBLISHABLE_FRONTEND_API_DEV_REGEX.test(frontendApi) || @@ -25,6 +44,26 @@ export function buildPublishableKey(frontendApi: string): string { return `${keyPrefix}${isomorphicBtoa(`${frontendApi}$`)}`; } +/** + * Validates that a decoded publishable key has the correct format. + * The decoded value should be a frontend API followed by exactly one '$' at the end. + * + * @param decoded - The decoded publishable key string to validate. + * @returns `true` if the decoded key has valid format, `false` otherwise. + */ +function isValidDecodedPublishableKey(decoded: string): boolean { + if (!decoded.endsWith('$')) { + return false; + } + + const withoutTrailing = decoded.slice(0, -1); + if (withoutTrailing.includes('$')) { + return false; + } + + return withoutTrailing.includes('.'); +} + export function parsePublishableKey( key: string | undefined, options: ParsePublishableKeyOptions & { fatal: true }, @@ -33,6 +72,19 @@ export function parsePublishableKey( key: string | undefined, options?: ParsePublishableKeyOptions, ): PublishableKey | null; +/** + * Parses and validates a publishable key, extracting the frontend API and instance type. + * + * @param key - The publishable key to parse. + * @param options - Configuration options for parsing. + * @param options.fatal + * @param options.domain + * @param options.proxyUrl + * @param options.isSatellite + * @returns Parsed publishable key object with instanceType and frontendApi, or null if invalid. + * + * @throws {Error} When options.fatal is true and key is missing or invalid. + */ export function parsePublishableKey( key: string | undefined, options: { fatal?: boolean; domain?: string; proxyUrl?: string; isSatellite?: boolean } = {}, @@ -53,10 +105,24 @@ export function parsePublishableKey( const instanceType = key.startsWith(PUBLISHABLE_KEY_LIVE_PREFIX) ? 'production' : 'development'; - let frontendApi = isomorphicAtob(key.split('_')[2]); + let decodedFrontendApi: string; + try { + decodedFrontendApi = isomorphicAtob(key.split('_')[2]); + } catch { + if (options.fatal) { + throw new Error('Publishable key not valid: Failed to decode key.'); + } + return null; + } + + if (!isValidDecodedPublishableKey(decodedFrontendApi)) { + if (options.fatal) { + throw new Error('Publishable key not valid: Decoded key has invalid format.'); + } + return null; + } - // TODO(@dimkl): validate packages/clerk-js/src/utils/instance.ts - frontendApi = frontendApi.slice(0, -1); + let frontendApi = decodedFrontendApi.slice(0, -1); if (options.proxyUrl) { frontendApi = options.proxyUrl; @@ -80,18 +146,43 @@ export function isPublishableKey(key: string = '') { try { const hasValidPrefix = key.startsWith(PUBLISHABLE_KEY_LIVE_PREFIX) || key.startsWith(PUBLISHABLE_KEY_TEST_PREFIX); - const hasValidFrontendApiPostfix = isomorphicAtob(key.split('_')[2] || '').endsWith('$'); + if (!hasValidPrefix) { + return false; + } + + const parts = key.split('_'); + if (parts.length !== 3) { + return false; + } + + const encodedPart = parts[2]; + if (!encodedPart) { + return false; + } - return hasValidPrefix && hasValidFrontendApiPostfix; + const decoded = isomorphicAtob(encodedPart); + return isValidDecodedPublishableKey(decoded); } catch { return false; } } +/** + * Creates a memoized cache for checking if URLs are development or staging environments. + * Uses a Map to cache results for better performance on repeated checks. + * + * @returns An object with an isDevOrStagingUrl method that checks if a URL is dev/staging. + */ export function createDevOrStagingUrlCache() { const devOrStagingUrlCache = new Map(); return { + /** + * Checks if a URL is a development or staging environment. + * + * @param url - The URL to check (string or URL object). + * @returns `true` if the URL is a development or staging environment, `false` otherwise. + */ isDevOrStagingUrl: (url: string | URL): boolean => { if (!url) { return false; @@ -108,22 +199,58 @@ export function createDevOrStagingUrlCache() { }; } +/** + * Checks if a publishable key is for a development environment. + * Supports both legacy format (test_) and new format (pk_test_). + * + * @param apiKey - The API key to check. + * @returns `true` if the key is for development, `false` otherwise. + */ export function isDevelopmentFromPublishableKey(apiKey: string): boolean { return apiKey.startsWith('test_') || apiKey.startsWith('pk_test_'); } +/** + * Checks if a publishable key is for a production environment. + * Supports both legacy format (live_) and new format (pk_live_). + * + * @param apiKey - The API key to check. + * @returns `true` if the key is for production, `false` otherwise. + */ export function isProductionFromPublishableKey(apiKey: string): boolean { return apiKey.startsWith('live_') || apiKey.startsWith('pk_live_'); } +/** + * Checks if a secret key is for a development environment. + * Supports both legacy format (test_) and new format (sk_test_). + * + * @param apiKey - The secret key to check. + * @returns `true` if the key is for development, `false` otherwise. + */ export function isDevelopmentFromSecretKey(apiKey: string): boolean { return apiKey.startsWith('test_') || apiKey.startsWith('sk_test_'); } +/** + * Checks if a secret key is for a production environment. + * Supports both legacy format (live_) and new format (sk_live_). + * + * @param apiKey - The secret key to check. + * @returns `true` if the key is for production, `false` otherwise. + */ export function isProductionFromSecretKey(apiKey: string): boolean { return apiKey.startsWith('live_') || apiKey.startsWith('sk_live_'); } +/** + * Generates a unique cookie suffix based on the publishable key using SHA-1 hashing. + * The suffix is base64-encoded and URL-safe (+ and / characters are replaced). + * + * @param publishableKey - The publishable key to generate suffix from. + * @param subtle - The SubtleCrypto interface to use for hashing (defaults to globalThis.crypto.subtle). + * @returns A promise that resolves to an 8-character URL-safe base64 string. + */ export async function getCookieSuffix( publishableKey: string, subtle: SubtleCrypto = globalThis.crypto.subtle, @@ -135,6 +262,14 @@ export async function getCookieSuffix( return isomorphicBtoa(stringDigest).replace(/\+/gi, '-').replace(/\//gi, '_').substring(0, 8); } +/** + * Creates a suffixed cookie name by appending the cookie suffix to the base name. + * Used to create unique cookie names based on the publishable key. + * + * @param cookieName - The base cookie name. + * @param cookieSuffix - The suffix to append (typically generated by getCookieSuffix). + * @returns The suffixed cookie name in format: `${cookieName}_${cookieSuffix}`. + */ export const getSuffixedCookieName = (cookieName: string, cookieSuffix: string): string => { return `${cookieName}_${cookieSuffix}`; }; From 22be9f06d3419baa32212e2e5a5fa91d40240f7d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 8 Jul 2025 09:14:16 -0400 Subject: [PATCH 08/18] feat(clerk-js): Update color logic utils to support CSS variable usage (#6187) Co-authored-by: Dylan Staley <88163+dstaley@users.noreply.github.com> --- .changeset/chubby-parts-try.md | 7 + packages/clerk-js/bundlewatch.config.json | 4 +- .../OrganizationSwitcherTrigger.tsx | 4 +- .../PaymentSources/AddPaymentSource.tsx | 48 +- .../__tests__/parseVariables.spec.ts | 58 ++ .../src/ui/customizables/parseVariables.ts | 116 ++-- .../src/ui/elements/Card/CardFooter.tsx | 2 +- .../clerk-js/src/ui/elements/CodeControl.tsx | 2 +- packages/clerk-js/src/ui/elements/Navbar.tsx | 4 +- .../clerk-js/src/ui/elements/PopoverCard.tsx | 2 +- .../clerk-js/src/ui/foundations/colors.ts | 10 +- .../src/ui/utils/__tests__/colors.test.ts | 36 -- .../ui/utils/__tests__/cssSupports.test.ts | 42 ++ .../ui/utils/__tests__/cssVariables.spec.ts | 522 ++++++++++++++++++ .../__tests__/normalizeColorString.spec.ts | 107 ---- .../src/ui/utils/colorOptionToHslaScale.ts | 161 ------ .../clerk-js/src/ui/utils/colors/README.md | 58 ++ .../utils/colors/__tests__/constants.spec.ts | 245 ++++++++ .../ui/utils/colors/__tests__/index.spec.ts | 244 ++++++++ .../ui/utils/colors/__tests__/legacy.spec.ts | 449 +++++++++++++++ .../ui/utils/colors/__tests__/modern.spec.ts | 222 ++++++++ .../ui/utils/colors/__tests__/scales.spec.ts | 367 ++++++++++++ .../ui/utils/colors/__tests__/utils.spec.ts | 188 +++++++ .../clerk-js/src/ui/utils/colors/constants.ts | 107 ++++ .../clerk-js/src/ui/utils/colors/index.ts | 160 ++++++ .../ui/utils/{colors.ts => colors/legacy.ts} | 16 +- .../clerk-js/src/ui/utils/colors/modern.ts | 106 ++++ .../clerk-js/src/ui/utils/colors/scales.ts | 291 ++++++++++ .../clerk-js/src/ui/utils/colors/utils.ts | 137 +++++ packages/clerk-js/src/ui/utils/cssSupports.ts | 50 ++ .../clerk-js/src/ui/utils/cssVariables.ts | 235 ++++++++ .../src/ui/utils/normalizeColorString.ts | 88 --- 32 files changed, 3616 insertions(+), 472 deletions(-) create mode 100644 .changeset/chubby-parts-try.md create mode 100644 packages/clerk-js/src/ui/customizables/__tests__/parseVariables.spec.ts delete mode 100644 packages/clerk-js/src/ui/utils/__tests__/colors.test.ts create mode 100644 packages/clerk-js/src/ui/utils/__tests__/cssSupports.test.ts create mode 100644 packages/clerk-js/src/ui/utils/__tests__/cssVariables.spec.ts delete mode 100644 packages/clerk-js/src/ui/utils/__tests__/normalizeColorString.spec.ts delete mode 100644 packages/clerk-js/src/ui/utils/colorOptionToHslaScale.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/README.md create mode 100644 packages/clerk-js/src/ui/utils/colors/__tests__/constants.spec.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/__tests__/index.spec.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/__tests__/legacy.spec.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/__tests__/modern.spec.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/__tests__/scales.spec.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/constants.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/index.ts rename packages/clerk-js/src/ui/utils/{colors.ts => colors/legacy.ts} (94%) create mode 100644 packages/clerk-js/src/ui/utils/colors/modern.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/scales.ts create mode 100644 packages/clerk-js/src/ui/utils/colors/utils.ts create mode 100644 packages/clerk-js/src/ui/utils/cssSupports.ts create mode 100644 packages/clerk-js/src/ui/utils/cssVariables.ts delete mode 100644 packages/clerk-js/src/ui/utils/normalizeColorString.ts diff --git a/.changeset/chubby-parts-try.md b/.changeset/chubby-parts-try.md new file mode 100644 index 00000000000..d06a51e52c5 --- /dev/null +++ b/.changeset/chubby-parts-try.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +--- + +Add CSS variable support to the `appearance.variables` object, enabling use of CSS custom properties. For example, you can now use `colorPrimary: 'var(--brand-color)'` to reference CSS variables defined in your stylesheets. + +This feature includes automatic fallback support for browsers that don't support modern CSS color manipulation features. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 23508f6f68f..09c556354dd 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "111.8KB" }, - { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "112.1KB" }, + { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "113.67KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" }, @@ -23,7 +23,7 @@ { "path": "./dist/waitlist*.js", "maxSize": "1.5KB" }, { "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" }, { "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" }, - { "path": "./dist/checkout*.js", "maxSize": "8.34KB" }, + { "path": "./dist/checkout*.js", "maxSize": "8.4KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/up-plans-page*.js", "maxSize": "1.0KB" }, diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx index a5071b8de59..1c09ebe39e0 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherTrigger.tsx @@ -113,8 +113,8 @@ const NotificationCountBadgeSwitcherTrigger = () => { ({ position: 'absolute', - top: `-${t.space.$2}`, - right: `-${t.space.$2}`, + top: `calc(${t.space.$2} * -1)`, + right: `calc(${t.space.$2} * -1)`, })} notificationCount={notificationCount} /> diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index 5ce088e1170..af2bee8f80b 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -13,37 +13,40 @@ import { useCardState } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtons } from '@/ui/elements/FormButtons'; import { FormContainer } from '@/ui/elements/FormContainer'; +import { resolveComputedCSSColor, resolveComputedCSSProperty } from '@/ui/utils/cssVariables'; import { handleError } from '@/ui/utils/errorHandler'; -import { normalizeColorString } from '@/ui/utils/normalizeColorString'; import { useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts'; import { descriptors, Flex, localizationKeys, Spinner, useAppearance, useLocalizations } from '../../customizables'; import type { LocalizationKey } from '../../localization'; -const useStripeAppearance = () => { +const useStripeAppearance = (node: HTMLElement | null) => { const theme = useAppearance().parsedInternalTheme; return useMemo(() => { + if (!node) { + return undefined; + } const { colors, fontWeights, fontSizes, radii, space } = theme; return { - 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, + 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), }; - }, [theme]); + }, [theme, node]); }; type AddPaymentSourceProps = { @@ -66,11 +69,12 @@ const [AddPaymentSourceContext, useAddPaymentSourceContext] = createContextAndHo const AddPaymentSourceRoot = ({ children, checkout, ...rest }: PropsWithChildren) => { const subscriberType = useSubscriberTypeContext(); + const stripeAppearanceNode = useRef(null); const { t } = useLocalizations(); const [headerTitle, setHeaderTitle] = useState(undefined); const [headerSubtitle, setHeaderSubtitle] = useState(undefined); const [submitLabel, setSubmitLabel] = useState(undefined); - const stripeAppearance = useStripeAppearance(); + const stripeAppearance = useStripeAppearance(stripeAppearanceNode.current); return ( +
({ + cssSupports: { + modernColor: vi.fn(), + }, +})); + +import { cssSupports } from '@/ui/utils/cssSupports'; + +import { removeInvalidValues } from '../parseVariables'; + +const mockModernColorSupport = vi.mocked(cssSupports.modernColor); + +describe('removeInvalidValues', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockModernColorSupport.mockReturnValue(false); + }); + + it('should return the variables as-is if modern color support is present', () => { + mockModernColorSupport.mockReturnValue(true); + const variables = { + colorPrimary: 'var(--color-primary)', + }; + + const result = removeInvalidValues(variables); + expect(result).toEqual({ colorPrimary: 'var(--color-primary)' }); + }); + + it('should remove invalid string values if modern color support is not present', () => { + mockModernColorSupport.mockReturnValue(false); + const variables = { + colorPrimary: 'var(--color-primary)', + colorDanger: '#ff0000', + }; + + const result = removeInvalidValues(variables); + expect(result).toEqual({ colorDanger: '#ff0000' }); + }); + + it('should remove invalid object values if modern color support is not present', () => { + mockModernColorSupport.mockReturnValue(false); + const variables = { + colorPrimary: { + 400: 'var(--color-primary-500)', + 500: '#fff', + }, + colorDanger: { + 500: '#ff0000', + }, + }; + + const result = removeInvalidValues(variables); + expect(result).toEqual({ colorDanger: { 500: '#ff0000' } }); + }); +}); diff --git a/packages/clerk-js/src/ui/customizables/parseVariables.ts b/packages/clerk-js/src/ui/customizables/parseVariables.ts index 5afe51f7c11..9a8a49017a7 100644 --- a/packages/clerk-js/src/ui/customizables/parseVariables.ts +++ b/packages/clerk-js/src/ui/customizables/parseVariables.ts @@ -2,46 +2,89 @@ import type { Theme } from '@clerk/types'; import { spaceScaleKeys } from '../foundations/sizes'; import type { fontSizes, fontWeights } from '../foundations/typography'; -import { colorOptionToHslaAlphaScale, colorOptionToHslaLightnessScale } from '../utils/colorOptionToHslaScale'; import { colors } from '../utils/colors'; +import { colorOptionToThemedAlphaScale, colorOptionToThemedLightnessScale } from '../utils/colors/scales'; +import { cssSupports } from '../utils/cssSupports'; import { fromEntries } from '../utils/fromEntries'; import { removeUndefinedProps } from '../utils/removeUndefinedProps'; export const createColorScales = (theme: Theme) => { - const variables = theme.variables || {}; + const variables = removeInvalidValues(theme.variables || {}); - const primaryScale = colorOptionToHslaLightnessScale(variables.colorPrimary, 'primary'); - const primaryAlphaScale = colorOptionToHslaAlphaScale(primaryScale?.primary500, 'primaryAlpha'); - const dangerScale = colorOptionToHslaLightnessScale(variables.colorDanger, 'danger'); - const dangerAlphaScale = colorOptionToHslaAlphaScale(dangerScale?.danger500, 'dangerAlpha'); - const successScale = colorOptionToHslaLightnessScale(variables.colorSuccess, 'success'); - const successAlphaScale = colorOptionToHslaAlphaScale(successScale?.success500, 'successAlpha'); - const warningScale = colorOptionToHslaLightnessScale(variables.colorWarning, 'warning'); - const warningAlphaScale = colorOptionToHslaAlphaScale(warningScale?.warning500, 'warningAlpha'); + const dangerScale = colorOptionToThemedLightnessScale(variables.colorDanger, 'danger'); + const primaryScale = colorOptionToThemedLightnessScale(variables.colorPrimary, 'primary'); + const successScale = colorOptionToThemedLightnessScale(variables.colorSuccess, 'success'); + const warningScale = colorOptionToThemedLightnessScale(variables.colorWarning, 'warning'); + + const dangerAlphaScale = colorOptionToThemedAlphaScale(dangerScale?.danger500, 'dangerAlpha'); + const neutralAlphaScale = colorOptionToThemedAlphaScale(variables.colorNeutral, 'neutralAlpha'); + const primaryAlphaScale = colorOptionToThemedAlphaScale(primaryScale?.primary500, 'primaryAlpha'); + const successAlphaScale = colorOptionToThemedAlphaScale(successScale?.success500, 'successAlpha'); + const warningAlphaScale = colorOptionToThemedAlphaScale(warningScale?.warning500, 'warningAlpha'); return removeUndefinedProps({ - ...primaryScale, - ...primaryAlphaScale, ...dangerScale, - ...dangerAlphaScale, + ...primaryScale, ...successScale, - ...successAlphaScale, ...warningScale, + ...dangerAlphaScale, + ...neutralAlphaScale, + ...primaryAlphaScale, + ...successAlphaScale, ...warningAlphaScale, - ...colorOptionToHslaAlphaScale(variables.colorNeutral, 'neutralAlpha'), primaryHover: colors.adjustForLightness(primaryScale?.primary500), - colorTextOnPrimaryBackground: toHSLA(variables.colorTextOnPrimaryBackground), - colorText: toHSLA(variables.colorText), - colorTextSecondary: toHSLA(variables.colorTextSecondary) || colors.makeTransparent(variables.colorText, 0.35), - colorInputText: toHSLA(variables.colorInputText), - colorBackground: toHSLA(variables.colorBackground), - colorInputBackground: toHSLA(variables.colorInputBackground), - colorShimmer: toHSLA(variables.colorShimmer), + colorTextOnPrimaryBackground: colors.toHslaString(variables.colorTextOnPrimaryBackground), + colorText: colors.toHslaString(variables.colorText), + colorTextSecondary: + colors.toHslaString(variables.colorTextSecondary) || colors.makeTransparent(variables.colorText, 0.35), + colorInputText: colors.toHslaString(variables.colorInputText), + colorBackground: colors.toHslaString(variables.colorBackground), + colorInputBackground: colors.toHslaString(variables.colorInputBackground), + colorShimmer: colors.toHslaString(variables.colorShimmer), }); }; -export const toHSLA = (str: string | undefined) => { - return str ? colors.toHslaString(str) : undefined; +export const removeInvalidValues = (variables: NonNullable): NonNullable => { + // Check for modern color support. If present, we can simply return the variables as-is since we support everything + // CSS supports. + if (cssSupports.modernColor()) { + return variables; + } + + // If not, we need to remove any values that are specified with CSS variables, as our color scale generation only + // supports CSS variables using modern CSS functionality. + const validVariables: Theme['variables'] = Object.fromEntries( + Object.entries(variables).filter(([key, value]) => { + if (typeof value === 'string') { + const isValid = !value.startsWith('var('); + if (!isValid) { + console.warn( + `Invalid color value: ${value} for key: ${key}, using default value instead. Using CSS variables is not supported in this browser.`, + ); + } + return isValid; + } + + if (typeof value === 'object') { + return Object.entries(value).every(([key, value]) => { + if (typeof value !== 'string') return true; + + const isValid = !value.startsWith('var('); + if (!isValid) { + console.warn( + `Invalid color value: ${value} for key: ${key}, using default value instead. Using CSS variables is not supported in this browser.`, + ); + } + + return isValid; + }); + } + + return false; + }), + ); + + return validVariables; }; export const createRadiiUnits = (theme: Theme) => { @@ -51,12 +94,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)`, }; }; @@ -65,12 +107,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})`]; }), ); }; @@ -83,13 +124,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/elements/Card/CardFooter.tsx b/packages/clerk-js/src/ui/elements/Card/CardFooter.tsx index e0410211e9c..cd21424ede7 100644 --- a/packages/clerk-js/src/ui/elements/Card/CardFooter.tsx +++ b/packages/clerk-js/src/ui/elements/Card/CardFooter.tsx @@ -49,7 +49,7 @@ export const CardFooter = React.forwardRef((pro elementDescriptor={descriptors.footer} sx={[ t => ({ - marginTop: `-${t.space.$2}`, + marginTop: `calc(${t.space.$2} * -1)`, paddingTop: t.space.$2, background: common.mergedColorsBackground( colors.setAlpha(t.colors.$colorBackground, 1), diff --git a/packages/clerk-js/src/ui/elements/CodeControl.tsx b/packages/clerk-js/src/ui/elements/CodeControl.tsx index 7e5118ed273..77ee4ac3366 100644 --- a/packages/clerk-js/src/ui/elements/CodeControl.tsx +++ b/packages/clerk-js/src/ui/elements/CodeControl.tsx @@ -336,7 +336,7 @@ export const OTPCodeControl = React.forwardRef<{ reset: any }>((_, ref) => { hasError={feedbackType === 'error'} elementDescriptor={descriptors.otpCodeFieldInputs} gap={2} - sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `-${t.space.$1}`, ...centerSx })} + sx={t => ({ direction: 'ltr', padding: t.space.$1, marginLeft: `calc(${t.space.$1} * -1)`, ...centerSx })} role='group' aria-label='Verification code input' > diff --git a/packages/clerk-js/src/ui/elements/Navbar.tsx b/packages/clerk-js/src/ui/elements/Navbar.tsx index 789c136adfc..5abd510aefc 100644 --- a/packages/clerk-js/src/ui/elements/Navbar.tsx +++ b/packages/clerk-js/src/ui/elements/Navbar.tsx @@ -159,7 +159,7 @@ const NavbarContainer = ( t.colors.$neutralAlpha50, ), padding: `${t.space.$6} ${t.space.$5} ${t.space.$4} ${t.space.$3}`, - marginRight: `-${t.space.$2}`, + marginRight: `calc(${t.space.$2} * -1)`, color: t.colors.$colorText, justifyContent: 'space-between', })} @@ -332,7 +332,7 @@ export const NavbarMenuButtonRow = ({ navbarTitleLocalizationKey, ...props }: Na t.colors.$neutralAlpha50, ), padding: `${t.space.$2} ${t.space.$3} ${t.space.$4} ${t.space.$3}`, - marginBottom: `-${t.space.$2}`, + marginBottom: `calc(${t.space.$2} * -1)`, [mqu.md]: { display: 'flex', }, diff --git a/packages/clerk-js/src/ui/elements/PopoverCard.tsx b/packages/clerk-js/src/ui/elements/PopoverCard.tsx index 2a00474443c..f9dd69aa961 100644 --- a/packages/clerk-js/src/ui/elements/PopoverCard.tsx +++ b/packages/clerk-js/src/ui/elements/PopoverCard.tsx @@ -85,7 +85,7 @@ const PopoverCardFooter = (props: PropsOfComponent) => { colors.setAlpha(t.colors.$colorBackground, 1), t.colors.$neutralAlpha50, ), - marginTop: `-${t.space.$2}`, + marginTop: `calc(${t.space.$2} * -1)`, paddingTop: t.space.$2, '&:empty': { padding: 0, diff --git a/packages/clerk-js/src/ui/foundations/colors.ts b/packages/clerk-js/src/ui/foundations/colors.ts index 2b949f0f2d3..4e97cfdb197 100644 --- a/packages/clerk-js/src/ui/foundations/colors.ts +++ b/packages/clerk-js/src/ui/foundations/colors.ts @@ -1,4 +1,4 @@ -import { colorOptionToHslaAlphaScale } from '../utils/colorOptionToHslaScale'; +import { colorOptionToThemedAlphaScale } from '../utils/colors/scales'; export const whiteAlpha = Object.freeze({ whiteAlpha25: 'hsla(0, 0%, 100%, 0.02)', @@ -65,7 +65,7 @@ export const colors = Object.freeze({ primary900: '#1B171C', primaryHover: '#3B3C45', //primary 500 adjusted for lightness // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...colorOptionToHslaAlphaScale('#2F3037', 'primaryAlpha')!, + ...colorOptionToThemedAlphaScale('#2F3037', 'primaryAlpha')!, danger50: '#FEF2F2', danger100: '#FEE5E5', danger200: '#FECACA', @@ -78,7 +78,7 @@ export const colors = Object.freeze({ danger900: '#7F1D1D', danger950: '#450A0A', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...colorOptionToHslaAlphaScale('#EF4444', 'dangerAlpha')!, + ...colorOptionToThemedAlphaScale('#EF4444', 'dangerAlpha')!, warning50: '#FFF6ED', warning100: '#FFEBD5', warning200: '#FED1AA', @@ -91,7 +91,7 @@ export const colors = Object.freeze({ warning900: '#7C2912', warning950: '#431207', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...colorOptionToHslaAlphaScale('#F36B16', 'warningAlpha')!, + ...colorOptionToThemedAlphaScale('#F36B16', 'warningAlpha')!, success50: '#F0FDF2', success100: '#DCFCE2', success200: '#BBF7C6', @@ -104,5 +104,5 @@ export const colors = Object.freeze({ success900: '#145323', success950: '#052E0F', // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...colorOptionToHslaAlphaScale('#22C543', 'successAlpha')!, + ...colorOptionToThemedAlphaScale('#22C543', 'successAlpha')!, } as const); diff --git a/packages/clerk-js/src/ui/utils/__tests__/colors.test.ts b/packages/clerk-js/src/ui/utils/__tests__/colors.test.ts deleted file mode 100644 index 7762f9dc9cd..00000000000 --- a/packages/clerk-js/src/ui/utils/__tests__/colors.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { HslaColor } from '@clerk/types'; - -import { colors } from '../colors'; - -describe('colors.toHslaColor(color)', function () { - const hsla = { h: 195, s: 100, l: 50, a: 1 }; - const cases: Array<[string, any]> = [ - // ['', undefined], - // ['00bfff', hsla], - ['transparent', { h: 0, s: 0, l: 0, a: 0 }], - ['#00bfff', hsla], - ['rgb(0, 191, 255)', hsla], - ['rgba(0, 191, 255, 0.3)', { ...hsla, a: 0.3 }], - ['hsl(195, 100%, 50%)', hsla], - ['hsla(195, 100%, 50%, 1)', hsla], - ]; - - it.each(cases)('colors.toHslaColor(%s) => %s', (a, expected) => { - expect(colors.toHslaColor(a)).toEqual(expected); - }); -}); - -describe('colors.toHslaColor(color)', function () { - const cases: Array<[HslaColor, any]> = [ - [colors.toHslaColor('transparent'), `hsla(0, 0%, 0%, 0)`], - [colors.toHslaColor('#00bfff'), 'hsla(195, 100%, 50%, 1)'], - [colors.toHslaColor('rgb(0, 191, 255)'), 'hsla(195, 100%, 50%, 1)'], - [colors.toHslaColor('rgba(0, 191, 255, 0.3)'), 'hsla(195, 100%, 50%, 0.3)'], - [colors.toHslaColor('hsl(195, 100%, 50%)'), 'hsla(195, 100%, 50%, 1)'], - [colors.toHslaColor('hsla(195, 100%, 50%, 1)'), 'hsla(195, 100%, 50%, 1)'], - ]; - - it.each(cases)('colors.toHslaColor(%s) => %s', (a, expected) => { - expect(colors.toHslaString(a)).toEqual(expected); - }); -}); diff --git a/packages/clerk-js/src/ui/utils/__tests__/cssSupports.test.ts b/packages/clerk-js/src/ui/utils/__tests__/cssSupports.test.ts new file mode 100644 index 00000000000..cb4af74c05f --- /dev/null +++ b/packages/clerk-js/src/ui/utils/__tests__/cssSupports.test.ts @@ -0,0 +1,42 @@ +import { clearCache, cssSupports } from '../cssSupports'; + +// Mock CSS.supports +const originalCSSSupports = CSS.supports; + +beforeAll(() => { + CSS.supports = jest.fn(feature => { + if (feature === 'color: hsl(from white h s l)') return true; + if (feature === 'color: color-mix(in srgb, white, black)') return false; + return false; + }); +}); + +afterAll(() => { + CSS.supports = originalCSSSupports; +}); + +beforeEach(() => { + clearCache(); + (CSS.supports as jest.Mock).mockClear(); +}); + +describe('cssSupports', () => { + test('relativeColorSyntax should return true when supported', () => { + expect(cssSupports.relativeColorSyntax()).toBe(true); + }); + + test('colorMix should return false when not supported', () => { + expect(cssSupports.colorMix()).toBe(false); + }); + + test('modernColor should return true when at least one feature is supported', () => { + expect(cssSupports.modernColor()).toBe(true); + }); + + test('caching works correctly', () => { + cssSupports.relativeColorSyntax(); + expect(CSS.supports).toHaveBeenCalledTimes(1); + cssSupports.relativeColorSyntax(); + expect(CSS.supports).toHaveBeenCalledTimes(1); // Should not call again due to caching + }); +}); 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/colorOptionToHslaScale.ts b/packages/clerk-js/src/ui/utils/colorOptionToHslaScale.ts deleted file mode 100644 index 7676ccbb389..00000000000 --- a/packages/clerk-js/src/ui/utils/colorOptionToHslaScale.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { ColorScale, CssColorOrAlphaScale, CssColorOrScale, HslaColor, HslaColorString } from '@clerk/types'; - -import { colors } from './colors'; -import { fromEntries } from './fromEntries'; - -type InternalColorScale = ColorScale & Partial>; - -const LIGHT_SHADES = ['25', '50', '100', '150', '200', '300', '400'].reverse(); -const DARK_SHADES = ['600', '700', '750', '800', '850', '900', '950']; - -const ALL_SHADES = [...[...LIGHT_SHADES].reverse(), '500', ...DARK_SHADES] as const; - -const TARGET_L_50_SHADE = 97; -const TARGET_L_900_SHADE = 12; - -function createEmptyColorScale(): InternalColorScale { - return { - '25': undefined, - '50': undefined, - '100': undefined, - '150': undefined, - '200': undefined, - '300': undefined, - '400': undefined, - '500': undefined, - '600': undefined, - '700': undefined, - '750': undefined, - '800': undefined, - '850': undefined, - '900': undefined, - '950': undefined, - }; -} - -type WithPrefix, Prefix extends string> = { - [k in keyof T as `${Prefix}${k & string}`]: T[k]; -}; - -export const colorOptionToHslaAlphaScale = ( - colorOption: CssColorOrAlphaScale | undefined, - prefix: Prefix, -): WithPrefix, Prefix> | undefined => { - return getUserProvidedScaleOrGenerateHslaColorsScale(colorOption, prefix, generateFilledAlphaScaleFromBaseHslaColor); -}; - -export const colorOptionToHslaLightnessScale = ( - colorOption: CssColorOrScale | undefined, - prefix: Prefix, -): WithPrefix, Prefix> | undefined => { - return fillUserProvidedScaleWithGeneratedHslaColors(colorOption, prefix, generateFilledScaleFromBaseHslaColor); -}; - -const getUserProvidedScaleOrGenerateHslaColorsScale = ( - colorOption: CssColorOrAlphaScale | undefined, - prefix: Prefix, - generator: (base: HslaColor) => InternalColorScale, -): WithPrefix, Prefix> | undefined => { - if (!colorOption) { - return undefined; - } - - if (typeof colorOption === 'object' && !ALL_SHADES.every(key => key in colorOption)) { - throw new Error('You need to provide all the following shades: ' + ALL_SHADES.join(', ')); - } - - if (typeof colorOption === 'object') { - const scale = Object.keys(colorOption).reduce((acc, key) => { - // @ts-expect-error - acc[key] = colors.toHslaColor(colorOption[key]); - return acc; - }, createEmptyColorScale()); - return prefixAndStringifyHslaScale(scale, prefix); - } - - const hslaColor = colors.toHslaColor(colorOption); - const filledHslaColorScale = generator(hslaColor); - return prefixAndStringifyHslaScale(filledHslaColorScale, prefix); -}; - -const fillUserProvidedScaleWithGeneratedHslaColors = ( - colorOption: CssColorOrScale | undefined, - prefix: Prefix, - generator: (base: HslaColor) => InternalColorScale, -): WithPrefix, Prefix> | undefined => { - if (!colorOption) { - return undefined; - } - - if (typeof colorOption === 'object' && !colorOption['500']) { - throw new Error('You need to provide at least the 500 shade'); - } - - const userDefinedHslaColorScale = userDefinedColorToHslaColorScale(colorOption); - const filledHslaColorScale = generator(userDefinedHslaColorScale['500']); - const merged = mergeFilledIntoUserDefinedScale(filledHslaColorScale, userDefinedHslaColorScale); - return prefixAndStringifyHslaScale(merged, prefix); -}; - -const mergeFilledIntoUserDefinedScale = ( - generated: InternalColorScale, - userDefined: InternalColorScale, -): InternalColorScale => { - // @ts-expect-error - return fromEntries(Object.entries(userDefined).map(([k, v]) => [k, v || generated[k]])); -}; - -const prefixAndStringifyHslaScale = ( - scale: InternalColorScale, - prefix: Prefix, -) => { - const res = {} as WithPrefix, Prefix>; - for (const key in scale) { - // @ts-expect-error - if (scale[key]) { - // @ts-expect-error - res[prefix + key] = colors.toHslaString(scale[key]); - } - } - return res; -}; - -const userDefinedColorToHslaColorScale = (colorOption: CssColorOrScale): InternalColorScale => { - const baseScale = typeof colorOption === 'string' ? { '500': colorOption } : colorOption; - const hslaScale = createEmptyColorScale(); - // @ts-expect-error - const entries = Object.keys(hslaScale).map(k => [k, baseScale[k] ? colors.toHslaColor(baseScale[k]) : undefined]); - return fromEntries(entries) as InternalColorScale; -}; - -/** - * This function generates a color scale using `base` as the 500 shade. - * The lightest shade (50) will always have a lightness of TARGET_L_50_SHADE, - * and the darkest shade (900) will always have a lightness of TARGET_L_900_SHADE. - * It calculates the required inc/decr lightness steps and applies them to base - */ -const generateFilledScaleFromBaseHslaColor = (base: HslaColor): InternalColorScale => { - const newScale = createEmptyColorScale(); - type Key = keyof typeof newScale; - newScale['500'] = base; - - const lightPercentage = (TARGET_L_50_SHADE - base.l) / LIGHT_SHADES.length; - const darkPercentage = (base.l - TARGET_L_900_SHADE) / DARK_SHADES.length; - - LIGHT_SHADES.forEach( - (shade, i) => (newScale[shade as any as Key] = colors.changeHslaLightness(base, (i + 1) * lightPercentage)), - ); - DARK_SHADES.map( - (shade, i) => (newScale[shade as any as Key] = colors.changeHslaLightness(base, (i + 1) * darkPercentage * -1)), - ); - return newScale as InternalColorScale; -}; - -const generateFilledAlphaScaleFromBaseHslaColor = (base: HslaColor): InternalColorScale => { - const newScale = createEmptyColorScale(); - const baseWithoutAlpha = colors.setHslaAlpha(base, 0); - const alphas = [0.02, 0.03, 0.07, 0.11, 0.15, 0.28, 0.41, 0.53, 0.62, 0.73, 0.78, 0.81, 0.84, 0.87, 0.92]; - // @ts-expect-error - Object.keys(newScale).forEach((k, i) => (newScale[k] = colors.setHslaAlpha(baseWithoutAlpha, alphas[i]))); - return newScale as InternalColorScale; -}; diff --git a/packages/clerk-js/src/ui/utils/colors/README.md b/packages/clerk-js/src/ui/utils/colors/README.md new file mode 100644 index 00000000000..027a7659e29 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/README.md @@ -0,0 +1,58 @@ +# Colors System + +This folder contains the color manipulation utilities for Clerk's UI components. The system automatically chooses between **legacy** and **modern** color handling based on browser support. + +## How It Works + +The color system uses a **progressive enhancement** approach: + +1. **Detect browser capabilities** - Check if the browser supports modern CSS color features +2. **Choose the right approach** - Use modern CSS when available, fall back to legacy methods +3. **Provide consistent API** - Same functions work regardless of which approach is used + +## Legacy vs Modern Approach + +### Legacy Color Handling (`legacy.ts`) + +- **When**: Used in older browsers that don't support modern CSS color features +- **How**: Converts colors to HSLA objects and manipulates values in JavaScript +- **Example**: `#ff0000` becomes `{ h: 0, s: 100, l: 50, a: 1 }` +- **Output**: Returns HSLA strings like `hsla(0, 100%, 50%, 1)` + +### Modern Color Handling (`modern.ts`) + +- **When**: Used in browsers that support `color-mix()` or relative color syntax +- **How**: Uses native CSS color functions in order to support CSS variables +- **Example**: `color-mix(in srgb, #ff0000 80%, white 20%)` for lightening +- **Output**: Returns modern CSS color strings + +## Key Features + +- **Automatic detection** - No need to manually choose legacy vs modern +- **Same API** - All functions work the same way regardless of browser +- **Fallback support** - Always works, even in older browsers + +## Main Functions + +```typescript +// Lighten a color by percentage +colors.lighten('#ff0000', 20); // Makes red 20% lighter + +// Make a color transparent +colors.makeTransparent('#ff0000', 50); // Makes red 50% transparent + +// Set specific opacity +colors.setAlpha('#ff0000', 0.5); // Sets red to 50% opacity + +// Adjust for better contrast +colors.adjustForLightness('#333333', 10); // Slightly lightens dark colors +``` + +## Browser Support Detection + +The system checks for these modern CSS features: + +- `color-mix()` function +- Relative color syntax (`hsl(from white h s l)`) + +If either is supported, modern handling is used. Otherwise, legacy handling kicks in. diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/constants.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/constants.spec.ts new file mode 100644 index 00000000000..3f7fc08a5a6 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/constants.spec.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest'; + +import { + ALL_SHADES, + ALPHA_PERCENTAGES, + ALPHA_VALUES, + COLOR_BOUNDS, + COLOR_SCALE, + DARK_SHADES, + LIGHT_SHADES, + LIGHTNESS_CONFIG, + LIGHTNESS_MIX_DATA, + MODERN_CSS_LIMITS, + RELATIVE_SHADE_STEPS, +} from '../constants'; + +describe('Color Constants', () => { + describe('COLOR_SCALE', () => { + it('should contain all expected color shades in order', () => { + expect(COLOR_SCALE).toEqual([25, 50, 100, 150, 200, 300, 400, 500, 600, 700, 750, 800, 850, 900, 950]); + }); + + it('should be readonly at compile time', () => { + // COLOR_SCALE is readonly via 'as const' but not frozen at runtime + // This is sufficient for immutability in TypeScript + expect(Array.isArray(COLOR_SCALE)).toBe(true); + }); + + it('should have correct length', () => { + expect(COLOR_SCALE).toHaveLength(15); + }); + }); + + describe('Shade groupings', () => { + it('should have correct light shades', () => { + expect(LIGHT_SHADES).toEqual(['400', '300', '200', '150', '100', '50', '25']); + }); + + it('should have correct dark shades', () => { + expect(DARK_SHADES).toEqual(['600', '700', '750', '800', '850', '900', '950']); + }); + + it('should have all shades including 500', () => { + expect(ALL_SHADES).toContain('500'); + expect(ALL_SHADES).toHaveLength(15); + }); + + it('should have all shades equal to light + dark + 500', () => { + const expected = [...LIGHT_SHADES, '500', ...DARK_SHADES]; + expect(ALL_SHADES).toEqual(expected); + }); + }); + + describe('LIGHTNESS_CONFIG', () => { + it('should have correct lightness configuration', () => { + expect(LIGHTNESS_CONFIG).toEqual({ + TARGET_LIGHT: 97, + TARGET_DARK: 12, + LIGHT_STEPS: 7, + DARK_STEPS: 7, + }); + }); + + it('should be readonly at compile time', () => { + // LIGHTNESS_CONFIG is readonly via 'as const' but not frozen at runtime + expect(typeof LIGHTNESS_CONFIG).toBe('object'); + }); + }); + + describe('ALPHA_VALUES', () => { + it('should have correct number of alpha values', () => { + expect(ALPHA_VALUES).toHaveLength(COLOR_SCALE.length); + }); + + it('should have all values between 0 and 1', () => { + ALPHA_VALUES.forEach(value => { + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + }); + }); + + it('should be in ascending order', () => { + for (let i = 1; i < ALPHA_VALUES.length; i++) { + expect(ALPHA_VALUES[i]).toBeGreaterThan(ALPHA_VALUES[i - 1]); + } + }); + }); + + describe('ALPHA_PERCENTAGES', () => { + it('should have entries for all color shades', () => { + COLOR_SCALE.forEach(shade => { + expect(ALPHA_PERCENTAGES[shade]).toBeDefined(); + expect(typeof ALPHA_PERCENTAGES[shade]).toBe('number'); + }); + }); + + it('should have all percentages between 0 and 100', () => { + Object.values(ALPHA_PERCENTAGES).forEach(percentage => { + expect(percentage).toBeGreaterThanOrEqual(0); + expect(percentage).toBeLessThanOrEqual(100); + }); + }); + + it('should be in ascending order following COLOR_SCALE order', () => { + for (let i = 1; i < COLOR_SCALE.length; i++) { + const currentShade = COLOR_SCALE[i]; + const previousShade = COLOR_SCALE[i - 1]; + expect(ALPHA_PERCENTAGES[currentShade]).toBeGreaterThan(ALPHA_PERCENTAGES[previousShade]); + } + }); + + it('should be readonly at compile time', () => { + // ALPHA_PERCENTAGES is readonly via 'as const' but not frozen at runtime + expect(typeof ALPHA_PERCENTAGES).toBe('object'); + }); + }); + + describe('LIGHTNESS_MIX_DATA', () => { + it('should have entries for all color shades', () => { + COLOR_SCALE.forEach(shade => { + expect(LIGHTNESS_MIX_DATA[shade]).toBeDefined(); + expect(typeof LIGHTNESS_MIX_DATA[shade]).toBe('object'); + }); + }); + + it('should have correct structure for each shade', () => { + Object.entries(LIGHTNESS_MIX_DATA).forEach(([_shade, data]) => { + expect(data).toHaveProperty('mixColor'); + expect(data).toHaveProperty('percentage'); + expect(typeof data.percentage).toBe('number'); + + if (data.mixColor !== null) { + expect(['white', 'black']).toContain(data.mixColor); + } + }); + }); + + it('should have 500 shade with no mix color', () => { + expect(LIGHTNESS_MIX_DATA[500]).toEqual({ + mixColor: null, + percentage: 0, + }); + }); + + it('should have light shades mixing with white', () => { + LIGHT_SHADES.forEach(shade => { + const numShade = parseInt(shade) as keyof typeof LIGHTNESS_MIX_DATA; + expect(LIGHTNESS_MIX_DATA[numShade].mixColor).toBe('white'); + }); + }); + + it('should have dark shades mixing with black', () => { + DARK_SHADES.forEach(shade => { + const numShade = parseInt(shade) as keyof typeof LIGHTNESS_MIX_DATA; + expect(LIGHTNESS_MIX_DATA[numShade].mixColor).toBe('black'); + }); + }); + + it('should be readonly at compile time', () => { + // LIGHTNESS_MIX_DATA is readonly via 'as const' but not frozen at runtime + expect(typeof LIGHTNESS_MIX_DATA).toBe('object'); + }); + }); + + describe('RELATIVE_SHADE_STEPS', () => { + it('should have correct step values', () => { + // Light shades should have steps 1-7 + expect(RELATIVE_SHADE_STEPS[400]).toBe(1); + expect(RELATIVE_SHADE_STEPS[300]).toBe(2); + expect(RELATIVE_SHADE_STEPS[200]).toBe(3); + expect(RELATIVE_SHADE_STEPS[150]).toBe(4); + expect(RELATIVE_SHADE_STEPS[100]).toBe(5); + expect(RELATIVE_SHADE_STEPS[50]).toBe(6); + expect(RELATIVE_SHADE_STEPS[25]).toBe(7); + + // Dark shades should have steps 1-7 + expect(RELATIVE_SHADE_STEPS[600]).toBe(1); + expect(RELATIVE_SHADE_STEPS[700]).toBe(2); + expect(RELATIVE_SHADE_STEPS[750]).toBe(3); + expect(RELATIVE_SHADE_STEPS[800]).toBe(4); + expect(RELATIVE_SHADE_STEPS[850]).toBe(5); + expect(RELATIVE_SHADE_STEPS[900]).toBe(6); + expect(RELATIVE_SHADE_STEPS[950]).toBe(7); + }); + + it('should not have a step for 500 shade', () => { + expect(RELATIVE_SHADE_STEPS[500]).toBeUndefined(); + }); + + it('should be readonly at compile time', () => { + // RELATIVE_SHADE_STEPS is readonly via 'as const' but not frozen at runtime + expect(typeof RELATIVE_SHADE_STEPS).toBe('object'); + }); + }); + + describe('COLOR_BOUNDS', () => { + it('should have correct RGB bounds', () => { + expect(COLOR_BOUNDS.rgb).toEqual({ min: 0, max: 255 }); + }); + + it('should have correct alpha bounds', () => { + expect(COLOR_BOUNDS.alpha).toEqual({ min: 0, max: 1 }); + }); + + it('should have correct hue bounds', () => { + expect(COLOR_BOUNDS.hue).toEqual({ min: 0, max: 360 }); + }); + + it('should have correct percentage bounds', () => { + expect(COLOR_BOUNDS.percentage).toEqual({ min: 0, max: 100 }); + }); + + it('should be readonly at compile time', () => { + // COLOR_BOUNDS is readonly via 'as const' but not frozen at runtime + expect(typeof COLOR_BOUNDS).toBe('object'); + }); + }); + + describe('MODERN_CSS_LIMITS', () => { + it('should have all required limits', () => { + expect(MODERN_CSS_LIMITS).toHaveProperty('MAX_LIGHTNESS_MIX'); + expect(MODERN_CSS_LIMITS).toHaveProperty('MIN_ALPHA_PERCENTAGE'); + expect(MODERN_CSS_LIMITS).toHaveProperty('MAX_LIGHTNESS_ADJUSTMENT'); + expect(MODERN_CSS_LIMITS).toHaveProperty('MIN_LIGHTNESS_FLOOR'); + expect(MODERN_CSS_LIMITS).toHaveProperty('LIGHTNESS_MULTIPLIER'); + expect(MODERN_CSS_LIMITS).toHaveProperty('MIX_MULTIPLIER'); + }); + + it('should have reasonable limit values', () => { + expect(MODERN_CSS_LIMITS.MAX_LIGHTNESS_MIX).toBeGreaterThan(0); + expect(MODERN_CSS_LIMITS.MAX_LIGHTNESS_MIX).toBeLessThanOrEqual(100); + + expect(MODERN_CSS_LIMITS.MIN_ALPHA_PERCENTAGE).toBeGreaterThan(0); + expect(MODERN_CSS_LIMITS.MIN_ALPHA_PERCENTAGE).toBeLessThanOrEqual(100); + + expect(MODERN_CSS_LIMITS.LIGHTNESS_MULTIPLIER).toBeGreaterThan(0); + expect(MODERN_CSS_LIMITS.MIX_MULTIPLIER).toBeGreaterThan(0); + }); + + it('should be readonly at compile time', () => { + // MODERN_CSS_LIMITS is readonly via 'as const' but not frozen at runtime + expect(typeof MODERN_CSS_LIMITS).toBe('object'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/index.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/index.spec.ts new file mode 100644 index 00000000000..415400ddf7c --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/index.spec.ts @@ -0,0 +1,244 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock cssSupports +vi.mock('../../cssSupports', () => ({ + cssSupports: { + modernColor: vi.fn(), + }, +})); + +vi.mock('../legacy', () => ({ + colors: { + toHslaColor: vi.fn(), + toHslaString: vi.fn(), + changeHslaLightness: vi.fn(), + setHslaAlpha: vi.fn(), + lighten: vi.fn(), + makeTransparent: vi.fn(), + makeSolid: vi.fn(), + setAlpha: vi.fn(), + adjustForLightness: vi.fn(), + }, +})); + +vi.mock('../modern', () => ({ + colors: { + lighten: vi.fn(), + makeTransparent: vi.fn(), + makeSolid: vi.fn(), + setAlpha: vi.fn(), + adjustForLightness: vi.fn(), + }, +})); + +import { cssSupports } from '../../cssSupports'; +import { colors, legacyColors, modernColors } from '../index'; + +// Get the mocked functions +const mockModernColorSupport = vi.mocked(cssSupports.modernColor); +const mockLegacyColors = vi.mocked(legacyColors); +const mockModernColors = vi.mocked(modernColors); + +describe('Colors Index', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockModernColorSupport.mockReturnValue(false); + }); + + describe('modernColors and legacyColors exports', () => { + it('should export modernColors', () => { + expect(modernColors).toBeDefined(); + }); + + it('should export legacyColors', () => { + expect(legacyColors).toBeDefined(); + }); + }); + + describe('toHslaColor', () => { + it('should return undefined for undefined input', () => { + expect(colors.toHslaColor(undefined)).toBeUndefined(); + }); + + it('should return color string when modern CSS is supported', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = colors.toHslaColor('red'); + expect(result).toBe('red'); + }); + + it('should call legacy toHslaColor when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.toHslaColor.mockReturnValue({ h: 0, s: 100, l: 50, a: 1 }); + + colors.toHslaColor('red'); + expect(mockLegacyColors.toHslaColor).toHaveBeenCalledWith('red'); + }); + }); + + describe('toHslaString', () => { + it('should return undefined for undefined input', () => { + expect(colors.toHslaString(undefined)).toBeUndefined(); + }); + + it('should return color string when modern CSS is supported and input is string', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = colors.toHslaString('red'); + expect(result).toBe('red'); + }); + + it('should call legacy toHslaString when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.toHslaString.mockReturnValue('hsla(0, 100%, 50%, 1)'); + + const hslaColor = { h: 0, s: 100, l: 50, a: 1 }; + colors.toHslaString(hslaColor); + expect(mockLegacyColors.toHslaString).toHaveBeenCalledWith(hslaColor); + }); + + it('should call legacy toHslaString for string input when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.toHslaString.mockReturnValue('hsla(0, 100%, 50%, 1)'); + + colors.toHslaString('red'); + expect(mockLegacyColors.toHslaString).toHaveBeenCalledWith('red'); + }); + }); + + describe('changeHslaLightness', () => { + it('should always use legacy implementation', () => { + const hslaColor = { h: 0, s: 100, l: 50, a: 1 }; + const lightness = 10; + + colors.changeHslaLightness(hslaColor, lightness); + expect(mockLegacyColors.changeHslaLightness).toHaveBeenCalledWith(hslaColor, lightness); + }); + }); + + describe('setHslaAlpha', () => { + it('should always use legacy implementation', () => { + const hslaColor = { h: 0, s: 100, l: 50, a: 1 }; + const alpha = 0.5; + + colors.setHslaAlpha(hslaColor, alpha); + expect(mockLegacyColors.setHslaAlpha).toHaveBeenCalledWith(hslaColor, alpha); + }); + }); + + describe('lighten', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.lighten.mockReturnValue('lightened-color'); + + const result = colors.lighten('red', 0.1); + expect(mockModernColors.lighten).toHaveBeenCalledWith('red', 0.1); + expect(result).toBe('lightened-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.lighten.mockReturnValue('legacy-lightened-color'); + + const result = colors.lighten('red', 0.1); + expect(mockLegacyColors.lighten).toHaveBeenCalledWith('red', 0.1); + expect(result).toBe('legacy-lightened-color'); + }); + + it('should handle default percentage', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.lighten.mockReturnValue('lightened-color'); + + colors.lighten('red'); + expect(mockModernColors.lighten).toHaveBeenCalledWith('red', 0); + }); + }); + + describe('makeTransparent', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.makeTransparent.mockReturnValue('transparent-color'); + + const result = colors.makeTransparent('red', 0.5); + expect(mockModernColors.makeTransparent).toHaveBeenCalledWith('red', 0.5); + expect(result).toBe('transparent-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.makeTransparent.mockReturnValue('legacy-transparent-color'); + + const result = colors.makeTransparent('red', 0.5); + expect(mockLegacyColors.makeTransparent).toHaveBeenCalledWith('red', 0.5); + expect(result).toBe('legacy-transparent-color'); + }); + }); + + describe('makeSolid', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.makeSolid.mockReturnValue('solid-color'); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(mockModernColors.makeSolid).toHaveBeenCalledWith('rgba(255, 0, 0, 0.5)'); + expect(result).toBe('solid-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.makeSolid.mockReturnValue('legacy-solid-color'); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(mockLegacyColors.makeSolid).toHaveBeenCalledWith('rgba(255, 0, 0, 0.5)'); + expect(result).toBe('legacy-solid-color'); + }); + }); + + describe('setAlpha', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.setAlpha.mockReturnValue('alpha-color'); + + const result = colors.setAlpha('red', 0.5); + expect(mockModernColors.setAlpha).toHaveBeenCalledWith('red', 0.5); + expect(result).toBe('alpha-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.setAlpha.mockReturnValue('legacy-alpha-color'); + + const result = colors.setAlpha('red', 0.5); + expect(mockLegacyColors.setAlpha).toHaveBeenCalledWith('red', 0.5); + expect(result).toBe('legacy-alpha-color'); + }); + }); + + describe('adjustForLightness', () => { + it('should use modern implementation when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.adjustForLightness.mockReturnValue('adjusted-color'); + + const result = colors.adjustForLightness('red', 5); + expect(mockModernColors.adjustForLightness).toHaveBeenCalledWith('red', 5); + expect(result).toBe('adjusted-color'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + mockLegacyColors.adjustForLightness.mockReturnValue('legacy-adjusted-color'); + + const result = colors.adjustForLightness('red', 5); + expect(mockLegacyColors.adjustForLightness).toHaveBeenCalledWith('red', 5); + expect(result).toBe('legacy-adjusted-color'); + }); + + it('should handle default lightness value', () => { + mockModernColorSupport.mockReturnValue(true); + mockModernColors.adjustForLightness.mockReturnValue('adjusted-color'); + + colors.adjustForLightness('red'); + expect(mockModernColors.adjustForLightness).toHaveBeenCalledWith('red', 5); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/legacy.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/legacy.spec.ts new file mode 100644 index 00000000000..e4b38c5b02a --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/legacy.spec.ts @@ -0,0 +1,449 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { colors } from '../legacy'; + +describe('Legacy Colors', () => { + describe('toHslaColor', () => { + describe('RGB and RGBA inputs', () => { + it('should parse hex colors without alpha', () => { + const result = colors.toHslaColor('#ff0000'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse hex colors with alpha', () => { + const result = colors.toHslaColor('#ff000080'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5019607843137255 }); + }); + + it('should parse 3-digit hex colors', () => { + const result = colors.toHslaColor('#f00'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse 4-digit hex colors with alpha', () => { + const result = colors.toHslaColor('#f008'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5333333333333333 }); + }); + + it('should parse rgb() colors', () => { + const result = colors.toHslaColor('rgb(255, 0, 0)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse rgba() colors', () => { + const result = colors.toHslaColor('rgba(255, 0, 0, 0.5)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should parse rgb() colors with percentages', () => { + const result = colors.toHslaColor('rgb(100%, 0%, 0%)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse rgba() colors with percentage alpha', () => { + const result = colors.toHslaColor('rgba(255, 0, 0, 50%)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should parse different RGB colors correctly', () => { + const blue = colors.toHslaColor('#0000ff'); + expect(blue).toEqual({ h: 240, s: 100, l: 50, a: 1 }); + + const green = colors.toHslaColor('#00ff00'); + expect(green).toEqual({ h: 120, s: 100, l: 50, a: 1 }); + + const yellow = colors.toHslaColor('#ffff00'); + expect(yellow).toEqual({ h: 60, s: 100, l: 50, a: 1 }); + }); + }); + + describe('HSL and HSLA inputs', () => { + it('should parse hsl() colors', () => { + const result = colors.toHslaColor('hsl(0, 100%, 50%)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse hsla() colors', () => { + const result = colors.toHslaColor('hsla(0, 100%, 50%, 0.5)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should parse hsl() colors with deg unit', () => { + const result = colors.toHslaColor('hsl(180deg, 50%, 25%)'); + expect(result).toEqual({ h: 180, s: 50, l: 25, a: 1 }); + }); + + it('should handle hue values over 360', () => { + const result = colors.toHslaColor('hsl(450, 100%, 50%)'); + expect(result).toEqual({ h: 90, s: 100, l: 50, a: 1 }); + }); + + it('should handle negative hue values', () => { + const result = colors.toHslaColor('hsl(-90, 100%, 50%)'); + expect(result).toEqual({ h: 270, s: 100, l: 50, a: 1 }); + }); + }); + + describe('HWB inputs', () => { + it('should parse hwb() colors', () => { + const result = colors.toHslaColor('hwb(0, 0%, 0%)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should parse hwb() colors with alpha', () => { + const result = colors.toHslaColor('hwb(0, 0%, 0%, 0.5)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should handle hwb colors with high whiteness and blackness', () => { + const result = colors.toHslaColor('hwb(0, 50%, 50%)'); + expect(result.h).toBe(0); + expect(result.a).toBe(1); + }); + }); + + describe('CSS keyword inputs', () => { + it('should parse named colors', () => { + expect(colors.toHslaColor('red')).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + expect(colors.toHslaColor('blue')).toEqual({ h: 240, s: 100, l: 50, a: 1 }); + expect(colors.toHslaColor('green')).toEqual({ h: 120, s: 100, l: 25, a: 1 }); + expect(colors.toHslaColor('white')).toEqual({ h: 0, s: 0, l: 100, a: 1 }); + expect(colors.toHslaColor('black')).toEqual({ h: 0, s: 0, l: 0, a: 1 }); + expect(colors.toHslaColor('transparent')).toEqual({ h: 0, s: 0, l: 0, a: 0 }); + }); + + it('should handle gray and grey equivalents', () => { + const gray = colors.toHslaColor('gray'); + const grey = colors.toHslaColor('grey'); + expect(gray).toEqual(grey); + expect(gray).toEqual({ h: 0, s: 0, l: 50, a: 1 }); + }); + }); + + describe('CSS variable inputs', () => { + // Mock DOM environment for testing CSS variables + const mockGetComputedStyle = vi.fn(); + const mockWindow = { + getComputedStyle: mockGetComputedStyle, + }; + + beforeEach(() => { + // @ts-ignore + global.window = mockWindow; + // @ts-ignore + global.getComputedStyle = mockGetComputedStyle; + // @ts-ignore + global.document = { + documentElement: document?.createElement?.('div') || {}, + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + // @ts-ignore + global.window = undefined; + // @ts-ignore + global.getComputedStyle = undefined; + // @ts-ignore + global.document = undefined; + }); + + it('should resolve CSS variables with hex values', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('#ff0000'), + }); + + const result = colors.toHslaColor('var(--brand)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should resolve CSS variables with rgb values', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('rgb(255, 0, 0)'), + }); + + const result = colors.toHslaColor('var(--primary-color)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + + it('should resolve CSS variables with hsl values', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('hsl(240, 100%, 50%)'), + }); + + const result = colors.toHslaColor('var(--accent)'); + expect(result).toEqual({ h: 240, s: 100, l: 50, a: 1 }); + }); + + it('should use fallback value when CSS variable is not defined', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue(''), + }); + + const result = colors.toHslaColor('var(--undefined-var, #00ff00)'); + expect(result).toEqual({ h: 120, s: 100, l: 50, a: 1 }); + }); + + it('should handle CSS variables with spaces', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue('hsl(180, 50%, 50%)'), + }); + + const result = colors.toHslaColor('var( --spaced-var )'); + expect(result).toEqual({ h: 180, s: 50, l: 50, a: 1 }); + }); + + it('should throw error when CSS variable cannot be resolved and no fallback', () => { + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: vi.fn().mockReturnValue(''), + }); + + expect(() => colors.toHslaColor('var(--undefined-var)')).toThrow(); + }); + + it('should work in server environment without window', () => { + // @ts-ignore + global.window = undefined; + // @ts-ignore + global.getComputedStyle = undefined; + + expect(() => colors.toHslaColor('var(--brand, red)')).not.toThrow(); + const result = colors.toHslaColor('var(--brand, red)'); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + }); + }); + + describe('error cases', () => { + it('should throw error for invalid color strings', () => { + expect(() => colors.toHslaColor('invalid')).toThrow(); + expect(() => colors.toHslaColor('')).toThrow(); + expect(() => colors.toHslaColor('not-a-color')).toThrow(); + }); + + it('should throw error with helpful message', () => { + expect(() => colors.toHslaColor('invalid')).toThrow(/cannot be used as a color within 'variables'/); + }); + }); + }); + + describe('toHslaString', () => { + it('should convert HslaColor object to string', () => { + const hsla = { h: 0, s: 100, l: 50, a: 1 }; + const result = colors.toHslaString(hsla); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should convert HslaColor object with alpha to string', () => { + const hsla = { h: 120, s: 50, l: 25, a: 0.8 }; + const result = colors.toHslaString(hsla); + expect(result).toBe('hsla(120, 50%, 25%, 0.8)'); + }); + + it('should convert color string to hsla string', () => { + const result = colors.toHslaString('#ff0000'); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should handle undefined alpha', () => { + const hsla = { h: 0, s: 100, l: 50, a: undefined }; + const result = colors.toHslaString(hsla); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + }); + + describe('changeHslaLightness', () => { + it('should increase lightness', () => { + const hsla = { h: 0, s: 100, l: 50, a: 1 }; + const result = colors.changeHslaLightness(hsla, 10); + expect(result).toEqual({ h: 0, s: 100, l: 60, a: 1 }); + }); + + it('should decrease lightness', () => { + const hsla = { h: 0, s: 100, l: 50, a: 1 }; + const result = colors.changeHslaLightness(hsla, -10); + expect(result).toEqual({ h: 0, s: 100, l: 40, a: 1 }); + }); + + it('should preserve other properties', () => { + const hsla = { h: 240, s: 75, l: 30, a: 0.8 }; + const result = colors.changeHslaLightness(hsla, 20); + expect(result).toEqual({ h: 240, s: 75, l: 50, a: 0.8 }); + }); + }); + + describe('setHslaAlpha', () => { + it('should set alpha value', () => { + const hsla = { h: 0, s: 100, l: 50, a: 1 }; + const result = colors.setHslaAlpha(hsla, 0.5); + expect(result).toEqual({ h: 0, s: 100, l: 50, a: 0.5 }); + }); + + it('should preserve other properties', () => { + const hsla = { h: 240, s: 75, l: 30, a: 0.8 }; + const result = colors.setHslaAlpha(hsla, 0.2); + expect(result).toEqual({ h: 240, s: 75, l: 30, a: 0.2 }); + }); + }); + + describe('lighten', () => { + it('should return undefined for undefined color', () => { + expect(colors.lighten(undefined)).toBeUndefined(); + }); + + it('should lighten color by percentage', () => { + const result = colors.lighten('hsl(0, 100%, 50%)', 0.2); + expect(result).toBe('hsla(0, 100%, 60%, 1)'); + }); + + it('should handle zero percentage', () => { + const result = colors.lighten('hsl(0, 100%, 50%)', 0); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should handle different color formats', () => { + const result = colors.lighten('#ff0000', 0.1); + expect(result).toBe('hsla(0, 100%, 55%, 1)'); + }); + }); + + describe('makeSolid', () => { + it('should return undefined for undefined color', () => { + expect(colors.makeSolid(undefined)).toBeUndefined(); + }); + + it('should make transparent color solid', () => { + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should keep solid color solid', () => { + const result = colors.makeSolid('rgb(255, 0, 0)'); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + }); + + describe('makeTransparent', () => { + it('should return undefined for undefined color', () => { + expect(colors.makeTransparent(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(colors.makeTransparent('')).toBeUndefined(); + }); + + it('should make color transparent by percentage', () => { + const result = colors.makeTransparent('rgb(255, 0, 0)', 0.5); + expect(result).toBe('hsla(0, 100%, 50%, 0.5)'); + }); + + it('should handle zero percentage', () => { + const result = colors.makeTransparent('rgb(255, 0, 0)', 0); + expect(result).toBe('hsla(0, 100%, 50%, 1)'); + }); + + it('should handle already transparent colors', () => { + const result = colors.makeTransparent('rgba(255, 0, 0, 0.8)', 0.5); + expect(result).toBe('hsla(0, 100%, 50%, 0.4)'); + }); + }); + + describe('setAlpha', () => { + it('should set alpha value', () => { + const result = colors.setAlpha('rgb(255, 0, 0)', 0.5); + expect(result).toBe('hsla(0, 100%, 50%, 0.5)'); + }); + + it('should handle empty string', () => { + const result = colors.setAlpha('', 0.5); + expect(result).toBe(''); + }); + + it('should replace existing alpha', () => { + const result = colors.setAlpha('rgba(255, 0, 0, 0.8)', 0.3); + expect(result).toBe('hsla(0, 100%, 50%, 0.3)'); + }); + }); + + describe('adjustForLightness', () => { + it('should return undefined for undefined color', () => { + expect(colors.adjustForLightness(undefined)).toBeUndefined(); + }); + + it('should adjust lightness with default value', () => { + const result = colors.adjustForLightness('hsl(0, 100%, 50%)'); + expect(result).toBe('hsla(0, 100%, 60%, 1)'); + }); + + it('should adjust lightness with custom value', () => { + const result = colors.adjustForLightness('hsl(0, 100%, 50%)', 10); + expect(result).toBe('hsla(0, 100%, 70%, 1)'); + }); + + it('should handle maximum lightness', () => { + const result = colors.adjustForLightness('hsl(0, 100%, 100%)', 5); + expect(result).toBe('hsla(0, 100%, 95%, 1)'); + }); + + it('should cap lightness at 100%', () => { + const result = colors.adjustForLightness('hsl(0, 100%, 90%)', 10); + expect(result).toBe('hsla(0, 100%, 100%, 1)'); + }); + + it('should handle different color formats', () => { + const result = colors.adjustForLightness('#ff0000', 5); + expect(result).toBe('hsla(0, 100%, 60%, 1)'); + }); + }); + + describe('edge cases and clamping', () => { + it('should clamp RGB values to valid range', () => { + const result = colors.toHslaColor('rgb(300, -50, 500)'); + expect(result.h).toBeGreaterThanOrEqual(0); + expect(result.s).toBeGreaterThanOrEqual(0); + expect(result.l).toBeGreaterThanOrEqual(0); + expect(result.a).toBe(1); + }); + + it('should clamp alpha values to valid range', () => { + const result = colors.toHslaColor('rgba(255, 0, 0, 2)'); + expect(result.a).toBe(1); + }); + + it('should throw error for whitespace in color strings', () => { + expect(() => colors.toHslaColor(' rgb(255, 0, 0) ')).toThrow(); + }); + + it('should throw error for uppercase RGB', () => { + expect(() => colors.toHslaColor('RGB(255, 0, 0)')).toThrow(); + }); + }); + + describe('complex color conversions', () => { + it('should handle grayscale colors correctly', () => { + const white = colors.toHslaColor('#ffffff'); + expect(white).toEqual({ h: 0, s: 0, l: 100, a: 1 }); + + const black = colors.toHslaColor('#000000'); + expect(black).toEqual({ h: 0, s: 0, l: 0, a: 1 }); + + const gray = colors.toHslaColor('#808080'); + expect(gray.s).toBe(0); + expect(gray.l).toBe(50); + }); + + it('should handle bright colors correctly', () => { + const cyan = colors.toHslaColor('#00ffff'); + expect(cyan).toEqual({ h: 180, s: 100, l: 50, a: 1 }); + + const magenta = colors.toHslaColor('#ff00ff'); + expect(magenta).toEqual({ h: 300, s: 100, l: 50, a: 1 }); + }); + + it('should handle dark colors correctly', () => { + const darkRed = colors.toHslaColor('#800000'); + expect(darkRed.h).toBe(0); + expect(darkRed.s).toBe(100); + expect(darkRed.l).toBe(25); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/modern.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/modern.spec.ts new file mode 100644 index 00000000000..7b780551637 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/modern.spec.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { cssSupports } from '../../cssSupports'; +import { colors } from '../modern'; + +// Mock cssSupports +vi.mock('../../cssSupports', () => ({ + cssSupports: { + relativeColorSyntax: vi.fn(), + colorMix: vi.fn(), + }, +})); + +// Get the mocked functions +const mockRelativeColorSyntax = vi.mocked(cssSupports.relativeColorSyntax); +const mockColorMix = vi.mocked(cssSupports.colorMix); + +describe('Modern CSS Colors', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRelativeColorSyntax.mockReturnValue(true); + mockColorMix.mockReturnValue(true); + }); + + describe('lighten', () => { + it('should return undefined for undefined color', () => { + expect(colors.lighten(undefined)).toBeUndefined(); + }); + + it('should use relative color syntax when supported', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = colors.lighten('red', 0.1); + expect(result).toMatch(/hsl\(from red h s calc\(l \+ 10%\)\)/); + }); + + it('should fall back to color-mix when relative color syntax not supported', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = colors.lighten('red', 0.1); + expect(result).toMatch(/color-mix\(in srgb, red, white 10%\)/); + }); + + it('should return original color when no modern CSS support', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + const result = colors.lighten('red', 0.1); + expect(result).toBe('red'); + }); + + it('should handle zero percentage', () => { + const result = colors.lighten('blue', 0); + expect(result).toMatch(/hsl\(from blue h s calc\(l \+ 0%\)\)/); + }); + + it('should limit color-mix percentage to maximum', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = colors.lighten('red', 2); // Very high percentage + expect(result).toMatch(/color-mix\(in srgb, red, white 95%\)/); // Should be capped + }); + }); + + describe('makeTransparent', () => { + it('should return undefined for undefined color', () => { + expect(colors.makeTransparent(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(colors.makeTransparent('')).toBeUndefined(); + }); + + it('should use color-mix when supported', () => { + mockColorMix.mockReturnValue(true); + + const result = colors.makeTransparent('red', 0.5); + expect(result).toMatch(/color-mix\(in srgb, transparent, red 50%\)/); + }); + + it('should return original color when color-mix not supported', () => { + mockColorMix.mockReturnValue(false); + + const result = colors.makeTransparent('red', 0.5); + expect(result).toBe('red'); + }); + + it('should handle zero transparency', () => { + const result = colors.makeTransparent('blue', 0); + expect(result).toMatch(/color-mix\(in srgb, transparent, blue 100%\)/); + }); + + it('should enforce minimum alpha percentage', () => { + const result = colors.makeTransparent('red', 0.99); // Very transparent + expect(result).toMatch(/color-mix\(in srgb, transparent, red 5%\)/); // Should be minimum + }); + }); + + describe('makeSolid', () => { + it('should return undefined for undefined color', () => { + expect(colors.makeSolid(undefined)).toBeUndefined(); + }); + + it('should use relative color syntax when supported', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(result).toMatch(/hsl\(from rgba\(255, 0, 0, 0\.5\) h s l \/ 1\)/); + }); + + it('should fall back to color-mix when relative color syntax not supported', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(result).toMatch(/color-mix\(in srgb, rgba\(255, 0, 0, 0\.5\), rgba\(255, 0, 0, 0\.5\) 100%\)/); + }); + + it('should return original color when no modern CSS support', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + const result = colors.makeSolid('rgba(255, 0, 0, 0.5)'); + expect(result).toBe('rgba(255, 0, 0, 0.5)'); + }); + }); + + describe('setAlpha', () => { + it('should use relative color syntax when supported', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = colors.setAlpha('red', 0.5); + expect(result).toMatch(/hsl\(from red h s l \/ 0\.5\)/); + }); + + it('should fall back to color-mix when relative color syntax not supported', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = colors.setAlpha('red', 0.5); + expect(result).toMatch(/color-mix\(in srgb, transparent, red 50%\)/); + }); + + it('should return original color when no modern CSS support', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + const result = colors.setAlpha('red', 0.5); + expect(result).toBe('red'); + }); + + it('should clamp alpha values to valid range', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const resultLow = colors.setAlpha('red', -0.5); + const resultHigh = colors.setAlpha('red', 1.5); + + expect(resultLow).toMatch(/hsl\(from red h s l \/ 0\)/); + expect(resultHigh).toMatch(/hsl\(from red h s l \/ 1\)/); + }); + + it('should handle boundary alpha values', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const resultZero = colors.setAlpha('red', 0); + const resultOne = colors.setAlpha('red', 1); + + expect(resultZero).toMatch(/hsl\(from red h s l \/ 0\)/); + expect(resultOne).toMatch(/hsl\(from red h s l \/ 1\)/); + }); + }); + + describe('adjustForLightness', () => { + it('should return undefined for undefined color', () => { + expect(colors.adjustForLightness(undefined)).toBeUndefined(); + }); + + it('should use color-mix when supported', () => { + mockColorMix.mockReturnValue(true); + + const result = colors.adjustForLightness('red', 5); + expect(result).toMatch(/color-mix\(in srgb, red, white 20%\)/); + }); + + it('should return original color when no modern CSS support', () => { + mockColorMix.mockReturnValue(false); + mockRelativeColorSyntax.mockReturnValue(false); + + const result = colors.adjustForLightness('red', 5); + expect(result).toBe('red'); + }); + + it('should handle default lightness value', () => { + mockColorMix.mockReturnValue(true); + + const result = colors.adjustForLightness('red'); + expect(result).toMatch(/color-mix\(in srgb, red, white 20%\)/); + }); + + it('should limit color-mix percentage', () => { + mockColorMix.mockReturnValue(true); + + const result = colors.adjustForLightness('red', 20); // High value + expect(result).toMatch(/color-mix\(in srgb, red, white 30%\)/); // Should be limited + }); + }); + + describe('CSS support detection', () => { + it('should handle missing CSS support gracefully', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + expect(colors.lighten('red', 0.1)).toBe('red'); + expect(colors.makeTransparent('red', 0.5)).toBe('red'); + expect(colors.makeSolid('red')).toBe('red'); + expect(colors.setAlpha('red', 0.5)).toBe('red'); + expect(colors.adjustForLightness('red', 5)).toBe('red'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/__tests__/scales.spec.ts b/packages/clerk-js/src/ui/utils/colors/__tests__/scales.spec.ts new file mode 100644 index 00000000000..8df9abee00a --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/scales.spec.ts @@ -0,0 +1,367 @@ +import type { ColorScale } from '@clerk/types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { cssSupports } from '../../cssSupports'; +import { + colorOptionToThemedAlphaScale, + colorOptionToThemedLightnessScale, + generateAlphaScale, + generateLightnessScale, + legacyScales, + modernScales, +} from '../scales'; + +// Mock cssSupports +vi.mock('../../cssSupports', () => ({ + cssSupports: { + modernColor: vi.fn(), + relativeColorSyntax: vi.fn(), + }, +})); + +// Get the mocked functions +const mockModernColorSupport = vi.mocked(cssSupports.modernColor); +const mockRelativeColorSyntax = vi.mocked(cssSupports.relativeColorSyntax); + +vi.mock('../index', () => ({ + colors: { + toHslaColor: (_color: string) => ({ h: 0, s: 50, l: 50, a: 1 }), + toHslaString: (color: any) => `hsla(${color.h}, ${color.s}%, ${color.l}%, ${color.a})`, + changeHslaLightness: (color: any, change: number) => ({ + ...color, + l: Math.max(0, Math.min(100, color.l + change)), + }), + setHslaAlpha: (color: any, alpha: number) => ({ ...color, a: alpha }), + }, +})); + +describe('Color Scales', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockModernColorSupport.mockReturnValue(false); + mockRelativeColorSyntax.mockReturnValue(false); + }); + + describe('generateAlphaScale', () => { + it('should return empty scale for undefined input', () => { + const result = generateAlphaScale(undefined); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + + it('should return empty scale for null input', () => { + const result = generateAlphaScale(null as any); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + + it('should generate scale from string color', () => { + const result = generateAlphaScale('red'); + expect(result).toBeDefined(); + expect(result['25']).toBeDefined(); + expect(result['500']).toBeDefined(); + expect(result['950']).toBeDefined(); + }); + + it('should use modern CSS when supported', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = generateAlphaScale('blue'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + + const result = generateAlphaScale('blue'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + }); + + it('should handle existing color scale input', () => { + const existingScale: ColorScale = { + '25': '#ff0000', + '50': '#ff0000', + '100': '#ff0000', + '150': '#ff0000', + '200': '#ff0000', + '300': '#ff0000', + '400': '#ff0000', + '500': '#ff0000', + '600': '#ff0000', + '700': '#ff0000', + '750': '#ff0000', + '800': '#ff0000', + '850': '#ff0000', + '900': '#ff0000', + '950': '#ff0000', + }; + + const result = generateAlphaScale(existingScale); + expect(result).toBeDefined(); + expect(result['500']).toBe('#ff0000'); + }); + }); + + describe('generateLightnessScale', () => { + it('should return empty scale for undefined input', () => { + const result = generateLightnessScale(undefined); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + + it('should generate scale from string color', () => { + const result = generateLightnessScale('red'); + expect(result).toBeDefined(); + expect(result['25']).toBeDefined(); + expect(result['500']).toBeDefined(); + expect(result['950']).toBeDefined(); + }); + + it('should use modern CSS when supported', () => { + mockModernColorSupport.mockReturnValue(true); + mockRelativeColorSyntax.mockReturnValue(true); + + const result = generateLightnessScale('green'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + }); + + it('should use legacy implementation when modern CSS not supported', () => { + mockModernColorSupport.mockReturnValue(false); + + const result = generateLightnessScale('green'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + }); + + it('should handle existing color scale input', () => { + const existingScale: ColorScale = { + '25': '#00ff00', + '50': '#00ff00', + '100': '#00ff00', + '150': '#00ff00', + '200': '#00ff00', + '300': '#00ff00', + '400': '#00ff00', + '500': '#00ff00', + '600': '#00ff00', + '700': '#00ff00', + '750': '#00ff00', + '800': '#00ff00', + '850': '#00ff00', + '900': '#00ff00', + '950': '#00ff00', + }; + + const result = generateLightnessScale(existingScale); + expect(result).toBeDefined(); + expect(result['500']).toBe('#00ff00'); + }); + }); + + describe('modernScales', () => { + it('should have generateAlphaScale function', () => { + expect(typeof modernScales.generateAlphaScale).toBe('function'); + }); + + it('should have generateLightnessScale function', () => { + expect(typeof modernScales.generateLightnessScale).toBe('function'); + }); + + it('should generate modern alpha scale', () => { + const result = modernScales.generateAlphaScale('red'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + expect(result['500']).toContain('color-mix'); + }); + + it('should generate modern lightness scale', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = modernScales.generateLightnessScale('red'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + expect(result['500']).toBe('red'); // 500 should be the original color + }); + }); + + describe('legacyScales', () => { + it('should have generateAlphaScale function', () => { + expect(typeof legacyScales.generateAlphaScale).toBe('function'); + }); + + it('should have generateLightnessScale function', () => { + expect(typeof legacyScales.generateLightnessScale).toBe('function'); + }); + + it('should generate legacy alpha scale', () => { + const result = legacyScales.generateAlphaScale('red'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + expect(result['500']).toContain('hsla'); + }); + + it('should generate legacy lightness scale', () => { + const result = legacyScales.generateLightnessScale('red'); + expect(result).toBeDefined(); + expect(typeof result['500']).toBe('string'); + expect(result['500']).toContain('hsla'); + }); + }); + + describe('scale merging', () => { + it('should merge user-provided colors with generated scale', () => { + const userScale: Partial> = { + '500': '#ff0000', + '700': '#cc0000', + }; + + const result = generateLightnessScale(userScale as any); + expect(result['500']).toBe('#ff0000'); + expect(result['700']).toBe('#cc0000'); + expect(result['25']).toBeDefined(); // Should be generated + expect(result['950']).toBeDefined(); // Should be generated + }); + }); + + describe('input validation', () => { + it('should handle empty string input', () => { + const result = generateLightnessScale(''); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + + it('should handle invalid color scale object', () => { + const invalidScale = { notAShade: 'red' }; + const result = generateLightnessScale(invalidScale as any); + expect(result).toBeDefined(); + expect(Object.values(result).every(v => v === undefined)).toBe(true); + }); + + it('should throw error when color scale object is missing 500 shade', () => { + const invalidScale = { '25': '#fef2f2', '100': '#fecaca', '600': '#dc2626' }; + + expect(() => generateAlphaScale(invalidScale as any)).toThrow('You need to provide at least the 500 shade'); + expect(() => generateLightnessScale(invalidScale as any)).toThrow('You need to provide at least the 500 shade'); + }); + }); + + describe('applyScalePrefix', () => { + // We need to access the internal applyScalePrefix function for testing + // Since it's now private, we'll test it through the public API + it('should apply prefix through themed functions', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = colorOptionToThemedAlphaScale('red', 'bg-'); + + expect(result).toBeDefined(); + if (result) { + expect(Object.keys(result)).toEqual(expect.arrayContaining([expect.stringMatching(/^bg-\d+$/)])); + } + }); + + it('should skip undefined values in prefixed results', () => { + mockModernColorSupport.mockReturnValue(false); + + // Empty string results in undefined values that should be filtered out + const result = colorOptionToThemedLightnessScale('', 'text-'); + + expect(result).toBeUndefined(); + }); + }); +}); + +describe('Themed Color Scales', () => { + describe('colorOptionToThemedAlphaScale', () => { + it('should return undefined for undefined input', () => { + const result = colorOptionToThemedAlphaScale(undefined, 'bg-'); + expect(result).toBeUndefined(); + }); + + it('should handle string color input', () => { + mockModernColorSupport.mockReturnValue(true); + + const result = colorOptionToThemedAlphaScale('red', 'bg-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('bg-500'); + }); + + it('should handle color scale object', () => { + const colorScale = { + '25': '#fef2f2', + '50': '#fee2e2', + '100': '#fecaca', + '150': '#fca5a5', + '200': '#f87171', + '300': '#ef4444', + '400': '#dc2626', + '500': '#b91c1c', + '600': '#991b1b', + '700': '#7f1d1d', + '750': '#6b1d1d', + '800': '#5a1616', + '850': '#4a1212', + '900': '#3a0e0e', + '950': '#2a0a0a', + }; + + const result = colorOptionToThemedAlphaScale(colorScale, 'bg-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('bg-500'); + }); + + it('should apply correct prefix', () => { + const result = colorOptionToThemedAlphaScale('red', 'text-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('text-500'); + }); + }); + + describe('colorOptionToThemedLightnessScale', () => { + it('should return undefined for undefined input', () => { + const result = colorOptionToThemedLightnessScale(undefined, 'bg-'); + expect(result).toBeUndefined(); + }); + + it('should handle string color input', () => { + mockModernColorSupport.mockReturnValue(true); + mockRelativeColorSyntax.mockReturnValue(true); + + const result = colorOptionToThemedLightnessScale('red', 'bg-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('bg-500'); + }); + + it('should handle partial color scale object', () => { + const partialScale = { + '500': '#ef4444', + '700': '#7f1d1d', + }; + + const result = colorOptionToThemedLightnessScale(partialScale, 'bg-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('bg-500'); + }); + + it('should apply correct prefix', () => { + const result = colorOptionToThemedLightnessScale('blue', 'text-'); + + expect(result).toBeDefined(); + expect(result).toHaveProperty('text-500'); + }); + + it('should handle empty string input', () => { + const result = colorOptionToThemedLightnessScale('', 'bg-'); + + // Empty strings are falsy, so the function returns undefined + expect(result).toBeUndefined(); + }); + }); +}); 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 new file mode 100644 index 00000000000..94f41fe9f20 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/__tests__/utils.spec.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { cssSupports } from '../../cssSupports'; +import { + createAlphaColorMixString, + createColorMixString, + createEmptyColorScale, + createRelativeColorString, + generateAlphaColorMix, + generateColorMixSyntax, + generateRelativeColorSyntax, + getSupportedColorVariant, +} from '../utils'; + +// Mock cssSupports +vi.mock('../../cssSupports', () => ({ + cssSupports: { + relativeColorSyntax: vi.fn(), + colorMix: vi.fn(), + }, +})); + +// Mock DOM APIs +const mockGetComputedStyle = vi.fn(); +const mockGetPropertyValue = vi.fn(); + +// Get the mocked functions +const mockRelativeColorSyntax = vi.mocked(cssSupports.relativeColorSyntax); +const mockColorMix = vi.mocked(cssSupports.colorMix); + +// Setup DOM mocks +Object.defineProperty(window, 'getComputedStyle', { + value: mockGetComputedStyle, + writable: true, +}); + +// Mock document.documentElement +Object.defineProperty(document, 'documentElement', { + value: { + style: {}, + }, + writable: true, +}); + +describe('Color Utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + // Setup getComputedStyle mock + mockGetComputedStyle.mockReturnValue({ + getPropertyValue: mockGetPropertyValue, + }); + mockGetPropertyValue.mockReturnValue(''); + }); + + describe('createEmptyColorScale', () => { + it('should create an empty color scale with all shades', () => { + const scale = createEmptyColorScale(); + + expect(scale).toHaveProperty('25', undefined); + expect(scale).toHaveProperty('50', undefined); + expect(scale).toHaveProperty('100', undefined); + expect(scale).toHaveProperty('500', undefined); + expect(scale).toHaveProperty('950', undefined); + }); + + it('should return a new object each time', () => { + const scale1 = createEmptyColorScale(); + const scale2 = createEmptyColorScale(); + + expect(scale1).not.toBe(scale2); + expect(scale1).toEqual(scale2); + }); + + it('should allow modification of returned scale', () => { + const scale = createEmptyColorScale(); + scale['500'] = 'red'; + + expect(scale['500']).toBe('red'); + }); + }); + + describe('color string generators', () => { + describe('createColorMixString', () => { + it('should generate color-mix syntax', () => { + const result = createColorMixString('red', 'blue', 50); + expect(result).toBe('color-mix(in srgb, red, blue 50%)'); + }); + }); + + describe('createRelativeColorString', () => { + it('should generate relative color syntax without alpha', () => { + const result = createRelativeColorString('red', 'h', 's', 'calc(l + 10%)'); + expect(result).toBe('hsl(from red h s calc(l + 10%))'); + }); + + it('should generate relative color syntax with alpha', () => { + const result = createRelativeColorString('red', 'h', 's', 'l', '0.5'); + expect(result).toBe('hsl(from red h s l / 0.5)'); + }); + }); + + describe('createAlphaColorMixString', () => { + it('should generate alpha color-mix syntax', () => { + const result = createAlphaColorMixString('red', 50); + expect(result).toBe('color-mix(in srgb, transparent, red 50%)'); + }); + }); + }); + + describe('generateRelativeColorSyntax', () => { + it('should return original color for 500 shade', () => { + const result = generateRelativeColorSyntax('red', 500); + expect(result).toBe('red'); + }); + + it('should generate correct syntax for light shades', () => { + const result = generateRelativeColorSyntax('red', 400); + expect(result).toMatch(/hsl\(from red h s calc\(l \+ \(1 \* \(\(97 - l\) \/ 7\)\)\)\)/); + }); + + it('should generate correct syntax for dark shades', () => { + const result = generateRelativeColorSyntax('red', 600); + expect(result).toMatch(/hsl\(from red h s calc\(l - \(1 \* \(\(l - 12\) \/ 7\)\)\)\)/); + }); + }); + + describe('generateColorMixSyntax', () => { + it('should return original color for 500 shade', () => { + const result = generateColorMixSyntax('red', 500); + expect(result).toBe('red'); + }); + + it('should generate color-mix with white for light shades', () => { + const result = generateColorMixSyntax('red', 50); + expect(result).toBe('color-mix(in srgb, red, white 80%)'); + }); + + it('should generate color-mix with black for dark shades', () => { + const result = generateColorMixSyntax('red', 800); + expect(result).toBe('color-mix(in srgb, red, black 44%)'); + }); + }); + + describe('generateAlphaColorMix', () => { + it('should generate alpha color-mix for all shades', () => { + const result25 = generateAlphaColorMix('red', 25); + const result500 = generateAlphaColorMix('red', 500); + const result950 = generateAlphaColorMix('red', 950); + + expect(result25).toBe('color-mix(in srgb, transparent, red 2%)'); + expect(result500).toBe('color-mix(in srgb, transparent, red 53%)'); + expect(result950).toBe('color-mix(in srgb, transparent, red 92%)'); + }); + }); + + describe('getSupportedColorVariant', () => { + it('should return original color for 500 shade', () => { + const result = getSupportedColorVariant('red', 500); + expect(result).toBe('red'); + }); + + it('should use relative color syntax when supported', () => { + mockRelativeColorSyntax.mockReturnValue(true); + + const result = getSupportedColorVariant('red', 400); + expect(result).toMatch(/hsl\(from red h s calc\(l \+ \(1 \* \(\(97 - l\) \/ 7\)\)\)\)/); + }); + + it('should fall back to color-mix when relative color syntax not supported', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(true); + + const result = getSupportedColorVariant('red', 400); + expect(result).toBe('color-mix(in srgb, red, white 16%)'); + }); + + it('should return original color when no modern CSS support', () => { + mockRelativeColorSyntax.mockReturnValue(false); + mockColorMix.mockReturnValue(false); + + const result = getSupportedColorVariant('red', 400); + expect(result).toBe('red'); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/colors/constants.ts b/packages/clerk-js/src/ui/utils/colors/constants.ts new file mode 100644 index 00000000000..3061e100e20 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/constants.ts @@ -0,0 +1,107 @@ +/** + * Shared constants for color utilities + */ + +import type { ColorScale } from '@clerk/types'; + +// Types +export type ColorShade = 25 | 50 | 100 | 150 | 200 | 300 | 400 | 500 | 600 | 700 | 750 | 800 | 850 | 900 | 950; +export type ColorShadeKey = keyof ColorScale; + +// Core color scale definition +export const COLOR_SCALE: readonly ColorShade[] = [ + 25, 50, 100, 150, 200, 300, 400, 500, 600, 700, 750, 800, 850, 900, 950, +] as const; + +// Shade groupings for scale generation +export const LIGHT_SHADES: ColorShadeKey[] = ['400', '300', '200', '150', '100', '50', '25']; +export const DARK_SHADES: ColorShadeKey[] = ['600', '700', '750', '800', '850', '900', '950']; +export const ALL_SHADES: ColorShadeKey[] = [...LIGHT_SHADES, '500', ...DARK_SHADES]; + +// Lightness configuration for scale generation +export const LIGHTNESS_CONFIG = { + TARGET_LIGHT: 97, // Target lightness for 50 shade + TARGET_DARK: 12, // Target lightness for 900 shade + LIGHT_STEPS: 7, // Number of light shades + DARK_STEPS: 7, // Number of dark shades +} as const; + +// Alpha percentages for color-mix generation +export const ALPHA_PERCENTAGES: Record = { + 25: 2, + 50: 3, + 100: 7, + 150: 11, + 200: 15, + 300: 28, + 400: 41, + 500: 53, + 600: 62, + 700: 73, + 750: 78, + 800: 81, + 850: 84, + 900: 87, + 950: 92, +} as const; + +export const ALPHA_VALUES = Object.values(ALPHA_PERCENTAGES) + .map(v => v / 100) + .sort(); + +// Lightness mix data for color-mix generation +export const LIGHTNESS_MIX_DATA: Record = { + 25: { mixColor: 'white', percentage: 85 }, + 50: { mixColor: 'white', percentage: 80 }, + 100: { mixColor: 'white', percentage: 68 }, + 150: { mixColor: 'white', percentage: 55 }, + 200: { mixColor: 'white', percentage: 40 }, + 300: { mixColor: 'white', percentage: 26 }, + 400: { mixColor: 'white', percentage: 16 }, + 500: { mixColor: null, percentage: 0 }, + 600: { mixColor: 'black', percentage: 12 }, + 700: { mixColor: 'black', percentage: 22 }, + 750: { mixColor: 'black', percentage: 30 }, + 800: { mixColor: 'black', percentage: 44 }, + 850: { mixColor: 'black', percentage: 55 }, + 900: { mixColor: 'black', percentage: 65 }, + 950: { mixColor: 'black', percentage: 75 }, +} as const; + +// Relative color syntax step configuration +export const RELATIVE_SHADE_STEPS: Record = { + // Light shades (lighter than 500) + 400: 1, + 300: 2, + 200: 3, + 150: 4, + 100: 5, + 50: 6, + 25: 7, + // Dark shades (darker than 500) + 600: 1, + 700: 2, + 750: 3, + 800: 4, + 850: 5, + 900: 6, + 950: 7, +} as const; + +// Color bounds for validation and clamping +export const COLOR_BOUNDS = { + rgb: { min: 0, max: 255 }, + alpha: { min: 0, max: 1 }, + hue: { min: 0, max: 360 }, + percentage: { min: 0, max: 100 }, +} as const; + +// Modern CSS utility constants +export const MODERN_CSS_LIMITS = { + MAX_LIGHTNESS_MIX: 95, // Maximum percentage for color-mix with white + MIN_ALPHA_PERCENTAGE: 5, // Minimum opacity for transparent color-mix + MAX_LIGHTNESS_ADJUSTMENT: 30, // Maximum lightness adjustment in color-mix + MIN_LIGHTNESS_FLOOR: 95, // Minimum lightness floor for very light colors + LIGHTNESS_MULTIPLIER: 2, // Multiplier for lightness adjustments + MIX_MULTIPLIER: 4, // Multiplier for mix percentage calculations +} as const; diff --git a/packages/clerk-js/src/ui/utils/colors/index.ts b/packages/clerk-js/src/ui/utils/colors/index.ts new file mode 100644 index 00000000000..bf0dbe8c0ab --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/index.ts @@ -0,0 +1,160 @@ +import type { HslaColor } from '@clerk/types'; + +import { cssSupports } from '../cssSupports'; +import { colors as legacyColors } from './legacy'; +import { colors as modernColors } from './modern'; + +export const colors = { + /** + * Changes the lightness value of an HSLA color object + * @param color - The HSLA color object to modify + * @param lightness - The new lightness value (0-100) + * @returns A new HSLA color object with the modified lightness + * @example + * ```typescript + * const darkColor = colors.changeHslaLightness({ h: 200, s: 50, l: 80, a: 1 }, 20); + * ``` + */ + changeHslaLightness: legacyColors.changeHslaLightness, + + /** + * Sets the alpha (opacity) value of an HSLA color object + * @param color - The HSLA color object to modify + * @param alpha - The new alpha value (0-1) + * @returns A new HSLA color object with the modified alpha + * @example + * ```typescript + * const semiTransparent = colors.setHslaAlpha({ h: 200, s: 50, l: 50, a: 1 }, 0.5); + * ``` + */ + setHslaAlpha: legacyColors.setHslaAlpha, + + /** + * Converts a color string to either a string (modern CSS) or HSLA object (legacy) + * Uses modern CSS features when supported, falls back to parsing the string into an HSLA object for older browsers + * @param color - CSS color string (hex, rgb, hsl, `var(--color)`, etc.) or undefined + * @returns Color string in modern browsers, HSLA object in legacy browsers, or undefined if input is undefined + * @example + * ```typescript + * const processedColor = colors.toHslaColor('#ff0000'); // '#ff0000' or { h: 0, s: 100, l: 50, a: 1 } + * const noColor = colors.toHslaColor(undefined); // undefined + * ``` + */ + toHslaColor: (color: string | undefined): string | HslaColor | undefined => { + if (!color) return undefined; + return cssSupports.modernColor() ? color : legacyColors.toHslaColor(color); + }, + + /** + * Converts a color (string or HSLA object) to a CSS string representation + * @param color - CSS color string, HSLA object, or undefined + * @returns CSS color string or undefined if input is undefined + * @example + * ```typescript + * const cssColor = colors.toHslaString('#ff0000'); // '#ff0000' or 'hsla(0, 100%, 50%, 1)' + * const hslaColor = colors.toHslaString({ h: 200, s: 50, l: 50, a: 1 }); // 'hsla(200, 50%, 50%, 1)' + * ``` + */ + toHslaString: (color: string | HslaColor | undefined): string | undefined => { + if (!color) return undefined; + if (cssSupports.modernColor() && typeof color === 'string') return color; + return legacyColors.toHslaString(color); + }, + + /** + * Creates a lighter version of the given color + * Uses modern CSS relative color syntax when supported, falls back to HSLA manipulation + * @param color - CSS color string or undefined + * @param percentage - How much lighter to make the color (0-100, default: 0) + * @returns Lightened color string or undefined if input is undefined + * @example + * ```typescript + * const lightBlue = colors.lighten('#0066cc', 20); // 20% lighter blue + * const noChange = colors.lighten('#0066cc'); // Same color (0% change) + * ``` + */ + lighten: (color: string | undefined, percentage = 0): string | undefined => { + if (cssSupports.modernColor()) { + return modernColors.lighten(color, percentage); + } + return legacyColors.lighten(color, percentage); + }, + + /** + * Creates a transparent version of the given color by reducing its opacity + * Uses modern CSS color-mix function when supported, falls back to HSLA alpha manipulation + * @param color - CSS color string or undefined + * @param percentage - How much transparency to add (0-100, default: 0) + * @returns Color with reduced opacity or undefined if input is undefined + * @example + * ```typescript + * const semiTransparent = colors.makeTransparent('#ff0000', 50); // 50% transparent red + * const opaque = colors.makeTransparent('#ff0000'); // Same color (0% transparency) + * ``` + */ + makeTransparent: (color: string | undefined, percentage = 0): string | undefined => { + if (cssSupports.modernColor()) { + return modernColors.makeTransparent(color, percentage); + } + return legacyColors.makeTransparent(color, percentage); + }, + + /** + * Removes transparency from a color, making it fully opaque + * Uses modern CSS features when supported, falls back to HSLA alpha manipulation + * @param color - CSS color string or undefined + * @returns Fully opaque version of the color or undefined if input is undefined + * @example + * ```typescript + * const solid = colors.makeSolid('rgba(255, 0, 0, 0.5)'); // Fully opaque red + * const alreadySolid = colors.makeSolid('#ff0000'); // Same color (already opaque) + * ``` + */ + makeSolid: (color: string | undefined): string | undefined => { + if (cssSupports.modernColor()) { + return modernColors.makeSolid(color); + } + return legacyColors.makeSolid(color); + }, + + /** + * Sets the alpha (opacity) value of a color + * Uses modern CSS relative color syntax when supported, falls back to HSLA manipulation + * @param color - CSS color string (required) + * @param alpha - Alpha value between 0 (transparent) and 1 (opaque) + * @returns Color string with the specified alpha value + * @throws {Error} When color is not provided + * @example + * ```typescript + * const halfTransparent = colors.setAlpha('#ff0000', 0.5); // 50% transparent red + * const fullyOpaque = colors.setAlpha('rgba(255, 0, 0, 0.3)', 1); // Fully opaque red + * ``` + */ + setAlpha: (color: string, alpha: number): string => { + if (cssSupports.modernColor()) { + return modernColors.setAlpha(color, alpha); + } + return legacyColors.setAlpha(color, alpha); + }, + + /** + * Adjusts a color's lightness for better contrast or visual hierarchy + * Uses modern CSS relative color syntax when supported, falls back to HSLA manipulation + * @param color - CSS color string or undefined + * @param lightness - Lightness adjustment amount (default: 5) + * @returns Color with adjusted lightness or undefined if input is undefined + * @example + * ```typescript + * const adjusted = colors.adjustForLightness('#333333', 10); // Slightly lighter dark gray + * const subtle = colors.adjustForLightness('#666666'); // Subtle lightness adjustment (5 units) + * ``` + */ + adjustForLightness: (color: string | undefined, lightness = 5): string | undefined => { + if (cssSupports.modernColor()) { + return modernColors.adjustForLightness(color, lightness); + } + return legacyColors.adjustForLightness(color, lightness); + }, +}; + +export { modernColors, legacyColors }; diff --git a/packages/clerk-js/src/ui/utils/colors.ts b/packages/clerk-js/src/ui/utils/colors/legacy.ts similarity index 94% rename from packages/clerk-js/src/ui/utils/colors.ts rename to packages/clerk-js/src/ui/utils/colors/legacy.ts index d3ee83e7550..5e59377c60b 100644 --- a/packages/clerk-js/src/ui/utils/colors.ts +++ b/packages/clerk-js/src/ui/utils/colors/legacy.ts @@ -10,6 +10,8 @@ import type { HslaColor, HslaColorString } from '@clerk/types'; +import { resolveCSSVariable } from '../cssVariables'; + const abbrRegex = /^#([a-f0-9]{3,4})$/i; const hexRegex = /^#([a-f0-9]{6})([a-f0-9]{2})?$/i; const rgbaRegex = @@ -255,17 +257,21 @@ const hslaColorToHslaString = ({ h, s, l, a }: HslaColor): HslaColorString => { }; const parse = (str: string): ParsedResult => { - const prefix = str.substr(0, 3).toLowerCase(); + // First try to resolve CSS variables + const resolvedStr = resolveCSSVariable(str); + const colorStr = resolvedStr || str; + + const prefix = colorStr.substr(0, 3).toLowerCase(); let res; if (prefix === 'hsl') { - res = { model: 'hsl', value: parseHsl(str) }; + res = { model: 'hsl', value: parseHsl(colorStr) }; } else if (prefix === 'hwb') { - res = { model: 'hwb', value: parseHwb(str) }; + res = { model: 'hwb', value: parseHwb(colorStr) }; } else { - res = { model: 'rgb', value: parseRgb(str) }; + res = { model: 'rgb', value: parseRgb(colorStr) }; } if (!res || !res.value) { - throw new Error(`Clerk: "${str}" cannot be used as a color within 'variables'. You can pass one of: + throw new Error(`Clerk: "${colorStr}" cannot be used as a color within 'variables'. You can pass one of: - any valid hsl or hsla color - any valid rgb or rgba color - any valid hex color diff --git a/packages/clerk-js/src/ui/utils/colors/modern.ts b/packages/clerk-js/src/ui/utils/colors/modern.ts new file mode 100644 index 00000000000..5da2b676415 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/modern.ts @@ -0,0 +1,106 @@ +/** + * CSS-based color manipulation utilities + * Uses color-mix() and relative color syntax when supported + */ + +import { cssSupports } from '../cssSupports'; +import { COLOR_BOUNDS, MODERN_CSS_LIMITS } from './constants'; +import { createAlphaColorMixString, createColorMixString, createRelativeColorString } from './utils'; + +/** + * CSS-based color manipulation utilities + * Uses color-mix() and relative color syntax when supported + */ +export const colors = { + /** + * Lightens a color by a percentage + */ + lighten: (color: string | undefined, percentage = 0): string | undefined => { + if (!color) return undefined; + + if (cssSupports.relativeColorSyntax()) { + // Use relative color syntax for precise lightness control + const lightnessIncrease = percentage * 100; // Convert to percentage + return createRelativeColorString(color, 'h', 's', `calc(l + ${lightnessIncrease}%)`); + } + + if (cssSupports.colorMix()) { + // Use color-mix as fallback + const mixPercentage = Math.min(percentage * 100, MODERN_CSS_LIMITS.MAX_LIGHTNESS_MIX); + return createColorMixString(color, 'white', mixPercentage); + } + + return color; // Return original if no CSS support + }, + + /** + * Makes a color transparent by a percentage + */ + makeTransparent: (color: string | undefined, percentage = 0): string | undefined => { + if (!color || color.toString() === '') return undefined; + + if (cssSupports.colorMix()) { + const alphaPercentage = Math.max((1 - percentage) * 100, MODERN_CSS_LIMITS.MIN_ALPHA_PERCENTAGE); + return createAlphaColorMixString(color, alphaPercentage); + } + + return color; // Return original if no CSS support + }, + + /** + * Makes a color completely opaque + */ + makeSolid: (color: string | undefined): string | undefined => { + if (!color) return undefined; + + if (cssSupports.relativeColorSyntax()) { + // Set alpha to 1 using relative color syntax + return createRelativeColorString(color, 'h', 's', 'l', '1'); + } + + if (cssSupports.colorMix()) { + // Mix with itself at 100% to remove transparency + return `color-mix(in srgb, ${color}, ${color} 100%)`; + } + + return color; // Return original if no CSS support + }, + + /** + * Sets the alpha value of a color + */ + setAlpha: (color: string, alpha: number): string => { + const clampedAlpha = Math.min(Math.max(alpha, COLOR_BOUNDS.alpha.min), COLOR_BOUNDS.alpha.max); + + if (cssSupports.relativeColorSyntax()) { + // Use relative color syntax for precise alpha control + return createRelativeColorString(color, 'h', 's', 'l', clampedAlpha.toString()); + } + + if (cssSupports.colorMix()) { + // Use color-mix with transparent + const percentage = clampedAlpha * 100; + return createAlphaColorMixString(color, percentage); + } + + return color; // Return original if no CSS support + }, + + /** + * Adjusts color for better contrast/lightness + */ + adjustForLightness: (color: string | undefined, lightness = 5): string | undefined => { + if (!color) return undefined; + + if (cssSupports.colorMix()) { + // Use color-mix with white for lightness adjustment - more conservative approach + const mixPercentage = Math.min( + lightness * MODERN_CSS_LIMITS.MIX_MULTIPLIER, + MODERN_CSS_LIMITS.MAX_LIGHTNESS_ADJUSTMENT, + ); + return createColorMixString(color, 'white', mixPercentage); + } + + return color; // Return original if no CSS support + }, +}; diff --git a/packages/clerk-js/src/ui/utils/colors/scales.ts b/packages/clerk-js/src/ui/utils/colors/scales.ts new file mode 100644 index 00000000000..bc040ca894e --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/scales.ts @@ -0,0 +1,291 @@ +import type { ColorScale, CssColorOrAlphaScale, CssColorOrScale, HslaColorString } from '@clerk/types'; + +import { cssSupports } from '../cssSupports'; +import { ALL_SHADES, ALPHA_VALUES, COLOR_SCALE, DARK_SHADES, LIGHT_SHADES, LIGHTNESS_CONFIG } from './constants'; +import { colors as legacyColors } from './legacy'; +import { createEmptyColorScale, generateAlphaColorMix, getSupportedColorVariant } from './utils'; + +// Types for themed scales +type InternalColorScale = ColorScale & Partial>; +type WithPrefix, Prefix extends string> = { + [K in keyof T as `${Prefix}${K & string}`]: T[K]; +}; + +/** + * Apply a prefix to a color scale + * @param scale - The color scale to apply the prefix to + * @param prefix - The prefix to apply + * @returns The color scale with the prefix applied + */ +function applyScalePrefix( + scale: ColorScale, + prefix: Prefix, +): Record<`${Prefix}${keyof ColorScale}`, string> { + const result: Record = {}; + + for (const [shade, color] of Object.entries(scale)) { + if (color !== undefined) { + result[prefix + shade] = color; + } + } + + return result as Record<`${Prefix}${keyof ColorScale}`, string>; +} + +/** + * Modern CSS alpha scale generation + */ +function generateModernAlphaScale(baseColor: string): ColorScale { + const scale = createEmptyColorScale(); + + COLOR_SCALE.forEach(shade => { + scale[shade] = generateAlphaColorMix(baseColor, shade); + }); + + return scale as ColorScale; +} + +/** + * Legacy HSLA alpha scale generation + */ +function generateLegacyAlphaScale(baseColor: string): ColorScale { + const scale = createEmptyColorScale(); + const parsedColor = legacyColors.toHslaColor(baseColor); + const baseWithoutAlpha = legacyColors.setHslaAlpha(parsedColor, 0); + + COLOR_SCALE.forEach((shade, index) => { + const alpha = ALPHA_VALUES[index] ?? 1; + const alphaColor = legacyColors.setHslaAlpha(baseWithoutAlpha, alpha); + scale[shade] = legacyColors.toHslaString(alphaColor); + }); + + return scale as ColorScale; +} + +/** + * Modern CSS lightness scale generation + */ +function generateModernLightnessScale(baseColor: string): ColorScale { + const scale = createEmptyColorScale(); + + COLOR_SCALE.forEach(shade => { + scale[shade] = getSupportedColorVariant(baseColor, shade); + }); + + return scale as ColorScale; +} + +/** + * Legacy HSLA lightness scale generation + */ +function generateLegacyLightnessScale(baseColor: string): ColorScale { + const scale = createEmptyColorScale(); + const parsedColor = legacyColors.toHslaColor(baseColor); + + // Set the base 500 shade + scale['500'] = legacyColors.toHslaString(parsedColor); + + // Calculate lightness steps + const lightStep = (LIGHTNESS_CONFIG.TARGET_LIGHT - parsedColor.l) / LIGHT_SHADES.length; + const darkStep = (parsedColor.l - LIGHTNESS_CONFIG.TARGET_DARK) / DARK_SHADES.length; + + // Generate light shades (lighter than base) + LIGHT_SHADES.forEach((shade, index) => { + const lightnessIncrease = (index + 1) * lightStep; + const lightColor = legacyColors.changeHslaLightness(parsedColor, lightnessIncrease); + scale[shade] = legacyColors.toHslaString(lightColor); + }); + + // Generate dark shades (darker than base) + DARK_SHADES.forEach((shade, index) => { + const lightnessDecrease = (index + 1) * darkStep * -1; + const darkColor = legacyColors.changeHslaLightness(parsedColor, lightnessDecrease); + scale[shade] = legacyColors.toHslaString(darkColor); + }); + + return scale as ColorScale; +} + +/** + * Processes color input and validates it + */ +function processColorInput( + color: string | ColorScale | CssColorOrScale | undefined, +): { baseColor: string; userScale?: ColorScale } | null { + if (!color) return null; + + if (typeof color === 'string') { + return { baseColor: color }; + } + + // If it's already a color scale object, extract the base color (500 shade) + if (color['500']) { + return { + baseColor: color['500'], + userScale: color as ColorScale, + }; + } + + // If it's an object, check if it has any valid shade keys + if (typeof color === 'object') { + const hasValidShadeKeys = ALL_SHADES.some((shade: keyof ColorScale) => color[shade]); + + if (hasValidShadeKeys && !color['500']) { + // Has valid shade keys but missing 500 - this is an error + throw new Error('You need to provide at least the 500 shade'); + } + + // No valid shade keys - treat as invalid input + if (!hasValidShadeKeys) { + return null; + } + } + + return null; +} + +/** + * Merges user-defined colors with generated scale + */ +function mergeWithUserScale(generated: ColorScale, userScale?: ColorScale): ColorScale { + if (!userScale) return generated; + + return { ...generated, ...userScale }; +} + +/** + * Unified alpha scale generator that automatically chooses between modern and legacy implementations + * @param color - Base color string or existing color scale + * @returns Complete color scale with alpha variations + */ +export function generateAlphaScale( + color: string | ColorScale | CssColorOrScale | undefined, +): ColorScale { + const processed = processColorInput(color); + if (!processed) { + return createEmptyColorScale() as ColorScale; + } + + const { baseColor, userScale } = processed; + + // Generate scale using modern or legacy implementation + const generated = cssSupports.modernColor() + ? generateModernAlphaScale(baseColor) + : generateLegacyAlphaScale(baseColor); + + // Merge with user-provided colors if any + return mergeWithUserScale(generated, userScale); +} + +/** + * Unified lightness scale generator that automatically chooses between modern and legacy implementations + * @param color - Base color string or existing color scale + * @returns Complete color scale with lightness variations + */ +export function generateLightnessScale( + color: string | ColorScale | CssColorOrScale | undefined, +): ColorScale { + const processed = processColorInput(color); + if (!processed) { + return createEmptyColorScale() as ColorScale; + } + + const { baseColor, userScale } = processed; + + // Generate scale using modern or legacy implementation + const generated = cssSupports.modernColor() + ? generateModernLightnessScale(baseColor) + : generateLegacyLightnessScale(baseColor); + + // Merge with user-provided colors if any + return mergeWithUserScale(generated, userScale); +} + +/** + * Direct access to modern scale generators (for testing or when modern CSS is guaranteed) + */ +export const modernScales = { + generateAlphaScale: generateModernAlphaScale, + generateLightnessScale: generateModernLightnessScale, +} as const; + +/** + * Direct access to legacy scale generators (for testing or compatibility) + */ +export const legacyScales = { + generateAlphaScale: generateLegacyAlphaScale, + generateLightnessScale: generateLegacyLightnessScale, +} as const; + +/** + * Converts a color scale to CSS color strings + * Works with both modern CSS (color-mix, relative colors) and legacy HSLA + */ +function convertScaleToCssStrings(scale: ColorScale): ColorScale { + const result: Partial> = {}; + + for (const [shade, color] of Object.entries(scale)) { + if (color && color !== undefined) { + // For modern CSS color-mix values, we keep them as-is since they're already valid CSS + // For legacy HSLA values, they're already in HSLA format + result[shade as keyof ColorScale] = color as HslaColorString; + } + } + + return result as ColorScale; +} + +/** + * Applies prefix to a color scale and converts to CSS color strings + */ +function prefixAndConvertScale( + scale: ColorScale, + prefix: Prefix, +): WithPrefix, Prefix> { + const cssScale = convertScaleToCssStrings(scale); + return applyScalePrefix(cssScale, prefix) as unknown as WithPrefix, Prefix>; +} + +/** + * Converts a color option to a themed alpha scale with prefix + * Returns CSS color values (modern color-mix/relative colors or legacy HSLA) + * @param colorOption - Color input (string or alpha scale object) + * @param prefix - Prefix to apply to scale keys + * @returns Prefixed CSS color scale or undefined + */ +export const colorOptionToThemedAlphaScale = ( + colorOption: CssColorOrAlphaScale | undefined, + prefix: Prefix, +): WithPrefix, Prefix> | undefined => { + if (!colorOption) { + return undefined; + } + + // Generate alpha scale using the unified scale generator + const scale = generateAlphaScale(colorOption); + + // Convert to CSS strings and apply prefix + return prefixAndConvertScale(scale, prefix); +}; + +/** + * Converts a color option to a themed lightness scale with prefix + * Returns CSS color values (modern color-mix/relative colors or legacy HSLA) + * @param colorOption - Color input (string or lightness scale object) + * @param prefix - Prefix to apply to scale keys + * @returns Prefixed CSS color scale or undefined + */ +export const colorOptionToThemedLightnessScale = ( + colorOption: CssColorOrScale | undefined, + prefix: Prefix, +): WithPrefix, Prefix> | undefined => { + if (!colorOption) { + return undefined; + } + + // Generate lightness scale using the unified scale generator + const scale = generateLightnessScale(colorOption); + + // Convert to CSS strings and apply prefix + return prefixAndConvertScale(scale, prefix); +}; diff --git a/packages/clerk-js/src/ui/utils/colors/utils.ts b/packages/clerk-js/src/ui/utils/colors/utils.ts new file mode 100644 index 00000000000..fb386487fb2 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/colors/utils.ts @@ -0,0 +1,137 @@ +import type { ColorScale } from '@clerk/types'; + +import { cssSupports } from '../cssSupports'; +import type { ColorShade } from './constants'; +import { ALL_SHADES, ALPHA_PERCENTAGES, LIGHTNESS_CONFIG, LIGHTNESS_MIX_DATA, RELATIVE_SHADE_STEPS } from './constants'; + +/** + * Pre-computed empty color scale to avoid object creation + */ +const EMPTY_COLOR_SCALE: ColorScale = Object.freeze( + ALL_SHADES.reduce( + (scale, shade) => { + scale[shade] = undefined; + return scale; + }, + {} as ColorScale, + ), +); + +/** + * Fast empty color scale creation - returns pre-computed frozen object + */ +export const createEmptyColorScale = (): ColorScale => { + return { ...EMPTY_COLOR_SCALE }; +}; + +/** + * Core color generation functions + */ + +/** + * Create a color-mix string + * @param baseColor - The base color + * @param mixColor - The color to mix with + * @param percentage - The percentage of the mix + * @returns The color-mix string + */ +export function createColorMixString(baseColor: string, mixColor: string, percentage: number): string { + return `color-mix(in srgb, ${baseColor}, ${mixColor} ${percentage}%)`; +} + +/** + * Generate a relative color syntax string + * @param color - The base color + * @param hue - The hue component + * @param saturation - The saturation component + * @param lightness - The lightness component + * @param alpha - The alpha component (optional) + * @returns The relative color syntax string + */ +export function createRelativeColorString( + color: string, + hue: string, + saturation: string, + lightness: string, + alpha?: string, +): string { + return `hsl(from ${color} ${hue} ${saturation} ${lightness}${alpha ? ` / ${alpha}` : ''})`; +} + +/** + * Create an alpha color-mix string + * @param color - The base color + * @param alphaPercentage - The alpha percentage + * @returns The alpha color-mix string + */ +export function createAlphaColorMixString(color: string, alphaPercentage: number): string { + return `color-mix(in srgb, transparent, ${color} ${alphaPercentage}%)`; +} + +/** + * Generate a relative color syntax string + * @param color - The base color + * @param shade - The shade to generate the color for + * @returns The relative color syntax string + */ +export function generateRelativeColorSyntax(color: string, shade: ColorShade): string { + if (shade === 500) return color; + + const steps = RELATIVE_SHADE_STEPS[shade]; + if (!steps) return color; + + const { TARGET_LIGHT, TARGET_DARK, LIGHT_STEPS, DARK_STEPS } = LIGHTNESS_CONFIG; + + // Light shades (25-400) + if (shade < 500) { + return createRelativeColorString( + color, + 'h', + 's', + `calc(l + (${steps} * ((${TARGET_LIGHT} - l) / ${LIGHT_STEPS})))`, + ); + } + + // Dark shades (600-950) + return createRelativeColorString(color, 'h', 's', `calc(l - (${steps} * ((l - ${TARGET_DARK}) / ${DARK_STEPS})))`); +} + +/** + * Generate a color-mix string + * @param color - The base color + * @param shade - The shade to generate the color for + * @returns The color-mix string + */ +export function generateColorMixSyntax(color: string, shade: ColorShade): string { + if (shade === 500) return color; + + const mixData = LIGHTNESS_MIX_DATA[shade]; + if (!mixData.mixColor) return color; + + return createColorMixString(color, mixData.mixColor, mixData.percentage); +} + +export function generateAlphaColorMix(color: string, shade: ColorShade): string { + const alphaPercentage = ALPHA_PERCENTAGES[shade]; + return createAlphaColorMixString(color, alphaPercentage); +} + +/** + * Get the optimal color variant for the given shade + * @param color - The base color + * @param shade - The shade to generate the color for + * @returns The optimal color variant + */ +export function getSupportedColorVariant(color: string, shade: ColorShade): string { + if (shade === 500) return color; + + if (cssSupports.relativeColorSyntax()) { + return generateRelativeColorSyntax(color, shade); + } + + if (cssSupports.colorMix()) { + return generateColorMixSyntax(color, shade); + } + + return color; +} diff --git a/packages/clerk-js/src/ui/utils/cssSupports.ts b/packages/clerk-js/src/ui/utils/cssSupports.ts new file mode 100644 index 00000000000..802efe33aaf --- /dev/null +++ b/packages/clerk-js/src/ui/utils/cssSupports.ts @@ -0,0 +1,50 @@ +const CSS_FEATURE_TESTS: Record = { + relativeColorSyntax: 'color: hsl(from white h s l)', + colorMix: 'color: color-mix(in srgb, white, black)', +} as const; + +let SUPPORTS_RELATIVE_COLOR: boolean | undefined; +let SUPPORTS_COLOR_MIX: boolean | undefined; +let SUPPORTS_MODERN_COLOR: boolean | undefined; + +export const cssSupports = { + relativeColorSyntax: () => { + if (SUPPORTS_RELATIVE_COLOR !== undefined) return SUPPORTS_RELATIVE_COLOR; + try { + SUPPORTS_RELATIVE_COLOR = CSS.supports(CSS_FEATURE_TESTS.relativeColorSyntax); + } catch { + SUPPORTS_RELATIVE_COLOR = false; + } + + return SUPPORTS_RELATIVE_COLOR; + }, + colorMix: () => { + if (SUPPORTS_COLOR_MIX !== undefined) return SUPPORTS_COLOR_MIX; + try { + SUPPORTS_COLOR_MIX = CSS.supports(CSS_FEATURE_TESTS.colorMix); + } catch { + SUPPORTS_COLOR_MIX = false; + } + + return SUPPORTS_COLOR_MIX; + }, + /** + * Returns true if either relativeColorSyntax or colorMix is supported + */ + modernColor() { + if (SUPPORTS_MODERN_COLOR !== undefined) return SUPPORTS_MODERN_COLOR; + try { + SUPPORTS_MODERN_COLOR = this.relativeColorSyntax() || this.colorMix(); + } catch { + SUPPORTS_MODERN_COLOR = false; + } + + return SUPPORTS_MODERN_COLOR; + }, +}; + +export const clearCache = () => { + SUPPORTS_RELATIVE_COLOR = undefined; + SUPPORTS_COLOR_MIX = undefined; + SUPPORTS_MODERN_COLOR = undefined; +}; 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; -} From a732143952efa8593baa6374f06a4650d3b2728c Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Tue, 8 Jul 2025 18:00:14 +0300 Subject: [PATCH 09/18] docs(repo): Clarify stable and canary release processes in documentation (#6206) --- docs/CICD.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/CICD.md b/docs/CICD.md index 92b25f309bd..7becad9b5b1 100644 --- a/docs/CICD.md +++ b/docs/CICD.md @@ -9,22 +9,23 @@ Every time a PR is merged into `main`, an automated canary release will happen. ## Stable releases -A stable release will be triggered every time the "ci(repo): Version packages" PR is merged. Once versioning and publishing is done, the `clerk/javascript` repo will dispatch a workflow event, notifying other related Clerk repos of the new releases. +A stable release will be triggered every time the "ci(repo): Version packages" PR is merged. Once the PR is merged, the following actions will take place: -Actions that will be triggered: +- All SDKs will be published to `npm`, except for those found in the excluded packages list in `.changeset/config.json`, or any packages with `private: true` set in their `package.json` file. +- A workflow dispatch will be triggered to update the `clerkjs-proxy` worker in `clerk/sdk-infra-workers`. +- A workflow dispatch will be triggered to update the `@clerk/nextjs` version in `clerk/dashboard`. +- A workflow dispatch will be triggered to update the generated docs in `clerk/generated-typedoc`. -- `clerk/cloudflare-workers`: The latest clerk-js versions in `clerkjs-proxy/wrangler.toml` will be updated a PR will open. Follow the instructions in the PR to manually release a new `clerkjs-proxy` worker. +For details regarding the package versioning/publishing process, refer to the [Publishing docs](https://github.com/clerk/javascript/blob/main/docs/PUBLISH.md). -For more details, refer to the [Publishing docs](https://github.com/clerk/javascript/blob/main/docs/PUBLISH.md). +Refer to the docs in the (private) `clerk/sdk-infra-workers` repo for more details about the `clerkjs-proxy` worker release process. ## Automated canary releases -A canary release will be triggered every time PR is merged into `main`. Once versioning and publishing is done, the `clerk/javascript` repo will dispatch a workflow event, notifying other related Clerk repos of the new releases. +A canary release will be triggered every time PR is merged into `main`. Every commit merged into main will trigger the following actions: -Actions that will be triggered: - -- `clerk/cloudflare-workers`: The latest clerk-js versions in `clerkjs-proxy/wrangler.toml` will be updated and directly committed to `main`. A second workflow will perform a canary release of the `clerkjs-proxy` worker. -- `clerk/accounts`: A new Accounts deployment will take place, using the most recent canary `@clerk/nextjs` version. This change will not be committed to `main`. +- A workflow dispatch will be triggered to update the `clerkjs-proxy` worker in `clerk/sdk-infra-workers`. +- The canary Accounts project will be deployed using the most recent `@canary` version of `@clerk/nextjs`. This happens for testing purposes. For more details about canary releases, refer to the [Publishing docs](https://github.com/clerk/javascript/blob/main/docs/PUBLISH.md). From 4ab82f7e112dc6e904a33ebf21d4d3ef4bb07319 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Tue, 8 Jul 2025 08:04:50 -0700 Subject: [PATCH 10/18] chore(clerk-js): Add permission checks to API keys component (#6253) --- .changeset/cuddly-kiwis-rush.md | 5 + .../tests/machine-auth/component.test.ts | 109 +++++++++++++++--- packages/clerk-js/bundlewatch.config.json | 2 +- packages/clerk-js/src/core/clerk.ts | 12 ++ packages/clerk-js/src/core/warnings.ts | 2 + .../src/ui/components/ApiKeys/ApiKeys.tsx | 31 +++-- .../ui/components/ApiKeys/ApiKeysTable.tsx | 28 +++-- .../src/ui/components/ApiKeys/useApiKeys.ts | 17 ++- .../OrganizationProfileRoutes.tsx | 24 ++-- .../src/ui/utils/createCustomPages.tsx | 3 +- .../clerk-js/src/utils/componentGuards.ts | 11 ++ 11 files changed, 194 insertions(+), 50 deletions(-) create mode 100644 .changeset/cuddly-kiwis-rush.md diff --git a/.changeset/cuddly-kiwis-rush.md b/.changeset/cuddly-kiwis-rush.md new file mode 100644 index 00000000000..0799f5a322b --- /dev/null +++ b/.changeset/cuddly-kiwis-rush.md @@ -0,0 +1,5 @@ +--- +"@clerk/clerk-js": minor +--- + +Added granular permission checks to `` component to support read-only and manage roles diff --git a/integration/tests/machine-auth/component.test.ts b/integration/tests/machine-auth/component.test.ts index e2d2c439dd2..f0f17f982a5 100644 --- a/integration/tests/machine-auth/component.test.ts +++ b/integration/tests/machine-auth/component.test.ts @@ -1,22 +1,25 @@ import { expect, test } from '@playwright/test'; import { appConfigs } from '../../presets'; -import type { FakeUser } from '../../testUtils'; +import type { FakeOrganization, FakeUser } from '../../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @generic', ({ app }) => { test.describe.configure({ mode: 'serial' }); - let fakeUser: FakeUser; + let fakeAdmin: FakeUser; + let fakeOrganization: FakeOrganization; test.beforeAll(async () => { const u = createTestUtils({ app }); - fakeUser = u.services.users.createFakeUser(); - await u.services.users.createBapiUser(fakeUser); + fakeAdmin = u.services.users.createFakeUser(); + const admin = await u.services.users.createBapiUser(fakeAdmin); + fakeOrganization = await u.services.users.createFakeOrganization(admin.id); }); test.afterAll(async () => { - await fakeUser.deleteIfExists(); + await fakeOrganization.delete(); + await fakeAdmin.deleteIfExists(); await app.teardown(); }); @@ -24,7 +27,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); await u.po.page.goToRelative('/api-keys'); @@ -33,7 +36,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge // Create API key 1 await u.po.apiKeys.clickAddButton(); await u.po.apiKeys.waitForFormOpened(); - await u.po.apiKeys.typeName(`${fakeUser.firstName}-api-key-1`); + await u.po.apiKeys.typeName(`${fakeAdmin.firstName}-api-key-1`); await u.po.apiKeys.selectExpiration('1d'); await u.po.apiKeys.clickSaveButton(); @@ -42,7 +45,7 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge // Create API key 2 await u.po.apiKeys.clickAddButton(); await u.po.apiKeys.waitForFormOpened(); - await u.po.apiKeys.typeName(`${fakeUser.firstName}-api-key-2`); + await u.po.apiKeys.typeName(`${fakeAdmin.firstName}-api-key-2`); await u.po.apiKeys.selectExpiration('7d'); await u.po.apiKeys.clickSaveButton(); @@ -54,13 +57,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); await u.po.page.goToRelative('/api-keys'); await u.po.apiKeys.waitForMounted(); - const apiKeyName = `${fakeUser.firstName}-${Date.now()}`; + const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`; // Create API key await u.po.apiKeys.clickAddButton(); @@ -95,13 +98,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); await u.po.page.goToRelative('/api-keys'); await u.po.apiKeys.waitForMounted(); - const apiKeyName = `${fakeUser.firstName}-${Date.now()}`; + const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`; // Create API key await u.po.apiKeys.clickAddButton(); @@ -133,13 +136,13 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge const u = createTestUtils({ app, page, context }); await u.po.signIn.goTo(); await u.po.signIn.waitForMounted(); - await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); await u.po.expect.toBeSignedIn(); await u.po.page.goToRelative('/api-keys'); await u.po.apiKeys.waitForMounted(); - const apiKeyName = `${fakeUser.firstName}-${Date.now()}`; + const apiKeyName = `${fakeAdmin.firstName}-${Date.now()}`; // Create API key await u.po.apiKeys.clickAddButton(); @@ -169,4 +172,82 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withAPIKeys] })('api keys @ge await row.locator('.cl-apiKeysRevealButton').click(); await expect(row.locator('input')).toHaveAttribute('type', 'password'); }); + + test('component does not render for orgs when user does not have permissions', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const fakeMember = u.services.users.createFakeUser(); + const member = await u.services.users.createBapiUser(fakeMember); + + await u.services.clerk.organizations.createOrganizationMembership({ + organizationId: fakeOrganization.organization.id, + role: 'org:member', + userId: member.id, + }); + + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeMember.email, password: fakeMember.password }); + await u.po.expect.toBeSignedIn(); + + let apiKeysRequestWasMade = false; + u.page.on('request', request => { + if (request.url().includes('/api_keys')) { + apiKeysRequestWasMade = true; + } + }); + + // Check that standalone component is not rendered + await u.po.page.goToRelative('/api-keys'); + await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 }); + + // Check that page is not rendered in OrganizationProfile + await u.po.page.goToRelative('/organization-profile#/organization-api-keys'); + await expect(u.page.locator('.cl-apiKeys-root')).toBeHidden({ timeout: 1000 }); + + expect(apiKeysRequestWasMade).toBe(false); + + await fakeMember.deleteIfExists(); + }); + + test('user with read permission can view API keys but not manage them', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + const fakeViewer = u.services.users.createFakeUser(); + const viewer = await u.services.users.createBapiUser(fakeViewer); + + await u.services.clerk.organizations.createOrganizationMembership({ + organizationId: fakeOrganization.organization.id, + role: 'org:viewer', + userId: viewer.id, + }); + + await u.po.signIn.goTo(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeViewer.email, password: fakeViewer.password }); + await u.po.expect.toBeSignedIn(); + + let apiKeysRequestWasMade = false; + u.page.on('request', request => { + if (request.url().includes('/api_keys')) { + apiKeysRequestWasMade = true; + } + }); + + // Check that standalone component is rendered and user can read API keys + await u.po.page.goToRelative('/api-keys'); + await u.po.apiKeys.waitForMounted(); + await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden(); + await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden(); + + // Check that page is rendered in OrganizationProfile and user can read API keys + await u.po.page.goToRelative('/organization-profile#/organization-api-keys'); + await expect(u.page.locator('.cl-apiKeys')).toBeVisible(); + await expect(u.page.getByRole('button', { name: /Add new key/i })).toBeHidden(); + await expect(u.page.getByRole('columnheader', { name: /Actions/i })).toBeHidden(); + + expect(apiKeysRequestWasMade).toBe(true); + + await fakeViewer.deleteIfExists(); + }); }); diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 09c556354dd..3edd92caf01 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "111.8KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "111.9KB" }, { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "113.67KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 5374806db9c..763f1f78ec5 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -86,6 +86,7 @@ import type { MountComponentRenderer } from '../ui/Components'; import { ALLOWED_PROTOCOLS, buildURL, + canViewOrManageAPIKeys, completeSignUpFlow, createAllowedRedirectOrigins, createBeforeUnloadTracker, @@ -168,6 +169,7 @@ const CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE = 'cannot_render_organizat const CANNOT_RENDER_ORGANIZATION_MISSING_ERROR_CODE = 'cannot_render_organization_missing'; const CANNOT_RENDER_SINGLE_SESSION_ENABLED_ERROR_CODE = 'cannot_render_single_session_enabled'; const CANNOT_RENDER_API_KEYS_DISABLED_ERROR_CODE = 'cannot_render_api_keys_disabled'; +const CANNOT_RENDER_API_KEYS_ORG_UNAUTHORIZED_ERROR_CODE = 'cannot_render_api_keys_org_unauthorized'; const defaultOptions: ClerkOptions = { polling: true, standardBrowser: true, @@ -1099,6 +1101,16 @@ export class Clerk implements ClerkInterface { } return; } + + if (this.organization && !canViewOrManageAPIKeys(this)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotRenderAPIKeysComponentForOrgWhenUnauthorized, { + code: CANNOT_RENDER_API_KEYS_ORG_UNAUTHORIZED_ERROR_CODE, + }); + } + return; + } + void this.#componentControls.ensureMounted({ preloadHint: 'APIKeys' }).then(controls => controls.mountComponent({ name: 'APIKeys', diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index 2af87837fec..7afd2e4c493 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -40,6 +40,8 @@ const warnings = { 'The SignIn or SignUp modals do not render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, this is no-op.', cannotRenderAPIKeysComponent: 'The component cannot be rendered when API keys is disabled. Since API keys is disabled, this is no-op.', + cannotRenderAPIKeysComponentForOrgWhenUnauthorized: + 'The component cannot be rendered for an organization unless a user has the required permissions. Since the user does not have the necessary permissions, this is no-op.', }; type SerializableWarnings = Serializable; diff --git a/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx b/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx index 95d2aaf2c05..37b25301659 100644 --- a/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx +++ b/packages/clerk-js/src/ui/components/ApiKeys/ApiKeys.tsx @@ -4,6 +4,7 @@ import type { CreateAPIKeyParams } from '@clerk/types'; import { lazy, useState } from 'react'; import useSWRMutation from 'swr/mutation'; +import { useProtect } from '@/ui/common'; import { useApiKeysContext, withCoreUserGuard } from '@/ui/contexts'; import { Box, @@ -22,6 +23,7 @@ import { InputWithIcon } from '@/ui/elements/InputWithIcon'; import { Pagination } from '@/ui/elements/Pagination'; import { MagnifyingGlass } from '@/ui/icons'; import { mqu } from '@/ui/styledSystem'; +import { isOrganizationId } from '@/utils'; import { ApiKeysTable } from './ApiKeysTable'; import type { OnCreateParams } from './CreateApiKeyForm'; @@ -41,6 +43,10 @@ const RevokeAPIKeyConfirmationModal = lazy(() => ); export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPageProps) => { + const isOrg = isOrganizationId(subject); + const canReadAPIKeys = useProtect({ permission: 'org:sys_api_keys:read' }); + const canManageAPIKeys = useProtect({ permission: 'org:sys_api_keys:manage' }); + const { apiKeys, isLoading, @@ -53,7 +59,7 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr startingRow, endingRow, cacheKey, - } = useApiKeys({ subject, perPage }); + } = useApiKeys({ subject, perPage, enabled: isOrg ? canReadAPIKeys : true }); const card = useCardState(); const { trigger: createApiKey, isMutating } = useSWRMutation(cacheKey, (_, { arg }: { arg: CreateAPIKeyParams }) => clerk.apiKeys.create(arg), @@ -118,16 +124,18 @@ export const APIKeysPage = ({ subject, perPage, revokeModalRoot }: APIKeysPagePr elementDescriptor={descriptors.apiKeysSearchInput} /> - -