Skip to content

feat(material/schematics): Add option to customize colors for neutral variant and error palettes #30321

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/material/schematics/ng-generate/theme-color/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ optimized to have enough contrast to be more accessible. See [Science of Color D
for more information about Material's color design.

For more customization, custom colors can be also be provided for the
secondary, tertiary, and neutral palette colors. It is recommended to choose colors that
are contrastful. Material has more detailed guidance for [accessible design](https://m3.material.io/foundations/accessible-design/patterns).
secondary, tertiary, neutral, neutral variant, and error palette colors. It is recommended to choose
colors that are contrastful. Material has more detailed guidance for [accessible design](https://m3.material.io/foundations/accessible-design/patterns).

## Options

Expand All @@ -30,6 +30,10 @@ secondary color generated from Material based on the primary.
tertiary color generated from Material based on the primary.
* `neutralColor` - Color to use for app's neutral color palette. Defaults to
neutral color generated from Material based on the primary.
* `neutralVariantColor` - Color to use for app's neutral variant color palette. Defaults to
neutral variant color generated from Material based on the primary.
* `errorColor` - Color to use for app's error color palette. Defaults to
error color generated from Material based on the other palettes.
* `includeHighContrast` - Whether to define high contrast values for the custom colors in the
generated file. For Sass files a mixin is defined, see the [high contrast override mixins section](#high-contrast-override-mixins)
for more information. Defaults to false.
Expand Down
202 changes: 202 additions & 0 deletions src/material/schematics/ng-generate/theme-color/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,64 @@ describe('material-theme-color-schematic', () => {
expect(transpileTheme(generatedSCSS)).toBe(transpileTheme(testSCSS));
});

it('should generate themes when provided a primary, secondary, tertiary, neutral, and neutral variant colors', async () => {
const tree = await runM3ThemeSchematic(runner, {
primaryColor: '#984061',
secondaryColor: '#984061',
tertiaryColor: '#984061',
neutralColor: '#984061',
neutralVariantColor: '#984061',
});

const generatedSCSS = tree.readText('_theme-colors.scss');

// Change test theme palette so that secondary, tertiary, and neutral are
// the same source color as primary to match schematic inputs
let testPalettes = testM3ColorPalettes;
testPalettes.secondary = testPalettes.primary;
testPalettes.tertiary = testPalettes.primary;
testPalettes.neutral = testPalettes.primary;
testPalettes.neutralVariant = testPalettes.primary;

const testSCSS = generateSCSSTheme(
testPalettes,
'Color palettes are generated from primary: #984061, secondary: #984061, tertiary: #984061, neutral: #984061, neutral variant: #984061',
);

expect(generatedSCSS).toBe(testSCSS);
expect(transpileTheme(generatedSCSS)).toBe(transpileTheme(testSCSS));
});

it('should generate themes when provided a primary, secondary, tertiary, neutral, neutral variant, and error colors', async () => {
const tree = await runM3ThemeSchematic(runner, {
primaryColor: '#984061',
secondaryColor: '#984061',
tertiaryColor: '#984061',
neutralColor: '#984061',
neutralVariantColor: '#984061',
errorColor: '#984061',
});

const generatedSCSS = tree.readText('_theme-colors.scss');

// Change test theme palette so that secondary, tertiary, and neutral are
// the same source color as primary to match schematic inputs
let testPalettes = testM3ColorPalettes;
testPalettes.secondary = testPalettes.primary;
testPalettes.tertiary = testPalettes.primary;
testPalettes.neutral = testPalettes.primary;
testPalettes.neutralVariant = testPalettes.primary;
testPalettes.error = testPalettes.primary;

const testSCSS = generateSCSSTheme(
testPalettes,
'Color palettes are generated from primary: #984061, secondary: #984061, tertiary: #984061, neutral: #984061, neutral variant: #984061, error: #984061',
);

expect(generatedSCSS).toBe(testSCSS);
expect(transpileTheme(generatedSCSS)).toBe(transpileTheme(testSCSS));
});

describe('and with high contrast overrides', () => {
it('should be able to generate high contrast overrides mixin', async () => {
const tree = await runM3ThemeSchematic(runner, {
Expand Down Expand Up @@ -300,6 +358,63 @@ describe('material-theme-color-schematic', () => {
expect(generatedCSS).toContain(`--mat-sys-tertiary: #ffebef`);
expect(generatedCSS).toContain(`--mat-sys-surface-bright: #4f5051`);
});

it('should be able to generate high contrast themes overrides when provided primary, secondary, tertiary, neutral, and neutral variant color', async () => {
const tree = await runM3ThemeSchematic(runner, {
primaryColor: '#984061',
secondaryColor: '#984061',
tertiaryColor: '#984061',
neutralColor: '#dfdfdf', // Different color since #984061 does not change the tonal palette
neutralVariantColor: '#dfdfdf', // Different color since #984061 does not change the tonal palette
includeHighContrast: true,
});

const generatedCSS = transpileTheme(tree.readText('_theme-colors.scss'));

// Check a system variable from each color palette for their high contrast light theme value
expect(generatedCSS).toContain(`--mat-sys-primary: #580b2f`);
expect(generatedCSS).toContain(`--mat-sys-secondary: #580b2f`);
expect(generatedCSS).toContain(`--mat-sys-tertiary: #580b2f`);
expect(generatedCSS).toContain(`--mat-sys-surface-bright: #f9f9f9`);
expect(generatedCSS).toContain(`--mat-sys-surface-variant: #e2e2e2`);

// Check a system variable from each color palette for their high contrast dark theme value
expect(generatedCSS).toContain(`--mat-sys-primary: #ffebef`);
expect(generatedCSS).toContain(`--mat-sys-secondary: #ffebef`);
expect(generatedCSS).toContain(`--mat-sys-tertiary: #ffebef`);
expect(generatedCSS).toContain(`--mat-sys-surface-bright: #4f5051`);
expect(generatedCSS).toContain(`--mat-sys-surface-variant: #454747`);
});

it('should be able to generate high contrast themes overrides when provided primary, secondary, tertiary, neutral, neutral variant, and error color', async () => {
const tree = await runM3ThemeSchematic(runner, {
primaryColor: '#984061',
secondaryColor: '#984061',
tertiaryColor: '#984061',
neutralColor: '#dfdfdf', // Different color since #984061 does not change the tonal palette
neutralVariantColor: '#dfdfdf', // Different color since #984061 does not change the tonal palette
errorColor: '#984061',
includeHighContrast: true,
});

const generatedCSS = transpileTheme(tree.readText('_theme-colors.scss'));

// Check a system variable from each color palette for their high contrast light theme value
expect(generatedCSS).toContain(`--mat-sys-primary: #580b2f`);
expect(generatedCSS).toContain(`--mat-sys-secondary: #580b2f`);
expect(generatedCSS).toContain(`--mat-sys-tertiary: #580b2f`);
expect(generatedCSS).toContain(`--mat-sys-surface-bright: #f9f9f9`);
expect(generatedCSS).toContain(`--mat-sys-surface-variant: #e2e2e2`);
expect(generatedCSS).toContain(`--mat-sys-error: #580b2f`);

// Check a system variable from each color palette for their high contrast dark theme value
expect(generatedCSS).toContain(`--mat-sys-primary: #ffebef`);
expect(generatedCSS).toContain(`--mat-sys-secondary: #ffebef`);
expect(generatedCSS).toContain(`--mat-sys-tertiary: #ffebef`);
expect(generatedCSS).toContain(`--mat-sys-surface-bright: #4f5051`);
expect(generatedCSS).toContain(`--mat-sys-surface-variant: #454747`);
expect(generatedCSS).toContain(`--mat-sys-error: #ffebef`);
});
});
});

Expand Down Expand Up @@ -405,6 +520,49 @@ describe('material-theme-color-schematic', () => {
expect(generatedCSS).toContain(`--mat-sys-surface-variant: light-dark(#f6dce2, #534247)`);
});

it('should generate CSS system variables when provided a primary, secondary, tertiary, neutral, and neutral variant colors', async () => {
const tree = await runM3ThemeSchematic(runner, {
primaryColor: '#984061',
secondaryColor: '#984061',
tertiaryColor: '#984061',
neutralColor: '#984061',
neutralVariantColor: '#984061',
isScss: false,
});

const generatedCSS = tree.readText('theme.css');

// Check a system variable from each color palette for their light dark value
expect(generatedCSS).toContain(`--mat-sys-primary: light-dark(#984061, #ffb0c8)`);
expect(generatedCSS).toContain(`--mat-sys-secondary: light-dark(#984061, #ffb0c8)`);
expect(generatedCSS).toContain(`--mat-sys-tertiary: light-dark(#984061, #ffb0c8)`);
expect(generatedCSS).toContain(`--mat-sys-error: light-dark(#ba1a1a, #ffb4ab)`);
expect(generatedCSS).toContain(`--mat-sys-surface: light-dark(#fff8f8, #2f0015);`);
expect(generatedCSS).toContain(`--mat-sys-surface-variant: light-dark(#ffd9e2, #7b2949)`);
});

it('should generate CSS system variables when provided a primary, secondary, tertiary, neutral, neutral variant, and error colors', async () => {
const tree = await runM3ThemeSchematic(runner, {
primaryColor: '#984061',
secondaryColor: '#984061',
tertiaryColor: '#984061',
neutralColor: '#984061',
neutralVariantColor: '#984061',
errorColor: '#984061',
isScss: false,
});

const generatedCSS = tree.readText('theme.css');

// Check a system variable from each color palette for their light dark value
expect(generatedCSS).toContain(`--mat-sys-primary: light-dark(#984061, #ffb0c8)`);
expect(generatedCSS).toContain(`--mat-sys-secondary: light-dark(#984061, #ffb0c8)`);
expect(generatedCSS).toContain(`--mat-sys-tertiary: light-dark(#984061, #ffb0c8)`);
expect(generatedCSS).toContain(`--mat-sys-error: light-dark(#984061, #ffb0c8)`);
expect(generatedCSS).toContain(`--mat-sys-surface: light-dark(#fff8f8, #2f0015);`);
expect(generatedCSS).toContain(`--mat-sys-surface-variant: light-dark(#ffd9e2, #7b2949)`);
});

describe('and with high contrast overrides', () => {
it('should generate high contrast system variables', async () => {
const tree = await runM3ThemeSchematic(runner, {
Expand Down Expand Up @@ -485,6 +643,50 @@ describe('material-theme-color-schematic', () => {
expect(generatedCSS).toContain(`--mat-sys-tertiary: light-dark(#580b2f, #ffebef)`);
expect(generatedCSS).toContain(`--mat-sys-surface-bright: light-dark(#f9f9f9, #4f5051)`);
});

it('should generate high contrast system variables when provided primary, secondary, tertiary, neutral, and neutral variant color', async () => {
const tree = await runM3ThemeSchematic(runner, {
primaryColor: '#984061',
secondaryColor: '#984061',
tertiaryColor: '#984061',
neutralColor: '#dfdfdf', // Different color since #984061 does not change the tonal palette
neutralVariantColor: '#dfdfdf', // Different color since #984061 does not change the tonal palette
isScss: false,
includeHighContrast: true,
});

const generatedCSS = tree.readText('theme.css');

// Check a system variable from each color palette for their high contrast light dark value
expect(generatedCSS).toContain(`--mat-sys-primary: light-dark(#580b2f, #ffebef)`);
expect(generatedCSS).toContain(`--mat-sys-secondary: light-dark(#580b2f, #ffebef)`);
expect(generatedCSS).toContain(`--mat-sys-tertiary: light-dark(#580b2f, #ffebef)`);
expect(generatedCSS).toContain(`--mat-sys-surface-bright: light-dark(#f9f9f9, #4f5051)`);
expect(generatedCSS).toContain(`--mat-sys-surface-variant: light-dark(#e2e2e2, #454747);`);
});

it('should generate high contrast system variables when provided primary, secondary, tertiary, neutral, neutral variant, and error color', async () => {
const tree = await runM3ThemeSchematic(runner, {
primaryColor: '#984061',
secondaryColor: '#984061',
tertiaryColor: '#984061',
neutralColor: '#dfdfdf', // Different color since #984061 does not change the tonal palette
neutralVariantColor: '#dfdfdf', // Different color since #984061 does not change the tonal palette
errorColor: '#984061',
isScss: false,
includeHighContrast: true,
});

const generatedCSS = tree.readText('theme.css');

// Check a system variable from each color palette for their high contrast light dark value
expect(generatedCSS).toContain(`--mat-sys-primary: light-dark(#580b2f, #ffebef)`);
expect(generatedCSS).toContain(`--mat-sys-secondary: light-dark(#580b2f, #ffebef)`);
expect(generatedCSS).toContain(`--mat-sys-tertiary: light-dark(#580b2f, #ffebef)`);
expect(generatedCSS).toContain(`--mat-sys-surface-bright: light-dark(#f9f9f9, #4f5051)`);
expect(generatedCSS).toContain(`--mat-sys-surface-variant: light-dark(#e2e2e2, #454747);`);
expect(generatedCSS).toContain(`--mat-sys-error: light-dark(#580b2f, #ffebef)`);
});
});
});
});
Expand Down
66 changes: 52 additions & 14 deletions src/material/schematics/ng-generate/theme-color/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export function getColorPalettes(
secondaryColor?: string,
tertiaryColor?: string,
neutralColor?: string,
neutralVariantColor?: string,
errorColor?: string,
): ColorPalettes {
// Create tonal palettes for each color and custom color overrides if applicable. Used for both
// standard contrast and high contrast schemes since they share the same tonal palettes.
Expand Down Expand Up @@ -157,21 +159,31 @@ export function getColorPalettes(
);
}

const neutralVariantPalette = TonalPalette.fromHueAndChroma(
primaryColorHct.hue,
primaryColorHct.chroma / 8.0 + 4.0,
);
let neutralVariantPalette;
if (neutralVariantColor) {
neutralVariantPalette = TonalPalette.fromHct(getHctFromHex(neutralVariantColor));
} else {
neutralVariantPalette = TonalPalette.fromHueAndChroma(
primaryColorHct.hue,
primaryColorHct.chroma / 8.0 + 4.0,
);
}

// Need to create color scheme to get generated error tonal palette.
const errorPalette = getMaterialDynamicScheme(
primaryPalette,
secondaryPalette,
tertiaryPalette,
neutralPalette,
neutralVariantPalette,
/* isDark */ false,
/* contrastLevel */ 0,
).errorPalette;
let errorPalette;
if (errorColor) {
errorPalette = TonalPalette.fromHct(getHctFromHex(errorColor));
} else {
// Need to create color scheme to get generated error tonal palette.
errorPalette = getMaterialDynamicScheme(
primaryPalette,
secondaryPalette,
tertiaryPalette,
neutralPalette,
neutralVariantPalette,
/* isDark */ false,
/* contrastLevel */ 0,
).errorPalette;
}

return {
primary: primaryPalette,
Expand Down Expand Up @@ -1007,6 +1019,8 @@ function getColorComment(
secondaryColor?: string,
tertiaryColor?: string,
neutralColor?: string,
neutralVariantColor?: string,
errorColor?: string,
) {
let colorComment = 'Color palettes are generated from primary: ' + primaryColor;
if (secondaryColor) {
Expand All @@ -1018,6 +1032,12 @@ function getColorComment(
if (neutralColor) {
colorComment += ', neutral: ' + neutralColor;
}
if (neutralVariantColor) {
colorComment += ', neutral variant: ' + neutralVariantColor;
}
if (errorColor) {
colorComment += ', error: ' + errorColor;
}
return colorComment;
}

Expand All @@ -1028,13 +1048,17 @@ export default function (options: Schema): Rule {
options.secondaryColor,
options.tertiaryColor,
options.neutralColor,
options.neutralVariantColor,
options.errorColor,
);

const colorPalettes = getColorPalettes(
options.primaryColor,
options.secondaryColor,
options.tertiaryColor,
options.neutralColor,
options.neutralVariantColor,
options.errorColor,
);

let lightHighContrastColorScheme: DynamicScheme;
Expand All @@ -1059,6 +1083,13 @@ export default function (options: Schema): Rule {
/* isDark */ true,
/* contrastLevel */ 1.0,
);

// Error palettes get generated by the color scheme's other palettes. Override the generated
// error palette with the custom one if applicable.
if (options.errorColor) {
lightHighContrastColorScheme.errorPalette = colorPalettes.error;
darkHighContrastColorScheme.errorPalette = colorPalettes.error;
}
}

if (options.isScss) {
Expand Down Expand Up @@ -1098,6 +1129,13 @@ export default function (options: Schema): Rule {
/* contrastLevel */ 0,
);

// Error palettes get generated by the color scheme's other palettes. Override the generated
// error palette with the custom one if applicable.
if (options.errorColor) {
lightColorScheme.errorPalette = colorPalettes.error;
darkColorScheme.errorPalette = colorPalettes.error;
}

themeCss += getAllSysVariablesCSS(lightColorScheme, darkColorScheme);

// Add high contrast media query to overwrite the color values when the user specifies
Expand Down
8 changes: 8 additions & 0 deletions src/material/schematics/ng-generate/theme-color/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ export interface Schema {
* Color to override the neutral color palette.
*/
neutralColor?: string;
/**
* Color to override the neutral variant color palette.
*/
neutralVariantColor?: string;
/**
* Color to override the error color palette.
*/
errorColor?: string;
/**
* Whether to create high contrast override theme mixins.
*/
Expand Down
Loading