Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion packages/browser/src/theme/getActiveTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const getActiveTheme = (mode: ThemeMode, config: BrowserThemeDetection = {}): Th
return DEFAULT_THEME;
}

if (mode === 'class') {
if (mode === 'auto') {
return detectThemeMode(mode, config);
}

Expand Down
197 changes: 166 additions & 31 deletions packages/browser/src/theme/themeDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

/**
Expand All @@ -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);
Expand All @@ -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
*/
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
7 changes: 6 additions & 1 deletion packages/javascript/src/theme/createTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,12 @@ const toThemeVars = (theme: ThemeConfig): ThemeVars => {
return themeVars;
};

const createTheme = (config: RecursivePartial<ThemeConfig> = {}, isDark = false): Theme => {
/**
* Theme configuration for createTheme function (excludes runtime detection properties)
*/
type CreateThemeConfig = Omit<ThemeConfig, 'mode' | 'detection'>;

const createTheme = (config: RecursivePartial<CreateThemeConfig> = {}, isDark = false): Theme => {
const baseTheme = isDark ? darkTheme : lightTheme;

const mergedConfig = {
Expand Down
70 changes: 63 additions & 7 deletions packages/javascript/src/theme/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading