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 = { /** pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy