|
1 | 1 | 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'; |
2 | 9 |
|
3 | 10 | export const hostContext = (selector: string, el: HTMLElement): boolean => { |
4 | 11 | return el.closest(selector) !== null; |
@@ -33,3 +40,191 @@ export const getClassMap = (classes: string | string[] | undefined): CssClassMap |
33 | 40 | getClassList(classes).forEach((c) => (map[c] = true)); |
34 | 41 | return map; |
35 | 42 | }; |
| 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