Skip to content

Commit b4bf913

Browse files
committed
Merge branch 'FW-6748' into FW-6748-screenshots
2 parents b30dd35 + ca30f10 commit b4bf913

File tree

2 files changed

+278
-41
lines changed

2 files changed

+278
-41
lines changed

core/src/utils/test/theme.spec.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { newSpecPage } from '@stencil/core/testing';
22

3+
import { Buttons } from '../../components/buttons/buttons';
34
import { CardContent } from '../../components/card-content/card-content';
45
import { Chip } from '../../components/chip/chip';
56
import {
7+
generateColorClasses,
68
generateComponentThemeCSS,
79
generateCSSVars,
810
generateGlobalThemeCSS,
911
getClassList,
1012
getClassMap,
1113
getCustomTheme,
14+
hexToRgb,
1215
injectCSS,
16+
mix,
1317
} from '../theme';
1418

1519
describe('getClassList()', () => {
@@ -281,6 +285,20 @@ describe('injectCSS', () => {
281285

282286
expect(shadowRoot!.innerHTML).toContain(`<style>${css}</style>`);
283287
});
288+
289+
it('should inject CSS into a scoped element', async () => {
290+
const page = await newSpecPage({
291+
components: [Buttons],
292+
html: '<ion-buttons></ion-buttons>',
293+
});
294+
295+
const target = page.body.querySelector('ion-buttons')!;
296+
297+
const css = ':host { background-color: red; }';
298+
injectCSS(css, target);
299+
300+
expect(target.innerHTML).toContain(`<style>${css}</style>`);
301+
});
284302
});
285303

