diff --git a/.changeset/heavy-keys-bow.md b/.changeset/heavy-keys-bow.md new file mode 100644 index 00000000000..e865c6f65f5 --- /dev/null +++ b/.changeset/heavy-keys-bow.md @@ -0,0 +1,5 @@ +--- +'@clerk/themes': minor +--- + +Add shadcn theme to @clerk/themes diff --git a/.changeset/ready-hats-vanish.md b/.changeset/ready-hats-vanish.md new file mode 100644 index 00000000000..39a78c2c8f1 --- /dev/null +++ b/.changeset/ready-hats-vanish.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/themes': minor +'@clerk/types': minor +--- + +Add optional `cssLayerName` to `BaseTheme` object diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 2439952460e..b28fa0670dd 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -112,6 +112,7 @@ import { isRedirectForFAPIInitiatedFlow, noOrganizationExists, noUserExists, + processCssLayerNameExtraction, removeClerkQueryParam, requiresUserInput, sessionExistsAndSingleSessionModeEnabled, @@ -2731,9 +2732,16 @@ export class Clerk implements ClerkInterface { }; #initOptions = (options?: ClerkOptions): ClerkOptions => { + const processedOptions = options ? { ...options } : {}; + + // Extract cssLayerName from baseTheme if present and move it to appearance level + if (processedOptions.appearance) { + processedOptions.appearance = processCssLayerNameExtraction(processedOptions.appearance); + } + return { ...defaultOptions, - ...options, + ...processedOptions, allowedRedirectOrigins: createAllowedRedirectOrigins( options?.allowedRedirectOrigins, this.frontendApi, diff --git a/packages/clerk-js/src/utils/__tests__/appearance.spec.ts b/packages/clerk-js/src/utils/__tests__/appearance.spec.ts new file mode 100644 index 00000000000..ff120ed96b7 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/appearance.spec.ts @@ -0,0 +1,261 @@ +import type { Appearance, BaseTheme } from '@clerk/types'; +import { describe, expect, it } from 'vitest'; + +import { processCssLayerNameExtraction } from '../appearance'; + +describe('processCssLayerNameExtraction', () => { + it('extracts cssLayerName from single baseTheme and moves it to appearance level', () => { + const appearance: Appearance = { + baseTheme: { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme-layer', + }, + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('theme-layer'); + expect(result?.baseTheme).toBeDefined(); + if (result?.baseTheme && !Array.isArray(result.baseTheme)) { + expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + expect(result.baseTheme.__type).toBe('prebuilt_appearance'); + } + }); + + it('preserves appearance-level cssLayerName over baseTheme cssLayerName', () => { + const appearance: Appearance = { + cssLayerName: 'appearance-layer', + baseTheme: { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme-layer', + }, + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('appearance-layer'); + if (result?.baseTheme && !Array.isArray(result.baseTheme)) { + expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + } + }); + + it('extracts cssLayerName from first theme in array that has one', () => { + const appearance: Appearance = { + baseTheme: [ + { + __type: 'prebuilt_appearance' as const, + }, + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'first-layer', + }, + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'second-layer', + }, + ], + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('first-layer'); + expect(result?.baseTheme).toBeDefined(); + if (result?.baseTheme && Array.isArray(result.baseTheme)) { + expect(result.baseTheme).toHaveLength(3); + expect((result.baseTheme[0] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + expect((result.baseTheme[1] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + expect((result.baseTheme[2] as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + result.baseTheme.forEach(theme => { + expect(theme.__type).toBe('prebuilt_appearance'); + }); + } + }); + + it('preserves appearance-level cssLayerName over array baseTheme cssLayerName', () => { + const appearance: Appearance = { + cssLayerName: 'appearance-layer', + baseTheme: [ + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme1-layer', + }, + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme2-layer', + }, + ], + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('appearance-layer'); + if (result?.baseTheme && Array.isArray(result.baseTheme)) { + result.baseTheme.forEach(theme => { + expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + }); + } + }); + + it('handles single baseTheme without cssLayerName', () => { + const appearance: Appearance = { + baseTheme: { + __type: 'prebuilt_appearance' as const, + }, + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBeUndefined(); + if (result?.baseTheme && !Array.isArray(result.baseTheme)) { + expect(result.baseTheme.__type).toBe('prebuilt_appearance'); + expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + } + }); + + it('handles array of baseThemes without any cssLayerName', () => { + const appearance: Appearance = { + baseTheme: [ + { + __type: 'prebuilt_appearance' as const, + }, + { + __type: 'prebuilt_appearance' as const, + }, + ], + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBeUndefined(); + if (result?.baseTheme && Array.isArray(result.baseTheme)) { + expect(result.baseTheme).toHaveLength(2); + result.baseTheme.forEach(theme => { + expect(theme.__type).toBe('prebuilt_appearance'); + expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + }); + } + }); + + it('handles no baseTheme provided', () => { + const appearance: Appearance = { + cssLayerName: 'standalone-layer', + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('standalone-layer'); + expect(result?.baseTheme).toBeUndefined(); + }); + + it('handles undefined appearance', () => { + const result = processCssLayerNameExtraction(undefined); + + expect(result).toBeUndefined(); + }); + + it('preserves other appearance properties', () => { + const appearance: Appearance = { + variables: { colorPrimary: 'blue' }, + baseTheme: { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme-layer', + }, + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('theme-layer'); + expect(result?.variables?.colorPrimary).toBe('blue'); + if (result?.baseTheme && !Array.isArray(result.baseTheme)) { + expect((result.baseTheme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + } + }); + + it('handles empty baseTheme array', () => { + const appearance: Appearance = { + baseTheme: [], + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBeUndefined(); + expect(result?.baseTheme).toEqual([]); + expect(Array.isArray(result?.baseTheme)).toBe(true); + }); + + it('uses first valid cssLayerName from mixed array when appearance.cssLayerName is absent', () => { + const appearance: Appearance = { + baseTheme: [ + { + __type: 'prebuilt_appearance' as const, + // No cssLayerName in first theme + }, + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'second-theme-layer', + }, + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'third-theme-layer', + }, + ], + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('second-theme-layer'); + expect(Array.isArray(result?.baseTheme)).toBe(true); + if (Array.isArray(result?.baseTheme)) { + expect(result.baseTheme).toHaveLength(3); + // Check that cssLayerName was removed from all themes + result.baseTheme.forEach(theme => { + expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + }); + } + }); + + it('preserves appearance.cssLayerName over baseTheme array cssLayerName', () => { + const appearance: Appearance = { + cssLayerName: 'appearance-level-layer', + baseTheme: [ + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme-layer-1', + }, + { + __type: 'prebuilt_appearance' as const, + cssLayerName: 'theme-layer-2', + }, + ], + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBe('appearance-level-layer'); + expect(Array.isArray(result?.baseTheme)).toBe(true); + if (Array.isArray(result?.baseTheme)) { + expect(result.baseTheme).toHaveLength(2); + // Check that cssLayerName was removed from all themes + result.baseTheme.forEach(theme => { + expect((theme as BaseTheme & { cssLayerName?: string }).cssLayerName).toBeUndefined(); + }); + } + }); + + it('returns single theme unchanged when it has no cssLayerName', () => { + const appearance: Appearance = { + baseTheme: { + __type: 'prebuilt_appearance' as const, + // No cssLayerName property + }, + }; + + const result = processCssLayerNameExtraction(appearance); + + expect(result?.cssLayerName).toBeUndefined(); + expect(result?.baseTheme).toEqual({ + __type: 'prebuilt_appearance', + }); + expect(Array.isArray(result?.baseTheme)).toBe(false); + }); +}); diff --git a/packages/clerk-js/src/utils/appearance.ts b/packages/clerk-js/src/utils/appearance.ts new file mode 100644 index 00000000000..5aae032072b --- /dev/null +++ b/packages/clerk-js/src/utils/appearance.ts @@ -0,0 +1,67 @@ +import type { Appearance, BaseTheme } from '@clerk/types'; + +/** + * Extracts cssLayerName from baseTheme and moves it to appearance level. + * This is a pure function that can be tested independently. + */ +export function processCssLayerNameExtraction(appearance: Appearance | undefined): Appearance | undefined { + if (!appearance || typeof appearance !== 'object' || !('baseTheme' in appearance) || !appearance.baseTheme) { + return appearance; + } + + let cssLayerNameFromBaseTheme: string | undefined; + + if (Array.isArray(appearance.baseTheme)) { + // Handle array of themes - extract cssLayerName from each and use the first one found + appearance.baseTheme.forEach((theme: BaseTheme) => { + if (!cssLayerNameFromBaseTheme && theme.cssLayerName) { + cssLayerNameFromBaseTheme = theme.cssLayerName; + } + }); + + // Create array without cssLayerName properties + const processedBaseThemeArray = appearance.baseTheme.map((theme: BaseTheme) => { + const { cssLayerName, ...rest } = theme; + return rest; + }); + + // Use existing cssLayerName at appearance level, or fall back to one from baseTheme(s) + const finalCssLayerName = appearance.cssLayerName || cssLayerNameFromBaseTheme; + + const result = { + ...appearance, + baseTheme: processedBaseThemeArray, + }; + + if (finalCssLayerName) { + result.cssLayerName = finalCssLayerName; + } + + return result; + } else { + // Handle single theme + const singleTheme = appearance.baseTheme; + let cssLayerNameFromSingleTheme: string | undefined; + + if (singleTheme.cssLayerName) { + cssLayerNameFromSingleTheme = singleTheme.cssLayerName; + } + + // Create new theme without cssLayerName + const { cssLayerName, ...processedBaseTheme } = singleTheme; + + // Use existing cssLayerName at appearance level, or fall back to one from baseTheme + const finalCssLayerName = appearance.cssLayerName || cssLayerNameFromSingleTheme; + + const result = { + ...appearance, + baseTheme: processedBaseTheme, + }; + + if (finalCssLayerName) { + result.cssLayerName = finalCssLayerName; + } + + return result; + } +} diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index b3999d638b1..99f3c68eaae 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './beforeUnloadTracker'; +export * from './appearance'; export * from './commerce'; export * from './completeSignUpFlow'; export * from './componentGuards'; diff --git a/packages/themes/src/createTheme.ts b/packages/themes/src/createTheme.ts index 55c99b06995..2c5e86f844e 100644 --- a/packages/themes/src/createTheme.ts +++ b/packages/themes/src/createTheme.ts @@ -13,5 +13,8 @@ interface CreateClerkThemeParams extends DeepPartial { export const experimental_createTheme = (appearance: Appearance): BaseTheme => { // Placeholder method that might hande more transformations in the future - return { ...appearance, __type: 'prebuilt_appearance' }; + return { + ...appearance, + __type: 'prebuilt_appearance', + }; }; diff --git a/packages/themes/src/themes/index.ts b/packages/themes/src/themes/index.ts index 70671673707..b57a2cb704d 100644 --- a/packages/themes/src/themes/index.ts +++ b/packages/themes/src/themes/index.ts @@ -1,4 +1,5 @@ export * from './dark'; export * from './shadesOfPurple'; export * from './neobrutalism'; +export * from './shadcn'; export * from './simple'; diff --git a/packages/themes/src/themes/shadcn.ts b/packages/themes/src/themes/shadcn.ts new file mode 100644 index 00000000000..fdae113713e --- /dev/null +++ b/packages/themes/src/themes/shadcn.ts @@ -0,0 +1,35 @@ +import { experimental_createTheme } from '../createTheme'; + +export const shadcn = experimental_createTheme({ + cssLayerName: 'components', + variables: { + colorBackground: 'var(--card)', + colorDanger: 'var(--destructive)', + colorForeground: 'var(--card-foreground)', + colorInput: 'var(--input)', + colorInputForeground: 'var(--card-foreground)', + colorModalBackdrop: 'var(--color-black)', + colorMuted: 'var(--muted)', + colorMutedForeground: 'var(--muted-foreground)', + colorNeutral: 'var(--foreground)', + colorPrimary: 'var(--primary)', + colorPrimaryForeground: 'var(--primary-foreground)', + colorRing: 'var(--ring)', + fontWeight: { + normal: 'var(--font-weight-normal)', + medium: 'var(--font-weight-medium)', + semibold: 'var(--font-weight-semibold)', + bold: 'var(--font-weight-semibold)', + }, + }, + elements: { + input: 'bg-transparent dark:bg-input/30', + cardBox: 'shadow-sm border', + popoverBox: 'shadow-sm border', + button: { + '&[data-variant="solid"]::after': { + display: 'none', + }, + }, + }, +}); diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index c9448c313ad..4e39e02ebe8 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -806,7 +806,7 @@ export type Variables = { }; export type BaseThemeTaggedType = { __type: 'prebuilt_appearance' }; -export type BaseTheme = BaseThemeTaggedType; +export type BaseTheme = BaseThemeTaggedType & { cssLayerName?: string }; export type Theme = { /**