Skip to content
Merged
2 changes: 1 addition & 1 deletion core/src/components/content/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion core/src/components/nav/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand Down
97 changes: 8 additions & 89 deletions core/src/global/ionic-global.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand All @@ -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;
};

/**
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
35 changes: 4 additions & 31 deletions core/src/global/test/ionic-global.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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');
});
});

Expand Down
106 changes: 105 additions & 1 deletion core/src/utils/theme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
31 changes: 29 additions & 2 deletions core/src/utils/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,32 @@
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
Expand Down Expand Up @@ -116,7 +142,7 @@
}

// Exclude components and palette from the default tokens
const { palette, components, ...defaultTokens } = theme;

Check warning on line 145 in core/src/utils/theme.ts

View workflow job for this annotation

GitHub Actions / test-core-lint

'components' is assigned a value but never used. Allowed unused vars must match /^(h|Fragment)$/u

// Generate CSS variables for the default design tokens
const defaultTokensCSS = generateCSSVars(defaultTokens);
Expand Down Expand Up @@ -211,9 +237,10 @@
// 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);
Expand Down
Loading