Skip to content

Commit be45f4a

Browse files
feat(themes): allow mode specific custom theme overrides with modular themes (#30657)
Issue number: internal --------- ## What is the current behavior? A `customTheme` object can not change based on the mode. ## What is the new behavior? Adds top level `ios` and `md` property checks to flatten with the `customTheme` object based on the mode. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Requires additional changes in order to test. --------- Co-authored-by: Brandy Smith <[email protected]>
1 parent cca089f commit be45f4a

File tree

6 files changed

+148
-125
lines changed

6 files changed

+148
-125
lines changed

core/src/components/content/content.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,7 @@ export class Content implements ComponentInterface {
444444
const { fixedSlotPlacement, inheritedAttributes, isMainContent, scrollX, scrollY, el } = this;
445445
const rtl = isRTL(el) ? 'rtl' : 'ltr';
446446
const theme = getIonTheme(this);
447-
const mode = getIonMode(this, theme);
447+
const mode = getIonMode(this);
448448
const forceOverscroll = this.shouldForceOverscroll(mode);
449449
const transitionShadow = theme === 'ios';
450450

core/src/components/nav/nav.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -908,7 +908,7 @@ export class Nav implements NavOutlet {
908908
}
909909
: undefined;
910910
const theme = getIonTheme(this);
911-
const mode = getIonMode(this, theme);
911+
const mode = getIonMode(this);
912912
const enteringEl = enteringView.element!;
913913
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
914914
const leavingEl = leavingView && leavingView.element!;

core/src/global/ionic-global.ts

Lines changed: 8 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Build, getMode, setMode, getElement } from '@stencil/core';
22
import { printIonWarning } from '@utils/logging';
3-
import { applyGlobalTheme } from '@utils/theme';
3+
import { applyGlobalTheme, getCustomTheme } from '@utils/theme';
44

55
import type { IonicConfig, Mode, Theme } from '../interface';
66
import { defaultTheme as baseTheme } from '../themes/base/default.tokens';
@@ -13,60 +13,6 @@ import { config, configFromSession, configFromURL, saveConfig } from './config';
1313
let defaultMode: Mode;
1414
let defaultTheme: Theme = 'md';
1515

16-
/**
17-
* Prints a warning message to the developer to inform them of
18-
* an invalid configuration of mode and theme.
19-
* @param mode The invalid mode configuration.
20-
* @param theme The invalid theme configuration.
21-
*/
22-
const printInvalidModeWarning = (mode: Mode, theme: Theme, ref?: any) => {
23-
printIonWarning(
24-
`Invalid mode and theme combination provided: mode: ${mode}, theme: ${theme}. Fallback mode ${getDefaultModeForTheme(
25-
theme
26-
)} will be used.`,
27-
ref
28-
);
29-
};
30-
31-
/**
32-
* Validates if a mode is accepted for a theme configuration.
33-
* @param mode The mode to validate.
34-
* @param theme The theme the mode is being used with.
35-
* @returns `true` if the mode is valid for the theme, `false` if invalid.
36-
*/
37-
export const isModeValidForTheme = (mode: Mode, theme: Theme) => {
38-
if (mode === 'md') {
39-
return theme === 'md' || theme === 'ionic';
40-
} else if (mode === 'ios') {
41-
return theme === 'ios' || theme === 'ionic';
42-
}
43-
return false;
44-
};
45-
46-
/**
47-
* Returns the default mode for a specified theme.
48-
* @param theme The theme to return a default mode for.
49-
* @returns The default mode, either `ios` or `md`.
50-
*/
51-
const getDefaultModeForTheme = (theme: Theme): Mode => {
52-
if (theme === 'ios') {
53-
return 'ios';
54-
}
55-
return 'md';
56-
};
57-
58-
/**
59-
* Returns the default theme for a specified mode.
60-
* @param mode The mode to return a default theme for.
61-
* @returns The default theme.
62-
*/
63-
const getDefaultThemeForMode = (mode: Mode): Theme => {
64-
if (mode === 'ios') {
65-
return 'ios';
66-
}
67-
return 'md';
68-
};
69-
7016
const isModeSupported = (elmMode: string) => ['ios', 'md'].includes(elmMode);
7117

7218
const isThemeSupported = (theme: string) => ['ios', 'md', 'ionic'].includes(theme);
@@ -75,32 +21,13 @@ const isIonicElement = (elm: HTMLElement) => elm.tagName?.startsWith('ION-');
7521

7622
/**
7723
* Returns the mode value of the element reference or the closest
78-
* parent with a valid mode.
24+
* parent with a valid mode. If no mode is set, then fallback
25+
* to the default mode.
7926
* @param ref The element reference to look up the mode for.
80-
* @param theme Optionally can provide the theme to avoid an additional look-up.
8127
* @returns The mode value for the element reference.
8228
*/
83-
export const getIonMode = (ref?: any, theme = getIonTheme(ref)): Mode => {
84-
if (ref?.mode && isModeValidForTheme(ref?.mode, theme)) {
85-
/**
86-
* If the reference already has a mode configuration,
87-
* use it instead of performing a look-up.
88-
*/
89-
return ref.mode;
90-
} else {
91-
const el = getElement(ref);
92-
const mode = (el.closest('[mode]')?.getAttribute('mode') as Mode) || defaultMode;
93-
94-
if (isModeValidForTheme(mode, theme)) {
95-
/**
96-
* The mode configuration is supported for the configured theme.
97-
*/
98-
return mode;
99-
} else {
100-
printInvalidModeWarning(mode, theme, ref);
101-
}
102-
}
103-
return getDefaultModeForTheme(theme);
29+
export const getIonMode = (ref?: any): Mode => {
30+
return ref?.mode || (getElement(ref).closest('[mode]')?.getAttribute('mode') as Mode) || defaultMode;
10431
};
10532

10633
/**
@@ -125,7 +52,7 @@ export const getIonTheme = (ref?: any): Theme => {
12552
const mode = ref?.mode ?? (el.closest('[mode]')?.getAttribute('mode') as Mode);
12653

12754
if (mode) {
128-
return getDefaultThemeForMode(mode);
55+
return mode;
12956
}
13057

13158
/**
@@ -210,15 +137,7 @@ export const initialize = (userConfig: IonicConfig = {}) => {
210137
* otherwise get the theme via config settings, and fallback to md.
211138
*/
212139

213-
Ionic.theme = defaultTheme = config.get(
214-
'theme',
215-
doc.documentElement.getAttribute('theme') || getDefaultThemeForMode(defaultMode)
216-
);
217-
218-
if (!isModeValidForTheme(defaultMode, defaultTheme)) {
219-
printInvalidModeWarning(defaultMode, defaultTheme, configObj);
220-
defaultMode = getDefaultModeForTheme(defaultTheme);
221-
}
140+
Ionic.theme = defaultTheme = config.get('theme', doc.documentElement.getAttribute('theme') || defaultMode);
222141

223142
config.set('mode', defaultMode);
224143
doc.documentElement.setAttribute('mode', defaultMode);
@@ -228,7 +147,7 @@ export const initialize = (userConfig: IonicConfig = {}) => {
228147
doc.documentElement.setAttribute('theme', defaultTheme);
229148
doc.documentElement.classList.add(defaultTheme);
230149

231-
const customTheme: BaseTheme | undefined = configObj.customTheme;
150+
const customTheme: BaseTheme | undefined = getCustomTheme(configObj.customTheme, defaultMode);
232151

233152
// Apply base theme, or combine with custom theme if provided
234153
if (customTheme) {

core/src/global/test/ionic-global.spec.ts

Lines changed: 4 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,35 +14,9 @@ jest.mock('@stencil/core', () => {
1414
* The implementation needs to be mocked before the implementation is imported.
1515
*/
1616
// eslint-disable-next-line import/first
17-
import { getIonTheme, isModeValidForTheme, getIonMode } from '../ionic-global';
17+
import { getIonTheme, getIonMode } from '../ionic-global';
1818

1919
describe('Ionic Global', () => {
20-
describe('isModeValidForTheme', () => {
21-
it('should return true for md mode with md theme', () => {
22-
expect(isModeValidForTheme('md', 'md')).toBe(true);
23-
});
24-
25-
it('should return true for md mode with ionic theme', () => {
26-
expect(isModeValidForTheme('md', 'ionic')).toBe(true);
27-
});
28-
29-
it('should return true for ios mode with ios theme', () => {
30-
expect(isModeValidForTheme('ios', 'ios')).toBe(true);
31-
});
32-
33-
it('should return true for ios mode with ionic theme', () => {
34-
expect(isModeValidForTheme('ios', 'ionic')).toBe(true);
35-
});
36-
37-
it('should return false for md mode with ios theme', () => {
38-
expect(isModeValidForTheme('md', 'ios')).toBe(false);
39-
});
40-
41-
it('should return false for ios mode with md theme', () => {
42-
expect(isModeValidForTheme('ios', 'md')).toBe(false);
43-
});
44-
});
45-
4620
describe('getIonMode', () => {
4721
const parentRef = { mode: 'md' };
4822
const ref = { parentElement: parentRef };
@@ -73,7 +47,7 @@ describe('Ionic Global', () => {
7347
}),
7448
}));
7549

76-
expect(getIonMode(ref, 'ios')).toBe('ios');
50+
expect(getIonMode(ref)).toBe('ios');
7751
});
7852

7953
it('should return the mode value of the closest parent with a valid mode', () => {
@@ -84,10 +58,9 @@ describe('Ionic Global', () => {
8458
expect(getIonMode()).toBe('md');
8559
});
8660

87-
it('should return the theme value if provided and no mode is found', () => {
61+
it('should return the default theme if no mode is found', () => {
8862
const ref = { mode: undefined };
89-
const theme = 'ios';
90-
expect(getIonMode(ref, theme)).toBe('ios');
63+
expect(getIonMode(ref)).toBe('md');
9164
});
9265
});
9366

core/src/utils/theme.spec.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,111 @@ import { newSpecPage } from '@stencil/core/testing';
33
import { CardContent } from '../components/card-content/card-content';
44
import { Chip } from '../components/chip/chip';
55

6-
import { generateComponentThemeCSS, generateCSSVars, generateGlobalThemeCSS, injectCSS } from './theme';
6+
import { generateComponentThemeCSS, generateCSSVars, generateGlobalThemeCSS, getCustomTheme, injectCSS } from './theme';
7+
8+
describe('getCustomTheme', () => {
9+
const baseCustomTheme = {
10+
radii: {
11+
sm: '14px',
12+
md: '18px',
13+
lg: '22px',
14+
},
15+
components: {
16+
IonChip: {
17+
hue: {
18+
subtle: {
19+
bg: 'red',
20+
color: 'white',
21+
},
22+
},
23+
},
24+
},
25+
};
26+
27+
const iosOverride = {
28+
components: {
29+
IonChip: {
30+
hue: {
31+
subtle: {
32+
bg: 'blue',
33+
},
34+
},
35+
},
36+
},
37+
};
38+
39+
const mdOverride = {
40+
components: {
41+
IonChip: {
42+
hue: {
43+
subtle: {
44+
bg: 'green',
45+
},
46+
},
47+
},
48+
},
49+
};
50+
51+
it('should return the custom theme if no mode overrides exist', () => {
52+
const customTheme = { ...baseCustomTheme };
53+
54+
const result = getCustomTheme(customTheme, 'ios');
55+
56+
expect(result).toEqual(customTheme);
57+
});
58+
59+
it('should combine only with ios overrides if mode is ios', () => {
60+
const customTheme = {
61+
...baseCustomTheme,
62+
ios: iosOverride,
63+
md: mdOverride,
64+
};
65+
66+
const result = getCustomTheme(customTheme, 'ios');
67+
68+
const expected = {
69+
...baseCustomTheme,
70+
components: {
71+
IonChip: {
72+
hue: {
73+
subtle: {
74+
bg: 'blue',
75+
color: 'white',
76+
},
77+
},
78+
},
79+
},
80+
};
81+
82+
expect(result).toEqual(expected);
83+
});
84+
85+
it('should combine only with md overrides if mode is md', () => {
86+
const customTheme = {
87+
...baseCustomTheme,
88+
ios: iosOverride,
89+
md: mdOverride,
90+
};
91+
92+
const result = getCustomTheme(customTheme, 'md');
93+
94+
const expected = {
95+
...baseCustomTheme,
96+
components: {
97+
IonChip: {
98+
hue: {
99+
subtle: {
100+
bg: 'green',
101+
color: 'white',
102+
},
103+
},
104+
},
105+
},
106+
};
107+
108+
expect(result).toEqual(expected);
109+
});
110+
});
7111

8112
describe('generateCSSVars', () => {
9113
it('should not generate CSS variables for an empty theme', () => {

core/src/utils/theme.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,32 @@ export const getClassMap = (classes: string | string[] | undefined): CssClassMap
3939
return map;
4040
};
4141

42+
/**
43+
* Gets and merges custom themes based on mode
44+
* @param customTheme The custom theme
45+
* @param mode The current mode (ios | md)
46+
* @returns The merged custom theme
47+
*/
48+
export const getCustomTheme = (customTheme: any, mode: string): any => {
49+
if (!customTheme) return undefined;
50+
51+
// Check if the custom theme contains mode overrides (ios | md)
52+
if (customTheme.ios || customTheme.md) {
53+
const { ios, md, ...baseCustomTheme } = customTheme;
54+
55+
// Flatten the mode-specific overrides based on current mode
56+
if (mode === 'ios' && ios) {
57+
return deepMerge(baseCustomTheme, ios);
58+
} else if (mode === 'md' && md) {
59+
return deepMerge(baseCustomTheme, md);
60+
}
61+
62+
return baseCustomTheme;
63+
}
64+
65+
return customTheme;
66+
};
67+
4268
/**
4369
* Flattens the theme object into CSS custom properties
4470
* @param theme The theme object to flatten
@@ -211,9 +237,10 @@ export const applyComponentTheme = (element: HTMLElement): void => {
211237
// Convert to 'IonChip' by capitalizing each part
212238
const themeLookupName = parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('');
213239

214-
if (customTheme?.components?.[themeLookupName]) {
215-
const componentTheme = customTheme.components[themeLookupName];
240+
// Get the component theme from the global custom theme if it exists
241+
const componentTheme = customTheme?.components?.[themeLookupName];
216242

243+
if (componentTheme) {
217244
// Add the theme class to the element (e.g., 'chip-themed')
218245
const themeClass = `${componentName}-themed`;
219246
element.classList.add(themeClass);

0 commit comments

Comments
 (0)