feat(themes): add support for modular themes and custom themes (#30651)

Issue number: internal

---------

## What is the new behavior?
- Moves `openURL` out of the `theme` utils because it makes more sense
in `helpers`
- Adds support for the default `default.tokens.ts` design tokens file
- Adds support for custom theme set globally and on a component 

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

## Other information

Requires additional changes in order to test.

---------

Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com>
This commit is contained in:
Brandy Smith
2025-08-29 13:53:24 -04:00
committed by Brandy Smith
parent fea1e64920
commit 1aa7c35da1
15 changed files with 658 additions and 33 deletions

View File

@ -3,8 +3,8 @@ import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Prop, h } from '@stencil/core';
import type { ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes } from '@utils/helpers';
import { createColorClasses, hostContext, openURL } from '@utils/theme';
import { inheritAriaAttributes, openURL } from '@utils/helpers';
import { createColorClasses, hostContext } from '@utils/theme';
import { arrowBackSharp, chevronBack } from 'ionicons/icons';
import { config } from '../../global/config';

View File

@ -3,8 +3,8 @@ import dotsThreeRegular from '@phosphor-icons/core/assets/regular/dots-three.svg
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, h } from '@stencil/core';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes } from '@utils/helpers';
import { createColorClasses, hostContext, openURL } from '@utils/theme';
import { inheritAriaAttributes, openURL } from '@utils/helpers';
import { createColorClasses, hostContext } from '@utils/theme';
import { chevronForwardOutline, ellipsisHorizontal } from 'ionicons/icons';
import { config } from '../../global/config';

View File

@ -2,9 +2,9 @@ import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, Watch, State, forceUpdate, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, hasShadowDom } from '@utils/helpers';
import { inheritAriaAttributes, hasShadowDom, openURL } from '@utils/helpers';
import { printIonWarning } from '@utils/logging';
import { createColorClasses, hostContext, openURL } from '@utils/theme';
import { createColorClasses, hostContext } from '@utils/theme';
import { getIonTheme, getIonMode } from '../../global/ionic-global';
import type { AnimationBuilder, Color } from '../../interface';

View File

@ -2,8 +2,8 @@ import type { ComponentInterface } from '@stencil/core';
import { Element, Component, Host, Prop, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAttributes } from '@utils/helpers';
import { createColorClasses, openURL } from '@utils/theme';
import { inheritAttributes, openURL } from '@utils/helpers';
import { createColorClasses } from '@utils/theme';
import { getIonTheme } from '../../global/ionic-global';
import type { AnimationBuilder, Color, Theme } from '../../interface';

View File

@ -2,9 +2,9 @@ import xRegular from '@phosphor-icons/core/assets/regular/x.svg';
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import { inheritAriaAttributes } from '@utils/helpers';
import { inheritAriaAttributes, openURL } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { createColorClasses, hostContext, openURL } from '@utils/theme';
import { createColorClasses, hostContext } from '@utils/theme';
import { close } from 'ionicons/icons';
import { config } from '../../global/config';

View File

@ -3,8 +3,8 @@ import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Listen, Prop, State, Watch, forceUpdate, h } from '@stencil/core';
import type { AnchorInterface, ButtonInterface } from '@utils/element-interface';
import type { Attributes } from '@utils/helpers';
import { inheritAttributes, raf } from '@utils/helpers';
import { createColorClasses, hostContext, openURL } from '@utils/theme';
import { inheritAttributes, raf, openURL } from '@utils/helpers';
import { createColorClasses, hostContext } from '@utils/theme';
import { chevronForward } from 'ionicons/icons';
import { config } from '../../global/config';

View File

@ -1,6 +1,7 @@
import type { ComponentInterface } from '@stencil/core';
import { Component, Host, Prop, h } from '@stencil/core';
import { createColorClasses, openURL } from '@utils/theme';
import { openURL } from '@utils/helpers';
import { createColorClasses } from '@utils/theme';
import { getIonTheme } from '../../global/ionic-global';
import type { AnimationBuilder, Color } from '../../interface';

View File

