diff --git a/core/src/components/content/content.tsx b/core/src/components/content/content.tsx index aef15c9c00c..63eccb0c6a2 100644 --- a/core/src/components/content/content.tsx +++ b/core/src/components/content/content.tsx @@ -444,7 +444,7 @@ export class Content implements ComponentInterface { const { fixedSlotPlacement, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this; const rtl = isRTL(el) ? 'rtl' : 'ltr'; const theme = getIonTheme(this); - const mode = getIonMode(this, theme); + const mode = getIonMode(this); const forceOverscroll = this.shouldForceOverscroll(mode); const transitionShadow = theme === 'ios'; diff --git a/core/src/components/nav/nav.tsx b/core/src/components/nav/nav.tsx index 45808d34b7c..c4624851276 100644 --- a/core/src/components/nav/nav.tsx +++ b/core/src/components/nav/nav.tsx @@ -908,7 +908,7 @@ export class Nav implements NavOutlet { } : undefined; const theme = getIonTheme(this); - const mode = getIonMode(this, theme); + const mode = getIonMode(this); const enteringEl = enteringView.element!; // eslint-disable-next-line @typescript-eslint/prefer-optional-chain const leavingEl = leavingView && leavingView.element!; diff --git a/core/src/global/ionic-global.ts b/core/src/global/ionic-global.ts index 3e90161070b..95e788aeb58 100644 --- a/core/src/global/ionic-global.ts +++ b/core/src/global/ionic-global.ts @@ -1,6 +1,6 @@ import { Build, getMode, setMode, getElement } from '@stencil/core'; import { printIonWarning } from '@utils/logging'; -import { applyGlobalTheme } from '@utils/theme'; +import { applyGlobalTheme, getCustomTheme } from '@utils/theme'; import type { IonicConfig, Mode, Theme } from '../interface'; import { defaultTheme as baseTheme } from '../themes/base/default.tokens'; @@ -13,60 +13,6 @@ import { config, configFromSession, configFromURL, saveConfig } from './config'; let defaultMode: Mode; let defaultTheme: Theme = 'md'; -/** - * Prints a warning message to the developer to inform them of - * an invalid configuration of mode and theme. - * @param mode The invalid mode configuration. - * @param theme The invalid theme configuration. - */ -const printInvalidModeWarning = (mode: Mode, theme: Theme, ref?: any) => { - printIonWarning( - `Invalid mode and theme combination provided: mode: ${mode}, theme: ${theme}. Fallback mode ${getDefaultModeForTheme( - theme - )} will be used.`, - ref - ); -}; - -/** - * Validates if a mode is accepted for a theme configuration. - * @param mode The mode to validate. - * @param theme The theme the mode is being used with. - * @returns `true` if the mode is valid for the theme, `false` if invalid. - */ -export const isModeValidForTheme = (mode: Mode, theme: Theme) => { - if (mode === 'md') { - return theme === 'md' || theme === 'ionic'; - } else if (mode === 'ios') { - return theme === 'ios' || theme === 'ionic'; - } - return false; -}; - -/** - * Returns the default mode for a specified theme. - * @param theme The theme to return a default mode for. - * @returns The default mode, either `ios` or `md`. - */ -const getDefaultModeForTheme = (theme: Theme): Mode => { - if (theme === 'ios') { - return 'ios'; - } - return 'md'; -}; - -/** - * Returns the default theme for a specified mode. - * @param mode The mode to return a default theme for. - * @returns The default theme. - */ -const getDefaultThemeForMode = (mode: Mode): Theme => { - if (mode === 'ios') { - return 'ios'; - } - return 'md'; -}; - const isModeSupported = (elmMode: string) => ['ios', 'md'].includes(elmMode); const isThemeSupported = (theme: string) => ['ios', 'md', 'ionic'].includes(theme); @@ -75,32 +21,13 @@ const isIonicElement = (elm: HTMLElement) => elm.tagName?.startsWith('ION-'); /** * Returns the mode value of the element reference or the closest - * parent with a valid mode. + * parent with a valid mode. If no mode is set, then fallback + * to the default mode. * @param ref The element reference to look up the mode for. - * @param theme Optionally can provide the theme to avoid an additional look-up. * @returns The mode value for the element reference. */ -export const getIonMode = (ref?: any, theme = getIonTheme(ref)): Mode => { - if (ref?.mode && isModeValidForTheme(ref?.mode, theme)) { - /** - * If the reference already has a mode configuration, - * use it instead of performing a look-up. - */ - return ref.mode; - } else { - const el = getElement(ref); - const mode = (el.closest('[mode]')?.getAttribute('mode') as Mode) || defaultMode; - - if (isModeValidForTheme(mode, theme)) { - /** - * The mode configuration is supported for the configured theme. - */ - return mode; - } else { - printInvalidModeWarning(mode, theme, ref); - } - } - return getDefaultModeForTheme(theme); +export const getIonMode = (ref?: any): Mode => { + return ref?.mode || (getElement(ref).closest('[mode]')?.getAttribute('mode') as Mode) || defaultMode; }; /** @@ -125,7 +52,7 @@ export const getIonTheme = (ref?: any): Theme => { const mode = ref?.mode ?? (el.closest('[mode]')?.getAttribute('mode') as Mode); if (mode) { - return getDefaultThemeForMode(mode); + return mode; } /** @@ -210,15 +137,7 @@ export const initialize = (userConfig: IonicConfig = {}) => { * otherwise get the theme via config settings, and fallback to md. */ - Ionic.theme = defaultTheme = config.get( - 'theme', - doc.documentElement.getAttribute('theme') || getDefaultThemeForMode(defaultMode) - ); - - if (!isModeValidForTheme(defaultMode, defaultTheme)) { - printInvalidModeWarning(defaultMode, defaultTheme, configObj); - defaultMode = getDefaultModeForTheme(defaultTheme); - } + Ionic.theme = defaultTheme = config.get('theme', doc.documentElement.getAttribute('theme') || defaultMode); config.set('mode', defaultMode); doc.documentElement.setAttribute('mode', defaultMode); @@ -228,7 +147,7 @@ export const initialize = (userConfig: IonicConfig = {}) => { doc.documentElement.setAttribute('theme', defaultTheme); doc.documentElement.classList.add(defaultTheme); - const customTheme: BaseTheme | undefined = configObj.customTheme; + const customTheme: BaseTheme | undefined = getCustomTheme(configObj.customTheme, defaultMode); // Apply base theme, or combine with custom theme if provided if (customTheme) { diff --git a/core/src/global/test/ionic-global.spec.ts b/core/src/global/test/ionic-global.spec.ts index f5d11b0360e..ba2a8e519ed 100644 --- a/core/src/global/test/ionic-global.spec.ts +++ b/core/src/global/test/ionic-global.spec.ts @@ -14,35 +14,9 @@ jest.mock('@stencil/core', () => { * The implementation needs to be mocked before the implementation is imported. */ // eslint-disable-next-line import/first -import { getIonTheme, isModeValidForTheme, getIonMode } from '../ionic-global'; +import { getIonTheme, getIonMode } from '../ionic-global'; describe('Ionic Global', () => { - describe('isModeValidForTheme', () => { - it('should return true for md mode with md theme', () => { - expect(isModeValidForTheme('md', 'md')).toBe(true); - }); - - it('should return true for md mode with ionic theme', () => { - expect(isModeValidForTheme('md', 'ionic')).toBe(true); - }); - - it('should return true for ios mode with ios theme', () => { - expect(isModeValidForTheme('ios', 'ios')).toBe(true); - }); - - it('should return true for ios mode with ionic theme', () => { - expect(isModeValidForTheme('ios', 'ionic')).toBe(true); - }); - - it('should return false for md mode with ios theme', () => { - expect(isModeValidForTheme('md', 'ios')).toBe(false); - }); - - it('should return false for ios mode with md theme', () => { - expect(isModeValidForTheme('ios', 'md')).toBe(false); - }); - }); - describe('getIonMode', () => { const parentRef = { mode: 'md' }; const ref = { parentElement: parentRef }; @@ -73,7 +47,7 @@ describe('Ionic Global', () => { }), })); - expect(getIonMode(ref, 'ios')).toBe('ios'); + expect(getIonMode(ref)).toBe('ios'); }); it('should return the mode value of the closest parent with a valid mode', () => { @@ -84,10 +58,9 @@ describe('Ionic Global', () => { expect(getIonMode()).toBe('md'); }); - it('should return the theme value if provided and no mode is found', () => { + it('should return the default theme if no mode is found', () => { const ref = { mode: undefined }; - const theme = 'ios'; - expect(getIonMode(ref, theme)).toBe('ios'); + expect(getIonMode(ref)).toBe('md'); }); }); diff --git a/core/src/utils/theme.spec.ts b/core/src/utils/theme.spec.ts index 22c64d2c01c..9a5fb6d38f3 100644 --- a/core/src/utils/theme.spec.ts +++ b/core/src/utils/theme.spec.ts @@ -3,7 +3,111 @@ import { newSpecPage } from '@stencil/core/testing'; import { CardContent } from '../components/card-content/card-content'; import { Chip } from '../components/chip/chip'; -import { generateComponentThemeCSS, generateCSSVars, generateGlobalThemeCSS, injectCSS } from './theme'; +import { generateComponentThemeCSS, generateCSSVars, generateGlobalThemeCSS, getCustomTheme, injectCSS } from './theme'; + +describe('getCustomTheme', () => { + const baseCustomTheme = { + radii: { + sm: '14px', + md: '18px', + lg: '22px', + }, + components: { + IonChip: { + hue: { + subtle: { + bg: 'red', + color: 'white', + }, + }, + }, + }, + }; + + const iosOverride = { + components: { + IonChip: { + hue: { + subtle: { + bg: 'blue', + }, + }, + }, + }, + }; + + const mdOverride = { + components: { + IonChip: { + hue: { + subtle: { + bg: 'green', + }, + }, + }, + }, + }; + + it('should return the custom theme if no mode overrides exist', () => { + const customTheme = { ...baseCustomTheme }; + + const result = getCustomTheme(customTheme, 'ios'); + + expect(result).toEqual(customTheme); + }); + + it('should combine only with ios overrides if mode is ios', () => { + const customTheme = { + ...baseCustomTheme, + ios: iosOverride, + md: mdOverride, + }; + + const result = getCustomTheme(customTheme, 'ios'); + + const expected = { + ...baseCustomTheme, + components: { + IonChip: { + hue: { + subtle: { + bg: 'blue', + color: 'white', + }, + }, + }, + }, + }; + + expect(result).toEqual(expected); + }); + + it('should combine only with md overrides if mode is md', () => { + const customTheme = { + ...baseCustomTheme, + ios: iosOverride, + md: mdOverride, + }; + + const result = getCustomTheme(customTheme, 'md'); + + const expected = { + ...baseCustomTheme, + components: { + IonChip: { + hue: { + subtle: { + bg: 'green', + color: 'white', + }, + }, + }, + }, + }; + + expect(result).toEqual(expected); + }); +}); describe('generateCSSVars', () => { it('should not generate CSS variables for an empty theme', () => { diff --git a/core/src/utils/theme.ts b/core/src/utils/theme.ts index 5c8ae845d10..d71fe43a385 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -39,6 +39,32 @@ export const getClassMap = (classes: string | string[] | undefined): CssClassMap return map; }; +/** + * Gets and merges custom themes based on mode + * @param customTheme The custom theme + * @param mode The current mode (ios | md) + * @returns The merged custom theme + */ +export const getCustomTheme = (customTheme: any, mode: string): any => { + if (!customTheme) return undefined; + + // Check if the custom theme contains mode overrides (ios | md) + if (customTheme.ios || customTheme.md) { + const { ios, md, ...baseCustomTheme } = customTheme; + + // Flatten the mode-specific overrides based on current mode + if (mode === 'ios' && ios) { + return deepMerge(baseCustomTheme, ios); + } else if (mode === 'md' && md) { + return deepMerge(baseCustomTheme, md); + } + + return baseCustomTheme; + } + + return customTheme; +}; + /** * Flattens the theme object into CSS custom properties * @param theme The theme object to flatten @@ -211,9 +237,10 @@ export const applyComponentTheme = (element: HTMLElement): void => { // Convert to 'IonChip' by capitalizing each part const themeLookupName = parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(''); - if (customTheme?.components?.[themeLookupName]) { - const componentTheme = customTheme.components[themeLookupName]; + // Get the component theme from the global custom theme if it exists + const componentTheme = customTheme?.components?.[themeLookupName]; + if (componentTheme) { // Add the theme class to the element (e.g., 'chip-themed') const themeClass = `${componentName}-themed`; element.classList.add(themeClass);