Skip to content

Commit 6e84ff3

Browse files
committed
feat(themes): add support for custom themes from design tokens
1 parent 3cfd930 commit 6e84ff3

File tree

5 files changed

+241
-0
lines changed

5 files changed

+241
-0
lines changed

core/src/global/ionic-global.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { Build, getMode, setMode, getElement } from '@stencil/core';
22
import { printIonWarning } from '@utils/logging';
3+
import { applyGlobalTheme } from '@utils/theme';
34

45
import type { IonicConfig, Mode, Theme } from '../interface';
6+
import { defaultTheme as baseTheme } from '../themes/base/default.tokens';
7+
import type { Theme as BaseTheme } from '../themes/base/default.tokens';
58
import { shouldUseCloseWatcher } from '../utils/hardware-back-button';
69
import { isPlatform, setupPlatforms } from '../utils/platform';
710

@@ -225,6 +228,16 @@ export const initialize = (userConfig: IonicConfig = {}) => {
225228
doc.documentElement.setAttribute('theme', defaultTheme);
226229
doc.documentElement.classList.add(defaultTheme);
227230

231+
const customTheme: BaseTheme | undefined = configObj.customTheme;
232+
233+
// Apply base theme, or combine with custom theme if provided
234+
if (customTheme) {
235+
const combinedTheme = applyGlobalTheme(baseTheme, customTheme);
236+
config.set('customTheme', combinedTheme);
237+
} else {
238+
applyGlobalTheme(baseTheme);
239+
}
240+
228241
if (config.getBoolean('_testing')) {
229242
config.set('animated', false);
230243
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const defaultTheme = {
2+
palette: {
3+
light: {},
4+
dark: {
5+
enabled: 'system',
6+
},
7+
},
8+
};
9+
10+
export type Theme = typeof defaultTheme;

core/src/utils/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,9 @@ export interface IonicConfig {
364364
scrollAssist?: boolean;
365365
hideCaretOnScroll?: boolean;
366366

367+
// Theme configs
368+
customTheme?: any;
369+
367370
// INTERNAL configs
368371
// TODO(FW-2832): types
369372
persistConfig?: boolean;

core/src/utils/helpers.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,3 +456,23 @@ export const openURL = async (
456456
}
457457
return false;
458458
};
459+
460+
/**
461+
* Deep merges two objects, with source properties overriding target properties
462+
* @param target The target object to merge into
463+
* @param source The source object to merge from
464+
* @returns The merged object (new object, doesn't modify original)
465+
*/
466+
export const deepMerge = (target: any, source: any): any => {
467+
// Create a new object to avoid modifying the original
468+
const result = { ...target };
469+
470+
for (const key in source) {
471+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
472+
result[key] = deepMerge(result[key] ?? {}, source[key]);
473+
} else {
474+
result[key] = source[key];
475+
}
476+
}
477+
return result;
478+
};

core/src/utils/theme.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
11
import type { Color, CssClassMap } from '../interface';
2+
import type { Theme } from '../themes/base/default.tokens';
3+
4+
import { deepMerge } from './helpers';
5+
6+
// Global constants
7+
export const CSS_PROPS_PREFIX = '--ion-';
8+
export const CSS_ROOT_SELECTOR = ':root';
29

310
export const hostContext = (selector: string, el: HTMLElement): boolean => {
411
return el.closest(selector) !== null;
@@ -33,3 +40,191 @@ export const getClassMap = (classes: string | string[] | undefined): CssClassMap
3340
getClassList(classes).forEach((c) => (map[c] = true));
3441
return map;
3542
};
43+
44+
/**
45+
* Flattens the theme object into CSS custom properties
46+
* @param theme The theme object to flatten
47+
* @param prefix The CSS prefix to use (e.g., '--ion-')
48+
* @returns CSS string with custom properties
49+
*/
50+
const generateCSSVars = (theme: any, prefix: string = CSS_PROPS_PREFIX): string => {
51+
const cssProps = Object.entries(theme)
52+
.flatMap(([key, val]) => {
53+
// Skip invalid keys or values
54+
if (!key || typeof key !== 'string' || val === null || val === undefined) {
55+
return [];
56+
}
57+
58+
// if key is camelCase, convert to kebab-case
59+
if (key.match(/([a-z])([A-Z])/g)) {
60+
key = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
61+
}
62+
63+
// Special handling for 'base' property - don't add suffix
64+
if (key === 'base') {
65+
return [`${prefix.slice(0, -1)}: ${val};`];
66+
}
67+
68+
// If it's a font-sizes key, create rem version
69+
// This is necessary to support the dynamic font size feature
70+
if (key === 'font-sizes' && typeof val === 'object' && val !== null) {
71+
// Access the root font size from the global theme context
72+
const fontSizeBase = parseFloat((window as any).Ionic?.config?.get?.('theme')?.fontSizes?.root ?? '16');
73+
return Object.entries(val).flatMap(([sizeKey, sizeValue]) => {
74+
if (!sizeKey || sizeValue == null) return [];
75+
const remValue = `${parseFloat(sizeValue) / fontSizeBase}rem`;
76+
// Return both px and rem values as separate array items
77+
return [
78+
`${prefix}${key}-${sizeKey}: ${sizeValue};`, // original px value
79+
`${prefix}${key}-${sizeKey}-rem: ${remValue};`, // rem value
80+
];
81+
});
82+
}
83+
84+
return typeof val === 'object' && val !== null
85+
? generateCSSVars(val, `${prefix}${key}-`)
86+
: [`${prefix}${key}: ${val};`];
87+
})
88+
.filter(Boolean);
89+
90+
return cssProps.join('\n');
91+
};
92+
93+
/**
94+
* Creates a style element and injects its CSS into a target element
95+
* @param css The CSS string to inject
96+
* @param target The target element to inject into
97+
*/
98+
const injectCSS = (css: string, target: Element | ShadowRoot = document.head) => {
99+
const style = document.createElement('style');
100+
style.innerHTML = css;
101+
target.appendChild(style);
102+
};
103+
104+
/**
105+
* Generates global CSS variables from a theme object
106+
* @param theme The theme object to generate CSS for
107+
* @returns The generated CSS string
108+
*/
109+
const generateGlobalThemeCSS = (theme: Theme): string => {
110+
if (typeof theme !== 'object' || Array.isArray(theme)) {
111+
console.warn('generateGlobalThemeCSS: Invalid theme object provided', theme);
112+
return '';
113+
}
114+
115+
if (Object.keys(theme).length === 0) {
116+
console.warn('generateGlobalThemeCSS: Empty theme object provided');
117+
return '';
118+
}
119+
120+
const { palette, ...defaultTokens } = theme;
121+
122+
// Generate CSS variables for the default design tokens
123+
const defaultTokensCSS = generateCSSVars(defaultTokens);
124+
125+
// Generate CSS variables for the light color palette
126+
const lightTokensCSS = generateCSSVars(palette.light);
127+
128+
let css = `
129+
${CSS_ROOT_SELECTOR} {
130+
${defaultTokensCSS}
131+
${lightTokensCSS}
132+
}
133+
`;
134+
135+
// Generate CSS variables for the dark color palette if it
136+
// is enabled for system preference
137+
if (palette.dark.enabled === 'system') {
138+
const darkTokensCSS = generateCSSVars(palette.dark);
139+
if (darkTokensCSS.length > 0) {
140+
css += `
141+
@media (prefers-color-scheme: dark) {
142+
${CSS_ROOT_SELECTOR} {
143+
${darkTokensCSS}
144+
}
145+
}
146+
`;
147+
}
148+
}
149+
150+
return css;
151+
};
152+
153+
/**
154+
* Applies the global theme from the provided base theme and user theme
155+
* @param baseTheme The default theme
156+
* @param userTheme The user's custom theme (optional)
157+
* @returns The combined theme object (or base theme if no user theme was provided)
158+
*/
159+
export const applyGlobalTheme = (baseTheme: Theme, userTheme?: any): any => {
160+
// If no base theme provided, error
161+
if (typeof baseTheme !== 'object' || Array.isArray(baseTheme)) {
162+
console.error('applyGlobalTheme: Valid base theme object is required', baseTheme);
163+
return {};
164+
}
165+
166+
// If no user theme provided or it is invalid, apply base theme
167+
if (!userTheme || typeof userTheme !== 'object' || Array.isArray(userTheme)) {
168+
if (userTheme) {
169+
console.error('applyGlobalTheme: Invalid user theme provided', userTheme);
170+
}
171+
injectCSS(generateGlobalThemeCSS(baseTheme));
172+
return baseTheme;
173+
}
174+
175+
// Merge themes and apply
176+
const mergedTheme = deepMerge(baseTheme, userTheme);
177+
injectCSS(generateGlobalThemeCSS(mergedTheme));
178+
return mergedTheme;
179+
};
180+
181+
/**
182+
* Generates component's themed CSS class with CSS variables
183+
* from its theme object
184+
* @param theme The theme object to generate CSS for
185+
* @param componentName The component name without any prefixes (e.g., 'chip')
186+
* @returns string containing the component's themed CSS variables
187+
*/
188+
const generateComponentThemeCSS = (theme: Theme, componentName: string): string => {
189+
const cssProps = generateCSSVars(theme, `${CSS_PROPS_PREFIX}${componentName}-`);
190+
191+
return `
192+
:host(.${componentName}-themed) {
193+
${cssProps}
194+
}
195+
`;
196+
};
197+
198+
/**
199+
* Applies a component theme to an element if it exists in the custom theme
200+
* @param element The element to apply the theme to
201+
* @returns true if theme was applied, false otherwise
202+
*/
203+
export const applyComponentTheme = (element: HTMLElement): void => {
204+
const customTheme = (window as any).Ionic?.config?.get?.('customTheme');
205+
206+
// Convert 'ION-CHIP' to 'ion-chip' and split into parts
207+
const parts = element.tagName.toLowerCase().split('-');
208+
209+
// Remove 'ion-' prefix to get 'chip'
210+
const componentName = parts.slice(1).join('-');
211+
212+
// Convert to 'IonChip' by capitalizing each part
213+
const themeLookupName = parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join('');
214+
215+
if (customTheme?.components?.[themeLookupName]) {
216+
const componentTheme = customTheme.components[themeLookupName];
217+
218+
// Add the theme class to the element (e.g., 'chip-themed')
219+
const themeClass = `${componentName}-themed`;
220+
element.classList.add(themeClass);
221+
222+
// Generate CSS custom properties inside a theme class selector
223+
const css = generateComponentThemeCSS(componentTheme, componentName);
224+
225+
// Inject styles into shadow root if available,
226+
// otherwise into the element itself
227+
const root = element.shadowRoot ?? element;
228+
injectCSS(css, root);
229+
}
230+
};

0 commit comments

Comments
 (0)