Files
ionic-framework/core/src/global/ionic-global.ts
Brandy Smith 1aa7c35da1 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>
2025-09-25 15:39:40 -04:00

337 lines
10 KiB
TypeScript

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';
import { config, configFromSession, configFromURL, saveConfig } from './config';
let defaultMode: Mode;
let defaultTheme: Theme = 'md';
/**
* Prints a warning message to the developer to inform them of
* an invalid configuration of mode and theme.
* @param mode The invalid mode configuration.
* @param theme The invalid theme configuration.
*/
const printInvalidModeWarning = (mode: Mode, theme: Theme, ref?: any) => {
printIonWarning(
`Invalid mode and theme combination provided: mode: ${mode}, theme: ${theme}. Fallback mode ${getDefaultModeForTheme(
theme
)} will be used.`,
ref
);
};
/**
* Validates if a mode is accepted for a theme configuration.
* @param mode The mode to validate.
* @param theme The theme the mode is being used with.
* @returns `true` if the mode is valid for the theme, `false` if invalid.
*/
export const isModeValidForTheme = (mode: Mode, theme: Theme) => {
if (mode === 'md') {
return theme === 'md' || theme === 'ionic';
} else if (mode === 'ios') {
return theme === 'ios' || theme === 'ionic';
}
return false;
};
/**
* Returns the default mode for a specified theme.
* @param theme The theme to return a default mode for.
* @returns The default mode, either `ios` or `md`.
*/
const getDefaultModeForTheme = (theme: Theme): Mode => {
if (theme === 'ios') {
return 'ios';
}
return 'md';
};
/**
* Returns the default theme for a specified mode.
* @param mode The mode to return a default theme for.
* @returns The default theme.
*/
const getDefaultThemeForMode = (mode: Mode): Theme => {
if (mode === 'ios') {
return 'ios';
}
return 'md';
};
const isModeSupported = (elmMode: string) => ['ios', 'md'].includes(elmMode);
const isThemeSupported = (theme: string) => ['ios', 'md', 'ionic'].includes(theme);
const isIonicElement = (elm: HTMLElement) => elm.tagName?.startsWith('ION-');
/**
* Returns the mode value of the element reference or the closest
* parent with a valid mode.
* @param ref The element reference to look up the mode for.
* @param theme Optionally can provide the theme to avoid an additional look-up.
* @returns The mode value for the element reference.
*/
export const getIonMode = (ref?: any, theme = getIonTheme(ref)): Mode => {
if (ref?.mode && isModeValidForTheme(ref?.mode, theme)) {
/**
* If the reference already has a mode configuration,
* use it instead of performing a look-up.
*/
return ref.mode;
} else {
const el = getElement(ref);
const mode = (el.closest('[mode]')?.getAttribute('mode') as Mode) || defaultMode;
if (isModeValidForTheme(mode, theme)) {
/**
* The mode configuration is supported for the configured theme.
*/
return mode;
} else {
printInvalidModeWarning(mode, theme, ref);
}
}
return getDefaultModeForTheme(theme);
};
/**
* Returns the theme value of the element reference or the closest
* parent with a valid theme.
*
* @param ref The element reference to look up the theme for.
* @returns The theme value for the element reference, defaults to
* the default theme if it cannot be determined.
*/
export const getIonTheme = (ref?: any): Theme => {
const theme: Theme = ref && getMode<Theme>(ref);
if (theme) {
return theme;
}
/**
* If the theme cannot be detected, then fallback to using
* the `mode` attribute to determine the style sheets to use.
*/
const el = getElement(ref);
const mode = ref?.mode ?? (el.closest('[mode]')?.getAttribute('mode') as Mode);
if (mode) {
return getDefaultThemeForMode(mode);
}
/**
* If a mode is not detected, then fallback to using the
* default theme.
*/
return defaultTheme;
};
export const rIC = (callback: () => void) => {
if ('requestIdleCallback' in window) {
(window as any).requestIdleCallback(callback);
} else {
setTimeout(callback, 32);
}
};
export const needInputShims = () => {
/**
* iOS always needs input shims
*/
const needsShimsIOS = isPlatform(window, 'ios') && isPlatform(window, 'mobile');
if (needsShimsIOS) {
return true;
}
/**
* Android only needs input shims when running
* in the browser and only if the browser is using the
* new Chrome 108+ resize behavior: https://developer.chrome.com/blog/viewport-resize-behavior/
*/
const isAndroidMobileWeb = isPlatform(window, 'android') && isPlatform(window, 'mobileweb');
if (isAndroidMobileWeb) {
return true;
}
return false;
};
export const initialize = (userConfig: IonicConfig = {}) => {
if (typeof (window as any) === 'undefined') {
return;
}
const doc = window.document;
const win = window;
const Ionic = ((win as any).Ionic = (win as any).Ionic || {});
// create the Ionic.config from raw config object (if it exists)
// and convert Ionic.config into a ConfigApi that has a get() fn
const configObj = {
...configFromSession(win),
persistConfig: false,
...Ionic.config,
...configFromURL(win),
...userConfig,
};
config.reset(configObj);
if (config.getBoolean('persistConfig')) {
saveConfig(win, configObj);
}
// Setup platforms
setupPlatforms(win);
Ionic.config = config;
/**
* Check if the mode was set as an attribute on <html>
* which could have been set by the user, or by pre-rendering
* otherwise get the mode via config settings, and fallback to md.
*/
Ionic.mode = defaultMode = config.get(
'mode',
doc.documentElement.getAttribute('mode') || (isPlatform(win, 'ios') ? 'ios' : 'md')
);
/**
* Check if the theme was set as an attribute on <html>
* which could have been set by the user, or by pre-rendering
* otherwise get the theme via config settings, and fallback to md.
*/
Ionic.theme = defaultTheme = config.get(
'theme',
doc.documentElement.getAttribute('theme') || getDefaultThemeForMode(defaultMode)
);
if (!isModeValidForTheme(defaultMode, defaultTheme)) {
printInvalidModeWarning(defaultMode, defaultTheme, configObj);
defaultMode = getDefaultModeForTheme(defaultTheme);
}
config.set('mode', defaultMode);
doc.documentElement.setAttribute('mode', defaultMode);
doc.documentElement.classList.add(defaultMode);
config.set('theme', defaultTheme);
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);
}
setMode((elm: any) => {
/**
* Iterate over all the element nodes, to both validate and
* set the "mode" that is used for determining the styles to
* apply to the element.
*
* setMode refers to Stencil's internal metadata for "mode",
* which is used to set the correct styleUrl for the component.
*
* If the "theme" attribute or property is set, then use it
* to determine the style sheets to use.
*
* If the "mode" attribute or property is set, then use it
* to determine the style sheets to use. This is fallback
* behavior for applications that are not setting the "theme".
*/
while (elm) {
const theme = elm.getAttribute('theme');
if (theme) {
if (isThemeSupported(theme)) {
return theme;
} else if (isIonicElement(elm)) {
printIonWarning(`Invalid theme: "${theme}". Supported themes include: "ios" or "md".`);
}
}
/**
* If a theme is not detected, then fallback to using the
* `mode` attribute to determine the style sheets to use.
*/
const elmMode = elm.getAttribute('mode');
if (elmMode) {
if (isModeSupported(elmMode)) {
return elmMode;
} else if (isIonicElement(elm)) {
printIonWarning(`Invalid mode: "${elmMode}". Ionic modes can be only "ios" or "md"`);
}
}
elm = elm.parentElement;
}
return defaultTheme;
});
// `IonApp` code
// ----------------------------------------------
if (Build.isBrowser) {
rIC(async () => {
const isHybrid = isPlatform(window, 'hybrid');
if (!config.getBoolean('_testing')) {
import('../utils/tap-click').then((module) => module.startTapClick(config));
}
if (config.getBoolean('statusTap', isHybrid)) {
import('../utils/status-tap').then((module) => module.startStatusTap());
}
if (config.getBoolean('inputShims', needInputShims())) {
/**
* needInputShims() ensures that only iOS and Android
* platforms proceed into this block.
*/
const platform = isPlatform(window, 'ios') ? 'ios' : 'android';
import('../utils/input-shims/input-shims').then((module) => module.startInputShims(config, platform));
}
const hardwareBackButtonModule = await import('../utils/hardware-back-button');
const supportsHardwareBackButtonEvents = isHybrid || shouldUseCloseWatcher();
if (config.getBoolean('hardwareBackButton', supportsHardwareBackButtonEvents)) {
hardwareBackButtonModule.startHardwareBackButton();
} else {
/**
* If an app sets hardwareBackButton: false and experimentalCloseWatcher: true
* then the close watcher will not be used.
*/
if (shouldUseCloseWatcher()) {
printIonWarning(
'[ion-app] - experimentalCloseWatcher was set to `true`, but hardwareBackButton was set to `false`. Both config options must be `true` for the Close Watcher API to be used.'
);
}
hardwareBackButtonModule.blockHardwareBackButton();
}
if (typeof (window as any) !== 'undefined') {
import('../utils/keyboard/keyboard').then((module) => module.startKeyboardAssist(window));
}
import('../utils/focus-visible').then((module) => module.getOrInitFocusVisibleUtility());
});
}
};
export default initialize;