From 3cfd9309bffa1786182e69cbd6f716d78a9208e0 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:29:59 -0400 Subject: [PATCH 1/7] refactor(utils): move openUrl to helpers --- .../components/back-button/back-button.tsx | 4 ++-- core/src/components/breadcrumb/breadcrumb.tsx | 4 ++-- core/src/components/button/button.tsx | 4 ++-- core/src/components/card/card.tsx | 4 ++-- core/src/components/fab-button/fab-button.tsx | 4 ++-- core/src/components/item/item.tsx | 4 ++-- .../components/router-link/router-link.tsx | 3 ++- core/src/index.ts | 3 +-- core/src/utils/helpers.ts | 22 ++++++++++++++++++ core/src/utils/theme.ts | 23 +------------------ 10 files changed, 38 insertions(+), 37 deletions(-) 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/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/utils/helpers.ts b/core/src/utils/helpers.ts index 2d03b1e6844..e4a32f07d2f 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,23 @@ 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; +}; diff --git a/core/src/utils/theme.ts b/core/src/utils/theme.ts index df2d49a1b58..d8690c29645 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -1,5 +1,4 @@ -import type { RouterDirection } from '../components/router/utils/interface'; -import type { AnimationBuilder, Color, CssClassMap } from '../interface'; +import type { Color, CssClassMap } from '../interface'; export const hostContext = (selector: string, el: HTMLElement): boolean => { return el.closest(selector) !== null; @@ -34,23 +33,3 @@ export const getClassMap = (classes: string | string[] | undefined): CssClassMap getClassList(classes).forEach((c) => (map[c] = true)); return map; }; - -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; -}; From 6e84ff39abeebfdd5f6f5c71a15cd8e288d8cb84 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Fri, 22 Aug 2025 17:52:26 -0400 Subject: [PATCH 2/7] feat(themes): add support for custom themes from design tokens --- core/src/global/ionic-global.ts | 13 ++ core/src/themes/base/default.tokens.ts | 10 ++ core/src/utils/config.ts | 3 + core/src/utils/helpers.ts | 20 +++ core/src/utils/theme.ts | 195 +++++++++++++++++++++++++ 5 files changed, 241 insertions(+) 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/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.ts b/core/src/utils/helpers.ts index e4a32f07d2f..bcf58dfda62 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -456,3 +456,23 @@ export const openURL = async ( } 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 (new object, doesn't modify original) + */ +export const deepMerge = (target: any, source: any): any => { + // Create a new object to avoid modifying the original + 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.ts b/core/src/utils/theme.ts index d8690c29645..4ba6c6030c4 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -1,4 +1,11 @@ import type { Color, CssClassMap } from '../interface'; +import type { Theme } from '../themes/base/default.tokens'; + +import { deepMerge } from './helpers'; + +// Global constants +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; @@ -33,3 +40,191 @@ export const getClassMap = (classes: string | string[] | undefined): CssClassMap getClassList(classes).forEach((c) => (map[c] = true)); return map; }; + +/** + * 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 + */ +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};`]; + } + + // 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 + */ +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 + */ +const generateGlobalThemeCSS = (theme: Theme): 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 ''; + } + + const { palette, ...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: Theme, 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 theme The theme object to generate CSS for + * @param componentName The component name without any prefixes (e.g., 'chip') + * @returns string containing the component's themed CSS variables + */ +const generateComponentThemeCSS = (theme: Theme, componentName: string): string => { + const cssProps = generateCSSVars(theme, `${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); + } +}; From 731640475da9d0c99a9ac01ee15a8a156ff528a4 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:16:02 -0400 Subject: [PATCH 3/7] test(utils): add deepMerge spec test --- core/src/utils/helpers.spec.ts | 25 ++++++++++++++++++++++++- core/src/utils/helpers.ts | 3 +-- 2 files changed, 25 insertions(+), 3 deletions(-) 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 bcf58dfda62..17a563b601e 100644 --- a/core/src/utils/helpers.ts +++ b/core/src/utils/helpers.ts @@ -461,10 +461,9 @@ export const openURL = async ( * 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 (new object, doesn't modify original) + * @returns The merged object */ export const deepMerge = (target: any, source: any): any => { - // Create a new object to avoid modifying the original const result = { ...target }; for (const key in source) { From 590647a4c12bf9555903180f0c72628c77575272 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 25 Aug 2025 14:54:20 -0400 Subject: [PATCH 4/7] test(theme): add spec test for theme utils --- core/src/utils/theme.spec.ts | 273 +++++++++++++++++++++++++++++++++++ core/src/utils/theme.ts | 16 +- 2 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 core/src/utils/theme.spec.ts diff --git a/core/src/utils/theme.spec.ts b/core/src/utils/theme.spec.ts new file mode 100644 index 00000000000..4ff1bed3f55 --- /dev/null +++ b/core/src/utils/theme.spec.ts @@ -0,0 +1,273 @@ +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(``); + }); +}); + +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 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 4ba6c6030c4..933b4c6544e 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -1,9 +1,7 @@ import type { Color, CssClassMap } from '../interface'; -import type { Theme } from '../themes/base/default.tokens'; import { deepMerge } from './helpers'; -// Global constants export const CSS_PROPS_PREFIX = '--ion-'; export const CSS_ROOT_SELECTOR = ':root'; @@ -47,7 +45,7 @@ export const getClassMap = (classes: string | string[] | undefined): CssClassMap * @param prefix The CSS prefix to use (e.g., '--ion-') * @returns CSS string with custom properties */ -const generateCSSVars = (theme: any, prefix: string = CSS_PROPS_PREFIX): string => { +export const generateCSSVars = (theme: any, prefix: string = CSS_PROPS_PREFIX): string => { const cssProps = Object.entries(theme) .flatMap(([key, val]) => { // Skip invalid keys or values @@ -95,7 +93,7 @@ const generateCSSVars = (theme: any, prefix: string = CSS_PROPS_PREFIX): string * @param css The CSS string to inject * @param target The target element to inject into */ -const injectCSS = (css: string, target: Element | ShadowRoot = document.head) => { +export const injectCSS = (css: string, target: Element | ShadowRoot = document.head) => { const style = document.createElement('style'); style.innerHTML = css; target.appendChild(style); @@ -106,7 +104,7 @@ const injectCSS = (css: string, target: Element | ShadowRoot = document.head) => * @param theme The theme object to generate CSS for * @returns The generated CSS string */ -const generateGlobalThemeCSS = (theme: Theme): string => { +export const generateGlobalThemeCSS = (theme: any): string => { if (typeof theme !== 'object' || Array.isArray(theme)) { console.warn('generateGlobalThemeCSS: Invalid theme object provided', theme); return ''; @@ -156,7 +154,7 @@ const generateGlobalThemeCSS = (theme: Theme): string => { * @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: Theme, userTheme?: any): any => { +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); @@ -181,12 +179,12 @@ export const applyGlobalTheme = (baseTheme: Theme, userTheme?: any): any => { /** * Generates component's themed CSS class with CSS variables * from its theme object - * @param theme The theme object to generate CSS for + * @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 */ -const generateComponentThemeCSS = (theme: Theme, componentName: string): string => { - const cssProps = generateCSSVars(theme, `${CSS_PROPS_PREFIX}${componentName}-`); +export const generateComponentThemeCSS = (componentTheme: any, componentName: string): string => { + const cssProps = generateCSSVars(componentTheme, `${CSS_PROPS_PREFIX}${componentName}-`); return ` :host(.${componentName}-themed) { From 89de2f9bf75684f1c872621547ea5d3950be296f Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:38:10 -0400 Subject: [PATCH 5/7] test(theme): add more tests for injectCSS --- core/src/utils/theme.spec.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/core/src/utils/theme.spec.ts b/core/src/utils/theme.spec.ts index 4ff1bed3f55..c185506328b 100644 --- a/core/src/utils/theme.spec.ts +++ b/core/src/utils/theme.spec.ts @@ -1,3 +1,8 @@ +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', () => { @@ -72,6 +77,36 @@ describe('injectCSS', () => { 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', () => { From b91899e753c2ba2802e5148b854a517d7f3ccccc Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:33:41 -0400 Subject: [PATCH 6/7] fix(theme): do not generate global root vars for component level vars --- core/src/utils/theme.spec.ts | 54 ++++++++++++++++++++++++++++++++++++ core/src/utils/theme.ts | 3 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/core/src/utils/theme.spec.ts b/core/src/utils/theme.spec.ts index c185506328b..22c64d2c01c 100644 --- a/core/src/utils/theme.spec.ts +++ b/core/src/utils/theme.spec.ts @@ -204,6 +204,60 @@ describe('generateGlobalThemeCSS', () => { 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: { diff --git a/core/src/utils/theme.ts b/core/src/utils/theme.ts index 933b4c6544e..e5d2c32cdc5 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -115,7 +115,8 @@ export const generateGlobalThemeCSS = (theme: any): string => { return ''; } - const { palette, ...defaultTokens } = theme; + // 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); From d7b0414fbf04c9df1fef987c5e1dcf1bd742c6d7 Mon Sep 17 00:00:00 2001 From: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> Date: Thu, 28 Aug 2025 16:12:48 -0400 Subject: [PATCH 7/7] refactor: improve part reference --- core/src/utils/theme.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/utils/theme.ts b/core/src/utils/theme.ts index e5d2c32cdc5..5c8ae845d10 100644 --- a/core/src/utils/theme.ts +++ b/core/src/utils/theme.ts @@ -205,8 +205,8 @@ export const applyComponentTheme = (element: HTMLElement): void => { // 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('-'); + // 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('');