diff --git a/core/src/components/back-button/back-button.tsx b/core/src/components/back-button/back-button.tsx index 4c4d738fb7e..cecaf32ebd3 100644 --- a/core/src/components/back-button/back-button.tsx +++ b/core/src/components/back-button/back-button.tsx @@ -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'; diff --git a/core/src/components/breadcrumb/breadcrumb.tsx b/core/src/components/breadcrumb/breadcrumb.tsx index 248b2e3f22c..561645e6587 100644 --- a/core/src/components/breadcrumb/breadcrumb.tsx +++ b/core/src/components/breadcrumb/breadcrumb.tsx @@ -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'; diff --git a/core/src/components/button/button.tsx b/core/src/components/button/button.tsx index ccf20ba012d..1b7e8aae03e 100644 --- a/core/src/components/button/button.tsx +++ b/core/src/components/button/button.tsx @@ -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'; diff --git a/core/src/components/card/card.tsx b/core/src/components/card/card.tsx index f52b3fad8dd..746ac85d999 100644 --- a/core/src/components/card/card.tsx +++ b/core/src/components/card/card.tsx @@ -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'; diff --git a/core/src/components/fab-button/fab-button.tsx b/core/src/components/fab-button/fab-button.tsx index b04ae8e98b1..17fb8bec972 100755 --- a/core/src/components/fab-button/fab-button.tsx +++ b/core/src/components/fab-button/fab-button.tsx @@ -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'; diff --git a/core/src/components/item/item.tsx b/core/src/components/item/item.tsx index e7c95b4ade9..fe53819ac98 100644 --- a/core/src/components/item/item.tsx +++ b/core/src/components/item/item.tsx @@ -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'; diff --git a/core/src/components/router-link/router-link.tsx b/core/src/components/router-link/router-link.tsx index 3649a339b48..326c956fa58 100644 --- a/core/src/components/router-link/router-link.tsx +++ b/core/src/components/router-link/router-link.tsx @@ -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'; diff --git a/core/src/global/ionic-global.ts b/core/src/global/ionic-global.ts index 6788304e821..3e90161070b 100644 --- a/core/src/global/ionic-global.ts +++ b/core/src/global/ionic-global.ts @@ -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); } diff --git a/core/src/index.ts b/core/src/index.ts index 6ebc176eb36..21b6e605df3 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -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, diff --git a/core/src/themes/base/default.tokens.ts b/core/src/themes/base/default.tokens.ts index e69de29bb2d..7278514d484 100644 --- a/core/src/themes/base/default.tokens.ts +++ b/core/src/themes/base/default.tokens.ts @@ -0,0 +1,10 @@ +export const defaultTheme = { + palette: { + light: {}, + dark: { + enabled: 'system', + }, + }, +}; + +export type Theme = typeof defaultTheme; diff --git a/core/src/utils/config.ts b/core/src/utils/config.ts index 24ea27b73da..73aebbd6f0e 100644 --- a/core/src/utils/config.ts +++ b/core/src/utils/config.ts @@ -364,6 +364,9 @@ export interface IonicConfig { scrollAssist?: boolean; hideCaretOnScroll?: boolean; + // Theme configs + customTheme?: any; + // INTERNAL configs // TODO(FW-2832): types persistConfig?: boolean; diff --git a/core/src/utils/helpers.spec.ts b/core/src/utils/helpers.spec.ts index 44dd9d8c3ce..40b4f44a9c8 100644 --- a/core/src/utils/helpers.spec.ts +++ b/core/src/utils/helpers.spec.ts @@ -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 }); + }); +}); diff --git a/core/src/utils/helpers.ts b/core/src/utils/helpers.ts index 2d03b1e6844..17a563b601e 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -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 => { + 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; +}; diff --git a/core/src/utils/theme.spec.ts b/core/src/utils/theme.spec.ts new file mode 100644 index 00000000000..22c64d2c01c --- /dev/null +++ b/core/src/utils/theme.spec.ts @@ -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(``); + }); + + it('should inject CSS into an element', async () => { + const page = await newSpecPage({ + components: [CardContent], + html: '', + }); + + const target = page.body.querySelector('ion-card-content')!; + + const css = ':host { background-color: red; }'; + injectCSS(css, target); + + expect(target.innerHTML).toContain(``); + }); + + it('should inject CSS into an element with a shadow root', async () => { + const page = await newSpecPage({ + components: [Chip], + html: '', + }); + + 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(``); + }); +}); + +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); + }); +}); diff --git a/core/src/utils/theme.ts b/core/src/utils/theme.ts index df2d49a1b58..e5d2c32cdc5 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -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 []; + } + + // 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(); + } + + // Special handling for 'base' property - don't add suffix + if (key === 'base') { + return [`${prefix.slice(0, -1)}: ${val};`]; + } -export const openURL = async ( - url: string | undefined | null, - ev: Event | undefined | null, - direction: RouterDirection, - animation?: AnimationBuilder -): Promise => { - if (url != null && url[0] !== '#' && !SCHEME.test(url)) { - const router = document.querySelector('ion-router'); - if (router) { - if (ev != null) { - ev.preventDefault(); + // 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 router.push(url, direction, animation); + + 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 false; + + 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('-'); + + // Remove 'ion-' prefix to get 'chip' + const componentName = parts.slice(1).join('-'); + + // 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); + } };