mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-05 21:58:20 +08:00
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:
committed by
Brandy Smith
parent
fea1e64920
commit
1aa7c35da1
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
export const defaultTheme = {
|
||||
palette: {
|
||||
light: {},
|
||||
dark: {
|
||||
enabled: 'system',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Theme = typeof defaultTheme;
|
||||
|
||||
@ -364,6 +364,9 @@ export interface IonicConfig {
|
||||
scrollAssist?: boolean;
|
||||
hideCaretOnScroll?: boolean;
|
||||
|
||||
// Theme configs
|
||||
customTheme?: any;
|
||||
|
||||
// INTERNAL configs
|
||||
// TODO(FW-2832): types
|
||||
persistConfig?: boolean;
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
362
core/src/utils/theme.spec.ts
Normal file
362
core/src/utils/theme.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user