@ -1,7 +1,10 @@
import { Build, getMode, setMode, getElement } from '@stencil/core';
import { printIonWarning } from '@utils/logging';
import { applyGlobalTheme } from '@utils/theme';
import type { IonicConfig, Mode, Theme } from '../interface';
import { defaultTheme as baseTheme } from '../themes/base/default.tokens';
import type { Theme as BaseTheme } from '../themes/base/default.tokens';
import { shouldUseCloseWatcher } from '../utils/hardware-back-button';
import { isPlatform, setupPlatforms } from '../utils/platform';
@ -225,6 +228,16 @@ export const initialize = (userConfig: IonicConfig = {}) => {
doc.documentElement.setAttribute('theme', defaultTheme);
doc.documentElement.classList.add(defaultTheme);
const customTheme: BaseTheme | undefined = configObj.customTheme;
// Apply base theme, or combine with custom theme if provided
if (customTheme) {
const combinedTheme = applyGlobalTheme(baseTheme, customTheme);
config.set('customTheme', combinedTheme);
} else {
applyGlobalTheme(baseTheme);
}
if (config.getBoolean('_testing')) {
config.set('animated', false);
}

View File

@ -7,12 +7,11 @@ export { mdTransitionAnimation } from './utils/transition/md.transition';
export { getTimeGivenProgression } from './utils/animation/cubic-bezier';
export { createGesture } from './utils/gesture';
export { initialize } from './global/ionic-global';
export { componentOnReady } from './utils/helpers';
export { componentOnReady, openURL } from './utils/helpers';
export { LogLevel } from './utils/logging';
export { isPlatform, Platforms, PlatformConfig, getPlatforms } from './utils/platform';
export { IonicSafeString } from './utils/sanitization';
export { IonicConfig, getMode, setupConfig } from './utils/config';
export { openURL } from './utils/theme';
export {
LIFECYCLE_WILL_ENTER,
LIFECYCLE_DID_ENTER,

View File

@ -0,0 +1,10 @@
export const defaultTheme = {
palette: {
light: {},
dark: {
enabled: 'system',
},
},
};
export type Theme = typeof defaultTheme;

View File

@ -364,6 +364,9 @@ export interface IonicConfig {
scrollAssist?: boolean;
hideCaretOnScroll?: boolean;
// Theme configs
customTheme?: any;
// INTERNAL configs
// TODO(FW-2832): types
persistConfig?: boolean;

View File

@ -1,4 +1,4 @@
import { inheritAriaAttributes } from './helpers';
import { deepMerge, inheritAriaAttributes } from './helpers';
describe('inheritAriaAttributes', () => {
it('should inherit aria attributes', () => {
@ -40,3 +40,26 @@ describe('inheritAriaAttributes', () => {
});
});
});
describe('deepMerge', () => {
it('should merge objects', () => {
const target = { a: 1, b: 2 };
const source = { b: 3, c: 4 };
const result = deepMerge(target, source);
expect(result).toEqual({ a: 1, b: 3, c: 4 });
});
it('should merge objects when target is undefined', () => {
const target = undefined;
const source = { a: 1, b: 2 };
const result = deepMerge(target, source);
expect(result).toEqual({ a: 1, b: 2 });
});
it('should merge objects when source is undefined', () => {
const target = { a: 1, b: 2 };
const source = undefined;
const result = deepMerge(target, source);
expect(result).toEqual({ a: 1, b: 2 });
});
});

View File

@ -3,7 +3,9 @@ import { focusElements } from '@utils/focus-visible';
import { printIonError } from '@utils/logging';
import type { Side } from '../components/menu/menu-interface';
import type { RouterDirection } from '../components/router/utils/interface';
import { config } from '../global/config';
import type { AnimationBuilder } from '../interface';
// TODO(FW-2832): types
@ -434,3 +436,42 @@ export const shallowEqualStringMap = (
export const isSafeNumber = (input: unknown): input is number => {
return typeof input === 'number' && !isNaN(input) && isFinite(input);
};
const SCHEME = /^[a-z][a-z0-9+\-.]*:/;
export const openURL = async (
url: string | undefined | null,
ev: Event | undefined | null,
direction: RouterDirection,
animation?: AnimationBuilder
): Promise<boolean> => {
if (url != null && url[0] !== '#' && !SCHEME.test(url)) {
const router = document.querySelector('ion-router');
if (router) {
if (ev != null) {
ev.preventDefault();
}
return router.push(url, direction, animation);
}
}
return false;
};
/**
* Deep merges two objects, with source properties overriding target properties
* @param target The target object to merge into
* @param source The source object to merge from
* @returns The merged object
*/
export const deepMerge = (target: any, source: any): any => {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = deepMerge(result[key] ?? {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
};

View File

@ -0,0 +1,362 @@
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';
describe('generateCSSVars', () => {
it('should not generate CSS variables for an empty theme', () => {
const theme = {
palette: {
light: {},
dark: {},
},
};
const css = generateCSSVars(theme);
expect(css).toBe('');
});
it('should generate CSS variables for a given theme', () => {
const theme = {
palette: {
light: {},
dark: {
enabled: 'system',
},
},
borderWidth: {
sm: '4px',
},
spacing: {
md: '12px',
},
scaling: {
0: '0',
},
radii: {
lg: '8px',
},
dynamicFont: '-apple-system-body',
fontFamily: 'Roboto, "Helvetica Neue", sans-serif',
fontWeights: {
semiBold: '600',
},
fontSizes: {
sm: '14px',
md: '16px',
},
lineHeights: {
sm: '1.2',
},
components: {},
};
const css = generateCSSVars(theme);
expect(css).toContain('--ion-palette-dark-enabled: system;');
expect(css).toContain('--ion-border-width-sm: 4px;');
expect(css).toContain('--ion-spacing-md: 12px;');
expect(css).toContain('--ion-scaling-0: 0;');
expect(css).toContain('--ion-radii-lg: 8px;');
expect(css).toContain('--ion-dynamic-font: -apple-system-body;');
expect(css).toContain('--ion-font-family: Roboto, "Helvetica Neue", sans-serif;');
expect(css).toContain('--ion-font-weights-semi-bold: 600;');
expect(css).toContain('--ion-font-sizes-sm: 14px;');
expect(css).toContain('--ion-font-sizes-sm-rem: 0.875rem;');
expect(css).toContain('--ion-font-sizes-md: 16px;');
expect(css).toContain('--ion-font-sizes-md-rem: 1rem;');
expect(css).toContain('--ion-line-heights-sm: 1.2;');
});
});
describe('injectCSS', () => {
it('should inject CSS into the head', () => {
const css = 'body { background-color: red; }';
injectCSS(css);
expect(document.head.innerHTML).toContain(`<style>${css}</style>`);
});
it('should inject CSS into an element', async () => {
const page = await newSpecPage({
components: [CardContent],
html: '<ion-card-content></ion-card-content>',
});
const target = page.body.querySelector('ion-card-content')!;
const css = ':host { background-color: red; }';
injectCSS(css, target);
expect(target.innerHTML).toContain(`<style>${css}</style>`);
});
it('should inject CSS into an element with a shadow root', async () => {
const page = await newSpecPage({
components: [Chip],
html: '<ion-chip></ion-chip>',
});
const target = page.body.querySelector('ion-chip')!;
const shadowRoot = target.shadowRoot;
expect(shadowRoot).toBeTruthy();
const css = ':host { background-color: red; }';
injectCSS(css, shadowRoot!);
expect(shadowRoot!.innerHTML).toContain(`<style>${css}</style>`);
});
});
describe('generateGlobalThemeCSS', () => {
it('should generate global CSS for a given theme', () => {
const theme = {
palette: {
light: {},
dark: {
enabled: 'never',
},
},
borderWidth: {
sm: '4px',
},
spacing: {
md: '12px',
},
dynamicFont: '-apple-system-body',
};
const css = generateGlobalThemeCSS(theme).replace(/\s/g, '');
const expectedCSS = `
:root {
--ion-border-width-sm: 4px;
--ion-spacing-md: 12px;
--ion-dynamic-font: -apple-system-body;
}
`.replace(/\s/g, '');
expect(css).toBe(expectedCSS);
});
it('should generate global CSS for a given theme with light palette', () => {
const theme = {
palette: {
light: {
color: {
primary: {
bold: {
base: '#0054e9',
contrast: '#ffffff',
shade: '#0041c4',
tint: '#0065ff',
},
subtle: {
base: '#0054e9',
contrast: '#ffffff',
shade: '#0041c4',
tint: '#0065ff',
},
},
red: {
50: '#ffebee',
100: '#ffcdd2',
200: '#ef9a9a',
},
},
},
dark: {
enabled: 'never',
},
},
borderWidth: {
sm: '4px',
},
spacing: {
md: '12px',
},
dynamicFont: '-apple-system-body',
};
const css = generateGlobalThemeCSS(theme).replace(/\s/g, '');
const expectedCSS = `
:root {
--ion-border-width-sm: 4px;
--ion-spacing-md: 12px;
--ion-dynamic-font: -apple-system-body;
--ion-color-primary-bold: #0054e9;
--ion-color-primary-bold-contrast: #ffffff;
--ion-color-primary-bold-shade: #0041c4;
--ion-color-primary-bold-tint: #0065ff;
--ion-color-primary-subtle: #0054e9;
--ion-color-primary-subtle-contrast: #ffffff;
--ion-color-primary-subtle-shade: #0041c4;
--ion-color-primary-subtle-tint: #0065ff;
--ion-color-red-50: #ffebee;
--ion-color-red-100: #ffcdd2;
--ion-color-red-200: #ef9a9a;
}
`.replace(/\s/g, '');
expect(css).toBe(expectedCSS);
});
it('should not include component or palette variables in global CSS', () => {
const theme = {
palette: {
light: {},
dark: {
enabled: 'never',
},
},
borderWidth: {
sm: '4px',
},
spacing: {
md: '12px',
},
components: {
IonChip: {
hue: {
subtle: {
bg: 'red',
},
},
shape: {
round: {
borderRadius: '4px',
},
},
},
IonButton: {
color: {
primary: {
bg: 'blue',
},
},
},
},
};
const css = generateGlobalThemeCSS(theme);
// Should include global design tokens
expect(css).toContain('--ion-border-width-sm: 4px');
expect(css).toContain('--ion-spacing-md: 12px');
// Should NOT include component variables
expect(css).not.toContain('--ion-components-ion-chip-hue-subtle-bg');
expect(css).not.toContain('--ion-components-ion-chip-shape-round-border-radius');
expect(css).not.toContain('--ion-components-ion-button-color-primary-bg');
expect(css).not.toContain('components');
// Should NOT include palette variables
expect(css).not.toContain('--ion-color-palette-dark-enabled-never');
expect(css).not.toContain('palette');
});
it('should generate global CSS for a given theme with dark palette enabled for system preference', () => {
const theme = {
palette: {
light: {},
dark: {
enabled: 'system',
color: {
primary: {
bold: {
base: '#0054e9',
contrast: '#ffffff',
shade: '#0041c4',
tint: '#0065ff',
},
subtle: {
base: '#0054e9',
contrast: '#ffffff',
shade: '#0041c4',
tint: '#0065ff',
},
},
red: {
50: '#ffebee',
100: '#ffcdd2',
200: '#ef9a9a',
},
},
},
},
borderWidth: {
sm: '4px',
},
spacing: {
md: '12px',
},
dynamicFont: '-apple-system-body',
};
const css = generateGlobalThemeCSS(theme).replace(/\s/g, '');
const expectedCSS = `
:root {
--ion-border-width-sm: 4px;
--ion-spacing-md: 12px;
--ion-dynamic-font: -apple-system-body;
}
@media(prefers-color-scheme: dark) {
:root {
--ion-enabled: system;
--ion-color-primary-bold: #0054e9;
--ion-color-primary-bold-contrast: #ffffff;
--ion-color-primary-bold-shade: #0041c4;
--ion-color-primary-bold-tint: #0065ff;
--ion-color-primary-subtle: #0054e9;
--ion-color-primary-subtle-contrast: #ffffff;
--ion-color-primary-subtle-shade: #0041c4;
--ion-color-primary-subtle-tint: #0065ff;
--ion-color-red-50: #ffebee;
--ion-color-red-100: #ffcdd2;
--ion-color-red-200: #ef9a9a;
}
}
`.replace(/\s/g, '');
expect(css).toBe(expectedCSS);
});
});
describe('generateComponentThemeCSS', () => {
it('should generate component theme CSS for a given theme', () => {
const IonChip = {
hue: {
subtle: {
bg: 'red',
color: 'white',
borderColor: 'black',
},
bold: {
bg: 'blue',
color: 'white',
borderColor: 'black',
},
},
};
const css = generateComponentThemeCSS(IonChip, 'chip').replace(/\s/g, '');
const expectedCSS = `
:host(.chip-themed) {
--ion-chip-hue-subtle-bg: red;
--ion-chip-hue-subtle-color: white;
--ion-chip-hue-subtle-border-color: black;
--ion-chip-hue-bold-bg: blue;
--ion-chip-hue-bold-color: white;
--ion-chip-hue-bold-border-color: black;
}
`.replace(/\s/g, '');
expect(css).toBe(expectedCSS);
});
});

View File

@ -1,5 +1,9 @@
import type { RouterDirection } from '../components/router/utils/interface';
import type { AnimationBuilder, Color, CssClassMap } from '../interface';
import type { Color, CssClassMap } from '../interface';
import { deepMerge } from './helpers';
export const CSS_PROPS_PREFIX = '--ion-';
export const CSS_ROOT_SELECTOR = ':root';
export const hostContext = (selector: string, el: HTMLElement): boolean => {
return el.closest(selector) !== null;
@ -35,22 +39,191 @@ export const getClassMap = (classes: string | string[] | undefined): CssClassMap
return map;
};
const SCHEME = /^[a-z][a-z0-9+\-.]*:/;
/**
* Flattens the theme object into CSS custom properties
* @param theme The theme object to flatten
* @param prefix The CSS prefix to use (e.g., '--ion-')
* @returns CSS string with custom properties
*/
export const generateCSSVars = (theme: any, prefix: string = CSS_PROPS_PREFIX): string => {
const cssProps = Object.entries(theme)
.flatMap(([key, val]) => {
// Skip invalid keys or values
if (!key || typeof key !== 'string' || val === null || val === undefined) {
return [];
}
export const openURL = async (
url: string | undefined | null,
ev: Event | undefined | null,
direction: RouterDirection,
animation?: AnimationBuilder
): Promise<boolean> => {
if (url != null && url[0] !== '#' && !SCHEME.test(url)) {
const router = document.querySelector('ion-router');
if (router) {
if (ev != null) {
ev.preventDefault();
// if key is camelCase, convert to kebab-case
if (key.match(/([a-z])([A-Z])/g)) {
key = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}
return router.push(url, direction, animation);
// Special handling for 'base' property - don't add suffix
if (key === 'base') {
return [`${prefix.slice(0, -1)}: ${val};`];
}
// If it's a font-sizes key, create rem version
// This is necessary to support the dynamic font size feature
if (key === 'font-sizes' && typeof val === 'object' && val !== null) {
// Access the root font size from the global theme context
const fontSizeBase = parseFloat((window as any).Ionic?.config?.get?.('theme')?.fontSizes?.root ?? '16');
return Object.entries(val).flatMap(([sizeKey, sizeValue]) => {
if (!sizeKey || sizeValue == null) return [];
const remValue = `${parseFloat(sizeValue) / fontSizeBase}rem`;
// Return both px and rem values as separate array items
return [
`${prefix}${key}-${sizeKey}: ${sizeValue};`, // original px value
`${prefix}${key}-${sizeKey}-rem: ${remValue};`, // rem value
];
});
}
return typeof val === 'object' && val !== null
? generateCSSVars(val, `${prefix}${key}-`)
: [`${prefix}${key}: ${val};`];
})
.filter(Boolean);
return cssProps.join('\n');
};
/**
* Creates a style element and injects its CSS into a target element
* @param css The CSS string to inject
* @param target The target element to inject into
*/
export const injectCSS = (css: string, target: Element | ShadowRoot = document.head) => {
const style = document.createElement('style');
style.innerHTML = css;
target.appendChild(style);
};
/**
* Generates global CSS variables from a theme object
* @param theme The theme object to generate CSS for
* @returns The generated CSS string
*/
export const generateGlobalThemeCSS = (theme: any): string => {
if (typeof theme !== 'object' || Array.isArray(theme)) {
console.warn('generateGlobalThemeCSS: Invalid theme object provided', theme);
return '';
}
if (Object.keys(theme).length === 0) {
console.warn('generateGlobalThemeCSS: Empty theme object provided');
return '';
}
// Exclude components and palette from the default tokens
const { palette, components, ...defaultTokens } = theme;
// Generate CSS variables for the default design tokens
const defaultTokensCSS = generateCSSVars(defaultTokens);
// Generate CSS variables for the light color palette
const lightTokensCSS = generateCSSVars(palette.light);
let css = `
${CSS_ROOT_SELECTOR} {
${defaultTokensCSS}
${lightTokensCSS}
}
`;
// Generate CSS variables for the dark color palette if it
// is enabled for system preference
if (palette.dark.enabled === 'system') {
const darkTokensCSS = generateCSSVars(palette.dark);
if (darkTokensCSS.length > 0) {
css += `
@media (prefers-color-scheme: dark) {
${CSS_ROOT_SELECTOR} {
${darkTokensCSS}
}
}
`;
}
}
return css;
};
/**
* Applies the global theme from the provided base theme and user theme
* @param baseTheme The default theme
* @param userTheme The user's custom theme (optional)
* @returns The combined theme object (or base theme if no user theme was provided)
*/
export const applyGlobalTheme = (baseTheme: any, userTheme?: any): any => {
// If no base theme provided, error
if (typeof baseTheme !== 'object' || Array.isArray(baseTheme)) {
console.error('applyGlobalTheme: Valid base theme object is required', baseTheme);
return {};
}
// If no user theme provided or it is invalid, apply base theme
if (!userTheme || typeof userTheme !== 'object' || Array.isArray(userTheme)) {
if (userTheme) {
console.error('applyGlobalTheme: Invalid user theme provided', userTheme);
}
injectCSS(generateGlobalThemeCSS(baseTheme));
return baseTheme;
}
// Merge themes and apply
const mergedTheme = deepMerge(baseTheme, userTheme);
injectCSS(generateGlobalThemeCSS(mergedTheme));
return mergedTheme;
};
/**
* Generates component's themed CSS class with CSS variables
* from its theme object
* @param componentTheme The component's object to generate CSS for (e.g., IonChip { })
* @param componentName The component name without any prefixes (e.g., 'chip')
* @returns string containing the component's themed CSS variables
*/
export const generateComponentThemeCSS = (componentTheme: any, componentName: string): string => {
const cssProps = generateCSSVars(componentTheme, `${CSS_PROPS_PREFIX}${componentName}-`);
return `
:host(.${componentName}-themed) {
${cssProps}
}
`;
};
/**
* Applies a component theme to an element if it exists in the custom theme
* @param element The element to apply the theme to
* @returns true if theme was applied, false otherwise
*/
export const applyComponentTheme = (element: HTMLElement): void => {
const customTheme = (window as any).Ionic?.config?.get?.('customTheme');
// Convert 'ION-CHIP' to 'ion-chip' and split into parts
const parts = element.tagName.toLowerCase().split('-');
// Get the component name 'chip' from the second part
const componentName = parts[1];
// 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];
// Add the theme class to the element (e.g., 'chip-themed')
const themeClass = `${componentName}-themed`;
element.classList.add(themeClass);
// Generate CSS custom properties inside a theme class selector
const css = generateComponentThemeCSS(componentTheme, componentName);
// Inject styles into shadow root if available,
// otherwise into the element itself
const root = element.shadowRoot ?? element;
injectCSS(css, root);
}
return false;
};