diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 5b687a17..11c056ca 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -56,9 +56,12 @@ export * from '@asgardeo/javascript'; export { detectThemeMode, createClassObserver, + createDataAttributeObserver, + createAutoObserver, createMediaQueryListener, - BrowserThemeDetection, } from './theme/themeDetection'; + +export type {BrowserThemeDetection} from './theme/themeDetection'; export {default as getActiveTheme} from './theme/getActiveTheme'; export {default as handleWebAuthnAuthentication} from './utils/handleWebAuthnAuthentication'; diff --git a/packages/browser/src/theme/getActiveTheme.ts b/packages/browser/src/theme/getActiveTheme.ts index cc1e85a6..b509a777 100644 --- a/packages/browser/src/theme/getActiveTheme.ts +++ b/packages/browser/src/theme/getActiveTheme.ts @@ -43,7 +43,7 @@ const getActiveTheme = (mode: ThemeMode, config: BrowserThemeDetection = {}): Th return DEFAULT_THEME; } - if (mode === 'class') { + if (mode === 'auto') { return detectThemeMode(mode, config); } diff --git a/packages/browser/src/theme/themeDetection.ts b/packages/browser/src/theme/themeDetection.ts index 5c47329e..af7271ea 100644 --- a/packages/browser/src/theme/themeDetection.ts +++ b/packages/browser/src/theme/themeDetection.ts @@ -16,62 +16,130 @@ * under the License. */ -import {ThemeDetection, ThemeMode} from '@asgardeo/javascript'; +import {ThemeDetection, ThemeMode, ThemeDetectionStrategy, DEFAULT_THEME} from '@asgardeo/javascript'; /** * Extended theme detection config that includes DOM-specific options */ export interface BrowserThemeDetection extends ThemeDetection { /** - * The element to observe for class changes + * The element to observe for changes (class or data attribute) * @default document.documentElement (html element) */ targetElement?: HTMLElement; } +/** + * Detects theme from data attribute + */ +const detectFromDataAttribute = ( + targetElement: HTMLElement, + config: BrowserThemeDetection, +): 'light' | 'dark' | null => { + const attrName: string = config.dataAttribute?.name || 'theme'; + const lightValue: string = config.dataAttribute?.values?.light || 'light'; + const darkValue: string = config.dataAttribute?.values?.dark || 'dark'; + + const attrValue: string | null = targetElement.getAttribute(`data-${attrName}`); + + if (attrValue === darkValue) return 'dark'; + if (attrValue === lightValue) return 'light'; + + return null; +}; + +/** + * Detects theme from CSS classes + */ +const detectFromClasses = (targetElement: HTMLElement, config: BrowserThemeDetection): 'light' | 'dark' | null => { + const darkClass: string = config.classes?.dark || 'dark'; + const lightClass: string = config.classes?.light || 'light'; + + const classList: DOMTokenList = targetElement.classList; + + if (classList.contains(darkClass)) return 'dark'; + if (classList.contains(lightClass)) return 'light'; + + return null; +}; + +/** + * Detects theme from system preference + */ +const detectFromSystem = (): ThemeMode => { + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + return DEFAULT_THEME; +}; + /** * Detects the current theme mode based on the specified method */ -export const detectThemeMode = (mode: ThemeMode, config: BrowserThemeDetection = {}): 'light' | 'dark' => { - const { - darkClass = 'dark', - lightClass = 'light', - targetElement = typeof document !== 'undefined' ? document.documentElement : null, - } = config; +export const detectThemeMode = (mode: ThemeMode, config: BrowserThemeDetection = {}): ThemeMode => { + const targetElement: HTMLElement | null = (config.targetElement || + (typeof document !== 'undefined' ? document.documentElement : null)) as HTMLElement | null; if (mode === 'light') return 'light'; if (mode === 'dark') return 'dark'; if (mode === 'system') { - if (typeof window !== 'undefined' && window.matchMedia) { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - } - return 'light'; + return detectFromSystem(); } - if (mode === 'class') { + if (mode === 'auto') { if (!targetElement) { - console.warn('ThemeDetection: targetElement is required for class-based detection, falling back to light mode'); - return 'light'; + console.warn('ThemeDetection: targetElement is required for auto detection, falling back to system'); + return detectFromSystem(); } - const classList = targetElement.classList; + // Get strategy and priority + // For legacy 'class' mode, default to 'class' strategy + const strategy: ThemeDetectionStrategy = config.strategy || 'auto'; + const priority: ThemeDetectionStrategy[] = config.priority || ['dataAttribute', 'class', 'system']; - // Check for explicit dark class first - if (classList.contains(darkClass)) { - return 'dark'; - } + if (strategy === 'auto') { + // Try detection methods in priority order + for (const method of priority) { + let result: ThemeMode | null = null; - // Check for explicit light class - if (classList.contains(lightClass)) { - return 'light'; - } + switch (method) { + case 'dataAttribute': + result = detectFromDataAttribute(targetElement, config); + break; + case 'class': + result = detectFromClasses(targetElement, config); + break; + case 'system': + result = detectFromSystem(); + break; + } - // If neither class is present, default to light - return 'light'; + if (result !== null) { + return result; + } + } + + // Fallback to light if nothing detected + return DEFAULT_THEME; + } else { + // Use specific strategy + switch (strategy) { + case 'dataAttribute': + return detectFromDataAttribute(targetElement, config) || DEFAULT_THEME; + case 'class': + return detectFromClasses(targetElement, config) || DEFAULT_THEME; + case 'system': + return detectFromSystem(); + case 'manual': + default: + return DEFAULT_THEME; + } + } } - return 'light'; + return DEFAULT_THEME; }; /** @@ -82,12 +150,13 @@ export const createClassObserver = ( callback: (isDark: boolean) => void, config: BrowserThemeDetection = {}, ): MutationObserver => { - const {darkClass = 'dark', lightClass = 'light'} = config; + const darkClass: string = config.classes?.dark || 'dark'; + const lightClass: string = config.classes?.light || 'light'; - const observer = new MutationObserver(mutations => { + const observer: MutationObserver = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { - const classList = targetElement.classList; + const classList: DOMTokenList = targetElement.classList; if (classList.contains(darkClass)) { callback(true); @@ -108,6 +177,72 @@ export const createClassObserver = ( return observer; }; +/** + * Creates a MutationObserver to watch for data attribute changes on the target element + */ +export const createDataAttributeObserver = ( + targetElement: HTMLElement, + callback: (isDark: boolean) => void, + config: BrowserThemeDetection = {}, +): MutationObserver => { + const attrName: string = config.dataAttribute?.name || 'theme'; + const lightValue: string = config.dataAttribute?.values?.light || 'light'; + const darkValue: string = config.dataAttribute?.values?.dark || 'dark'; + + const observer: MutationObserver = new MutationObserver(mutations => { + mutations.forEach(mutation => { + if (mutation.type === 'attributes' && mutation.attributeName === `data-${attrName}`) { + const attrValue: string | null = targetElement.getAttribute(`data-${attrName}`); + + if (attrValue === darkValue) { + callback(true); + } else if (attrValue === lightValue) { + callback(false); + } + // If the value doesn't match expected values, don't trigger callback + } + }); + }); + + observer.observe(targetElement, { + attributes: true, + attributeFilter: [`data-${attrName}`], + }); + + return observer; +}; + +/** + * Creates observers for auto-detection based on priority + */ +export const createAutoObserver = ( + targetElement: HTMLElement, + callback: (isDark: boolean) => void, + config: BrowserThemeDetection = {}, +): {observers: MutationObserver[]; mediaQuery: MediaQueryList | null} => { + const priority: ThemeDetectionStrategy[] = config.priority || ['dataAttribute', 'class', 'system']; + const observers: MutationObserver[] = []; + let mediaQuery: MediaQueryList | null = null; + + for (const strategy of priority) { + switch (strategy) { + case 'dataAttribute': + observers.push(createDataAttributeObserver(targetElement, callback, config)); + break; + case 'class': + observers.push(createClassObserver(targetElement, callback, config)); + break; + case 'system': + if (!mediaQuery) { + mediaQuery = createMediaQueryListener(callback); + } + break; + } + } + + return {observers, mediaQuery}; +}; + /** * Creates a media query listener for system theme changes */ @@ -116,7 +251,7 @@ export const createMediaQueryListener = (callback: (isDark: boolean) => void): M return null; } - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const mediaQuery: MediaQueryList = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = (e: MediaQueryListEvent) => { callback(e.matches); diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index e92a4b48..a8166d5a 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -146,7 +146,7 @@ export {FieldType} from './models/field'; export {default as AsgardeoJavaScriptClient} from './AsgardeoJavaScriptClient'; export {default as createTheme, DEFAULT_THEME} from './theme/createTheme'; -export {ThemeColors, ThemeConfig, Theme, ThemeMode, ThemeDetection} from './theme/types'; +export {ThemeColors, ThemeConfig, Theme, ThemeMode, ThemeDetection, ThemeDetectionStrategy} from './theme/types'; export {default as arrayBufferToBase64url} from './utils/arrayBufferToBase64url'; export {default as base64urlToArrayBuffer} from './utils/base64urlToArrayBuffer'; diff --git a/packages/javascript/src/theme/createTheme.ts b/packages/javascript/src/theme/createTheme.ts index f1f91799..7a1c1007 100644 --- a/packages/javascript/src/theme/createTheme.ts +++ b/packages/javascript/src/theme/createTheme.ts @@ -580,7 +580,12 @@ const toThemeVars = (theme: ThemeConfig): ThemeVars => { return themeVars; }; -const createTheme = (config: RecursivePartial = {}, isDark = false): Theme => { +/** + * Theme configuration for createTheme function (excludes runtime detection properties) + */ +type CreateThemeConfig = Omit; + +const createTheme = (config: RecursivePartial = {}, isDark = false): Theme => { const baseTheme = isDark ? darkTheme : lightTheme; const mergedConfig = { diff --git a/packages/javascript/src/theme/types.ts b/packages/javascript/src/theme/types.ts index 9e12a581..f165a1f0 100644 --- a/packages/javascript/src/theme/types.ts +++ b/packages/javascript/src/theme/types.ts @@ -198,6 +198,15 @@ export interface ThemeConfig { * Component style overrides */ components?: ThemeComponents; + /** + * Theme mode configuration + * @default 'auto' (automatically detects theme from data attributes, classes, or system preference) + */ + mode?: ThemeMode; + /** + * Theme detection configuration (used when mode is 'auto' or 'system') + */ + detection?: ThemeDetection; } export interface ThemeComponentVars { @@ -349,19 +358,66 @@ export interface Theme extends ThemeConfig { vars: ThemeVars; } -export type ThemeMode = 'light' | 'dark' | 'system' | 'class'; +export type ThemeMode = 'light' | 'dark' | 'system' | 'auto'; + +export type ThemeDetectionStrategy = 'system' | 'class' | 'dataAttribute' | 'manual' | 'auto'; export interface ThemeDetection { /** - * The CSS class name to detect for dark mode (without the dot) - * @default 'dark' + * How to detect the current theme when mode is 'auto' + * @default 'auto' (tries dataAttribute -> class -> system in that order) + */ + strategy?: ThemeDetectionStrategy; + /** + * The target element to observe for theme changes + * @default document.documentElement (html element) + */ + targetElement?: HTMLElement | null; + /** + * CSS class configuration (for 'class' strategy) + */ + classes?: { + /** + * The CSS class name to detect for dark mode (without the dot) + * @default 'dark' + */ + dark?: string; + /** + * The CSS class name to detect for light mode (without the dot) + * @default 'light' + */ + light?: string; + }; + /** + * Data attribute configuration (for 'dataAttribute' strategy) */ - darkClass?: string; + dataAttribute?: { + /** + * The data attribute name to observe (without 'data-' prefix) + * @default 'theme' (observes data-theme attribute) + */ + name?: string; + /** + * Value mapping for theme modes + */ + values?: { + /** + * Data attribute value for light theme + * @default 'light' + */ + light?: string; + /** + * Data attribute value for dark theme + * @default 'dark' + */ + dark?: string; + }; + }; /** - * The CSS class name to detect for light mode (without the dot) - * @default 'light' + * Detection priority when strategy is 'auto' + * @default ['dataAttribute', 'class', 'system'] */ - lightClass?: string; + priority?: ThemeDetectionStrategy[]; } export interface ThemeImage { diff --git a/packages/react/src/contexts/Theme/ThemeContext.ts b/packages/react/src/contexts/Theme/ThemeContext.ts index 5c6688e7..1eada0fb 100644 --- a/packages/react/src/contexts/Theme/ThemeContext.ts +++ b/packages/react/src/contexts/Theme/ThemeContext.ts @@ -17,11 +17,11 @@ */ import {createContext} from 'react'; -import {Theme} from '@asgardeo/browser'; +import {Theme, ThemeMode} from '@asgardeo/browser'; export interface ThemeContextValue { theme: Theme; - colorScheme: 'light' | 'dark'; + colorScheme: ThemeMode; /** * The text direction for the UI. */ diff --git a/packages/react/src/contexts/Theme/ThemeProvider.tsx b/packages/react/src/contexts/Theme/ThemeProvider.tsx index 2ec48309..396639e5 100644 --- a/packages/react/src/contexts/Theme/ThemeProvider.tsx +++ b/packages/react/src/contexts/Theme/ThemeProvider.tsx @@ -25,6 +25,8 @@ import { RecursivePartial, detectThemeMode, createClassObserver, + createDataAttributeObserver, + createAutoObserver, createMediaQueryListener, BrowserThemeDetection, ThemePreferences, @@ -40,10 +42,8 @@ export interface ThemeProviderProps { * - 'light': Always use light theme * - 'dark': Always use dark theme * - 'system': Use system preference (prefers-color-scheme media query) - * - 'class': Detect theme based on CSS classes on HTML element - * - 'branding': Use active theme from branding preference (requires inheritFromBranding=true) */ - mode?: ThemeMode | 'branding'; + mode?: ThemeMode; /** * Configuration for theme detection when using 'class' or 'system' mode */ @@ -110,20 +110,17 @@ const applyThemeToDOM = (theme: Theme) => { const ThemeProvider: FC> = ({ children, theme: themeConfig, - mode = DEFAULT_THEME, + mode = 'auto', detection = {}, inheritFromBranding = true, }: PropsWithChildren): ReactElement => { - const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(() => { + const [colorScheme, setColorScheme] = useState(() => { // Initialize with detected theme mode or fallback to defaultMode if (mode === 'light' || mode === 'dark') { return mode; } - // For 'branding' mode, start with system preference and update when branding loads - if (mode === 'branding') { - return detectThemeMode('system', detection); - } - return detectThemeMode(mode, detection); + + return detectThemeMode(mode as ThemeMode, detection); }); // Use branding theme if inheritFromBranding is enabled @@ -149,15 +146,12 @@ const ThemeProvider: FC> = ({ } } - // Update color scheme based on branding active theme when available + /** + * Update color scheme based on branding active theme when available + */ useEffect(() => { if (inheritFromBranding && brandingActiveTheme) { - // Update color scheme based on mode preference - if (mode === 'branding') { - // Always follow branding active theme - setColorScheme(brandingActiveTheme); - } else if (mode === 'system' && !isBrandingLoading) { - // For system mode, prefer branding but allow system override if no branding + if (!isBrandingLoading) { setColorScheme(brandingActiveTheme); } } @@ -225,18 +219,43 @@ const ThemeProvider: FC> = ({ }, []); useEffect(() => { - let observer: MutationObserver | null = null; + let observers: MutationObserver[] = []; let mediaQuery: MediaQueryList | null = null; - // Don't set up automatic theme detection for branding mode - if (mode === 'branding') { - return null; - } + const targetElement = (detection.targetElement || document.documentElement) as HTMLElement; - if (mode === 'class') { - const targetElement = detection.targetElement || document.documentElement; - if (targetElement) { - observer = createClassObserver(targetElement, handleThemeChange, detection); + if (mode === 'auto') { + // Auto-detection: try multiple strategies based on priority + const strategy = detection.strategy || 'auto'; + + if (strategy === 'auto') { + // Use createAutoObserver for multi-strategy detection + const {observers: autoObservers, mediaQuery: autoMediaQuery} = createAutoObserver( + targetElement, + handleThemeChange, + detection, + ); + observers = autoObservers; + mediaQuery = autoMediaQuery; + } else { + // Use specific strategy + switch (strategy) { + case 'dataAttribute': + if (targetElement) { + observers.push(createDataAttributeObserver(targetElement, handleThemeChange, detection)); + } + break; + case 'class': + if (targetElement) { + observers.push(createClassObserver(targetElement, handleThemeChange, detection)); + } + break; + case 'system': + if (!inheritFromBranding || !brandingActiveTheme) { + mediaQuery = createMediaQueryListener(handleThemeChange); + } + break; + } } } else if (mode === 'system') { // Only set up system listener if not using branding or branding hasn't loaded yet @@ -246,11 +265,15 @@ const ThemeProvider: FC> = ({ } return () => { - if (observer) { - observer.disconnect(); - } + // Clean up all observers + observers.forEach(observer => { + if (observer) { + observer.disconnect(); + } + }); + + // Clean up media query listener if (mediaQuery) { - // Clean up media query listener if (mediaQuery.removeEventListener) { mediaQuery.removeEventListener('change', handleThemeChange as any); } else {