286304
describe('generateGlobalThemeCSS', () => {
@@ -560,3 +578,214 @@ describe('generateComponentThemeCSS', () => {
560578
expect(css).toBe(expectedCSS);
561579
});
562580
});
581+
582+
describe('generateColorClasses', () => {
583+
let consoleWarnSpy: jest.SpyInstance;
584+
585+
beforeEach(() => {
586+
consoleWarnSpy = jest.spyOn(console, 'warn');
587+
// Suppress console.warn output from polluting the test output
588+
consoleWarnSpy.mockImplementation(() => {});
589+
});
590+
591+
afterEach(() => {
592+
consoleWarnSpy.mockRestore();
593+
});
594+
595+
it('should generate color classes for a given theme', () => {
596+
const theme = {
597+
palette: {
598+
light: {
599+
color: {
600+
primary: {
601+
bold: {
602+
base: '#0054e9',
603+
contrast: '#ffffff',
604+
foreground: '#000000',
605+
shade: '#0041c4',
606+
tint: '#0065ff',
607+
},
608+
subtle: {
609+
base: '#0054e9',
610+
contrast: '#ffffff',
611+
foreground: '#000000',
612+
shade: '#0041c4',
613+
tint: '#0065ff',
614+
},
615+
},
616+
},
617+
},
618+
},
619+
};
620+
621+
const css = generateColorClasses(theme).replace(/\s/g, '');
622+
623+
const expectedCSS = `
624+
:root .ion-color-primary {
625+
--ion-color-base: var(--ion-color-primary, var(--ion-color-primary-bold)) !important;
626+
--ion-color-base-rgb: var(--ion-color-primary-rgb, var(--ion-color-primary-bold-rgb)) !important;
627+
--ion-color-contrast: var(--ion-color-primary-contrast, var(--ion-color-primary-bold-contrast)) !important;
628+
--ion-color-contrast-rgb: var(--ion-color-primary-contrast-rgb, var(--ion-color-primary-bold-contrast-rgb)) !important;
629+
--ion-color-shade: var(--ion-color-primary-shade, var(--ion-color-primary-bold-shade)) !important;
630+
--ion-color-tint: var(--ion-color-primary-tint, var(--ion-color-primary-bold-tint)) !important;
631+
--ion-color-foreground: var(--ion-color-primary-foreground, var(--ion-color-primary-bold-foreground)) !important;
632+
633+
--ion-color-subtle-base: var(--ion-color-primary-subtle) !important;
634+
--ion-color-subtle-base-rgb: var(--ion-color-primary-subtle-rgb) !important;
635+
--ion-color-subtle-contrast: var(--ion-color-primary-subtle-contrast) !important;
636+
--ion-color-subtle-contrast-rgb: var(--ion-color-primary-subtle-contrast-rgb) !important;
637+
--ion-color-subtle-shade: var(--ion-color-primary-subtle-shade) !important;
638+
--ion-color-subtle-tint: var(--ion-color-primary-subtle-tint) !important;
639+
--ion-color-subtle-foreground: var(--ion-color-primary-subtle-foreground) !important;
640+
}
641+
`.replace(/\s/g, '');
642+
643+
expect(css).toBe(expectedCSS);
644+
});
645+
646+
it('should not generate color classes for a given theme without colors', () => {
647+
const theme = {
648+
spacing: {
649+
xs: '12px',
650+
sm: '12px',
651+
md: '12px',
652+
lg: '12px',
653+
xl: '12px',
654+
xxl: '12px',
655+
},
656+
};
657+
658+
const css = generateColorClasses(theme).replace(/\s/g, '');
659+
660+
expect(css).toBe('');
661+
});
662+
663+
it('should not generate color classes for a given theme with an invalid string color value', () => {
664+
const theme = {
665+
spacing: {
666+
xs: '12px',
667+
sm: '12px',
668+
md: '12px',
669+
lg: '12px',
670+
xl: '12px',
671+
xxl: '12px',
672+
},
673+
color: 'red',
674+
};
675+
676+
const css = generateColorClasses(theme).replace(/\s/g, '');
677+
678+
// Only check the first log to get the string message
679+
expect(consoleWarnSpy.mock.calls[0][0]).toContain(
680+
'[Ionic Warning]: Invalid color configuration in theme. Expected color to be an object, but found string.'
681+
);
682+
683+
expect(css).toBe('');
684+
});
685+
686+
it('should not generate color classes for a given theme with an invalid array color value', () => {
687+
const theme = {
688+
spacing: {
689+
xs: '12px',
690+
sm: '12px',
691+
md: '12px',
692+
lg: '12px',
693+
xl: '12px',
694+
xxl: '12px',
695+
},
696+
color: ['red', 'blue', 'yellow'],
697+
};
698+
699+
const css = generateColorClasses(theme).replace(/\s/g, '');
700+
701+
// Only check the first log to get the string message
702+
expect(consoleWarnSpy.mock.calls[0][0]).toContain(
703+
'[Ionic Warning]: Invalid color configuration in theme. Expected color to be an object, but found array.'
704+
);
705+
706+
expect(css).toBe('');
707+
});
708+
});
709+
710+
describe('hexToRgb()', () => {
711+
it('should convert 6-digit hex colors to RGB strings', () => {
712+
expect(hexToRgb('#ffffff')).toBe('255, 255, 255');
713+
expect(hexToRgb('#000000')).toBe('0, 0, 0');
714+
expect(hexToRgb('#ff0000')).toBe('255, 0, 0');
715+
expect(hexToRgb('#00ff00')).toBe('0, 255, 0');
716+
expect(hexToRgb('#0000ff')).toBe('0, 0, 255');
717+
expect(hexToRgb('#3880ff')).toBe('56, 128, 255');
718+
});
719+
720+
it('should convert 3-digit hex colors to RGB strings', () => {
721+
expect(hexToRgb('#fff')).toBe('255, 255, 255');
722+
expect(hexToRgb('#000')).toBe('0, 0, 0');
723+
expect(hexToRgb('#f00')).toBe('255, 0, 0');
724+
expect(hexToRgb('#0f0')).toBe('0, 255, 0');
725+
expect(hexToRgb('#00f')).toBe('0, 0, 255');
726+
expect(hexToRgb('#abc')).toBe('170, 187, 204');
727+
});
728+
729+
it('should handle hex colors without hash prefix', () => {
730+
expect(hexToRgb('ffffff')).toBe('255, 255, 255');
731+
expect(hexToRgb('fff')).toBe('255, 255, 255');
732+
expect(hexToRgb('3880ff')).toBe('56, 128, 255');
733+
});
734+
});
735+
736+
describe('mix()', () => {
737+
it('should mix two hex colors by weight percentage', () => {
738+
// Mix white into black
739+
expect(mix('#000000', '#ffffff', '0%')).toBe('#000000');
740+
expect(mix('#000000', '#ffffff', '50%')).toBe('#808080');
741+
expect(mix('#000000', '#ffffff', '100%')).toBe('#ffffff');
742+
});
743+
744+
it('should mix colors with different percentages', () => {
745+
// Mix red into blue
746+
expect(mix('#0000ff', '#ff0000', '25%')).toBe('#4000bf');
747+
expect(mix('#0000ff', '#ff0000', '75%')).toBe('#bf0040');
748+
});
749+
750+
it('should handle 3-digit hex colors', () => {
751+
expect(mix('#000', '#fff', '50%')).toBe('#808080');
752+
expect(mix('#f00', '#0f0', '50%')).toBe('#808000');
753+
754+
// 3-digit + 6-digit
755+
expect(mix('#000', '#ffffff', '50%')).toBe('#808080');
756+
expect(mix('#000000', '#fff', '50%')).toBe('#808080');
757+
});
758+
759+
it('should handle hex colors without hash prefix', () => {
760+
expect(mix('000000', 'ffffff', '50%')).toBe('#808080');
761+
expect(mix('f00', '0f0', '50%')).toBe('#808000');
762+
763+
// With and without hash prefix
764+
expect(mix('#000000', 'ffffff', '50%')).toBe('#808080');
765+
expect(mix('f00', '#0f0', '50%')).toBe('#808000');
766+
});
767+
768+
it('should handle fractional percentages', () => {
769+
expect(mix('#000000', '#ffffff', '12.5%')).toBe('#202020');
770+
expect(mix('#ffffff', '#000000', '87.5%')).toBe('#202020');
771+
});
772+
773+
it('should work with real-world color examples', () => {
774+
// Mix primary Ionic blue with white
775+
expect(mix('#3880ff', '#ffffff', '10%')).toBe('#4c8dff');
776+
777+
// Mix primary Ionic blue with black for shade
778+
expect(mix('#3880ff', '#000000', '12%')).toBe('#3171e0');
779+
});
780+
781+
it('should handle edge cases', () => {
782+
// Same colors should return base color regardless of weight
783+
expect(mix('#ff0000', '#ff0000', '50%')).toBe('#ff0000');
784+
785+
// Zero weight should return base color
786+
expect(mix('#123456', '#abcdef', '0%')).toBe('#123456');
787+
788+
// 100% weight should return mix color
789+
expect(mix('#123456', '#abcdef', '100%')).toBe('#abcdef');
790+
});
791+
});

core/src/utils/theme.ts

Lines changed: 49 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { printIonWarning } from '@utils/logging';
2+
13
import type { Color, CssClassMap } from '../interface';
24

35
import { deepMerge } from './helpers';
@@ -174,7 +176,17 @@ export const generateColorClasses = (theme: any): string => {
174176
// direct color property if there is no light palette
175177
const colors = theme?.palette?.light?.color || theme?.color;
176178

177-
if (!colors || typeof colors !== 'object') {
179+
if (!colors) {
180+
return '';
181+
}
182+
183+
if (typeof colors !== 'object' || Array.isArray(colors)) {
184+
const colorsType = Array.isArray(colors) ? 'array' : typeof colors;
185+
printIonWarning(
186+
`Invalid color configuration in theme. Expected color to be an object, but found ${colorsType}.`,
187+
theme
188+
);
189+
178190
return '';
179191
}
180192

@@ -389,62 +401,58 @@ export const applyComponentTheme = (element: HTMLElement): void => {
389401
};
390402

391403
/**
392-
* Converts a hex color to RGB comma-separated values
393-
* @param hex Hex color (e.g., '#ffffff' or '#fff')
394-
* @returns RGB string (e.g., '255, 255, 255')
404+
* Parses a hex color string and returns RGB values as an array.
405+
*
406+
* @param hex Hex color (e.g. `'#ffffff'` or `'#fff'`)
407+
*
408+
* @returns RGB values as `[r, g, b]` array
395409
*/
396-
export const hexToRgb = (hex: string): string => {
410+
const parseHex = (hex: string): [number, number, number] => {
397411
const cleanHex = hex.replace('#', '');
398412

399-
let r: number, g: number, b: number;
400-
413+
// Short hex format like 'fff' → expand to 'ffffff'
401414
if (cleanHex.length === 3) {
402-
// Short hex format like 'fff' → expand to 'ffffff'
403-
r = parseInt(cleanHex[0] + cleanHex[0], 16);
404-
g = parseInt(cleanHex[1] + cleanHex[1], 16);
405-
b = parseInt(cleanHex[2] + cleanHex[2], 16);
406-
} else {
415+
return [
416+
parseInt(cleanHex[0] + cleanHex[0], 16),
417+
parseInt(cleanHex[1] + cleanHex[1], 16),
418+
parseInt(cleanHex[2] + cleanHex[2], 16),
419+
];
407420
// Full hex format like 'ffffff'
408-
r = parseInt(cleanHex.substr(0, 2), 16);
409-
g = parseInt(cleanHex.substr(2, 2), 16);
410-
b = parseInt(cleanHex.substr(4, 2), 16);
421+
} else {
422+
return [
423+
parseInt(cleanHex.substring(0, 2), 16),
424+
parseInt(cleanHex.substring(2, 4), 16),
425+
parseInt(cleanHex.substring(4, 6), 16),
426+
];
411427
}
428+
};
412429

430+
/**
431+
* Converts a hex color to a string of RGB comma-separated values.
432+
*
433+
* @param hex Hex color (e.g. `'#ffffff'` or `'#fff'`)
434+
*
435+
* @returns RGB string (e.g. `'255, 255, 255'`)
436+
*/
437+
export const hexToRgb = (hex: string): string => {
438+
const [r, g, b] = parseHex(hex);
413439
return `${r}, ${g}, ${b}`;
414440
};
415441

416442
/**
417-
* Mixes two hex colors by a given weight percentage
418-
* @param baseColor Base color (e.g., '#0054e9')
419-
* @param mixColor Color to mix in (e.g., '#000000' or '#fff')
420-
* @param weight Weight percentage as string - how much of mixColor to mix into baseColor (e.g., '12%')
421-
* @returns Mixed hex color
443+
* Mixes two hex colors by a given weight percentage and returns
444+
* it as a hex color.
445+
*
446+
* @param baseColor Base color (e.g. `'#0054e9'`)
447+
* @param mixColor Color to mix in (e.g. `'#000000'` or `'#fff'`)
448+
* @param weight Weight percentage as string - how much of mixColor to mix into baseColor (e.g. `'12%'`)
449+
*
450+
* @returns Mixed hex color (e.g. `'#004acd'`)
422451
*/
423452
export const mix = (baseColor: string, mixColor: string, weight: string): string => {
424453
// Parse weight percentage
425454
const w = parseFloat(weight.replace('%', '')) / 100;
426455

427-
// Parse hex colors
428-
const parseHex = (hex: string): [number, number, number] => {
429-
const cleanHex = hex.replace('#', '');
430-
431-
// Short hex format like 'fff' → expand to 'ffffff'
432-
if (cleanHex.length === 3) {
433-
return [
434-
parseInt(cleanHex[0] + cleanHex[0], 16),
435-
parseInt(cleanHex[1] + cleanHex[1], 16),
436-
parseInt(cleanHex[2] + cleanHex[2], 16),
437-
];
438-
// Full hex format like 'ffffff'
439-
} else {
440-
return [
441-
parseInt(cleanHex.substr(0, 2), 16),
442-
parseInt(cleanHex.substr(2, 2), 16),
443-
parseInt(cleanHex.substr(4, 2), 16),
444-
];
445-
}
446-
};
447-
448456
// Parse both colors
449457
const [baseR, baseG, baseB] = parseHex(baseColor);
450458
const [mixR, mixG, mixB] = parseHex(mixColor);

0 commit comments

Comments
 (0)