Skip to content

Commit 0f4d186

Browse files
committedJun 11, 2024·
fix(material/schematics): estimate missing hues in M3 schematic (#29231)
We use `@material/material-color-utilities` to generate the palettes in the M3 `ng generate` schematic, but it appears that the utilities don't generate all the neutral hues for some colors which leads to transparent dropdowns (see #29157). We handle this in our built in palettes by estimating the missing hues based on the hues around them. These changes port the estimation logic to the schematic to ensure that we always generate full themes. Fixes #29157. (cherry picked from commit 3da4323)
1 parent 6dd1689 commit 0f4d186

File tree

3 files changed

+169
-2
lines changed

3 files changed

+169
-2
lines changed
 

‎src/material/core/theming/_palettes.scss

+2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
/// The Material Design spec references some neutral hues that are not generated by
1313
/// https://m3.material.io/theme-builder. For now we use this function to estimate the missing hues
1414
/// by blending the nearest hues that are generated.
15+
/// Note: when updating, the corresponding logic in the theme generation schematic should be
16+
/// updated as well. See `src/material/schematics/ng-generate/m3-theme/index.ts#patchMissingHues`
1517
@function _patch-missing-hues($palette) {
1618
$neutral: map.get($palette, neutral);
1719
$palette: map.set($palette, neutral, 4, _estimate-hue($neutral, 4, 0, 10));

‎src/material/schematics/ng-generate/m3-theme/index.spec.ts

+43
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,49 @@ describe('material-m3-theme-schematic', () => {
275275
expect(generatedCSS).toContain(`--sys-primary: ${primaryColor}`);
276276
expect(generatedCSS).toContain('var(--sys-primary)');
277277
});
278+
279+
it('should estimate missing neutral hues', async () => {
280+
const tree = await runM3ThemeSchematic(runner, {
281+
primaryColor: '#232e62',
282+
secondaryColor: '#cc862a',
283+
tertiaryColor: '#44263e',
284+
neutralColor: '#929093',
285+
themeTypes: 'light',
286+
});
287+
288+
expect(tree.readContent('m3-theme.scss')).toContain(
289+
[
290+
` neutral: (`,
291+
` 0: #000000,`,
292+
` 4: #000527,`,
293+
` 6: #00073a,`,
294+
` 10: #000c61,`,
295+
` 12: #051166,`,
296+
` 17: #121e71,`,
297+
` 20: #1a2678,`,
298+
` 22: #1f2b7d,`,
299+
` 24: #243082,`,
300+
` 25: #273384,`,
301+
` 30: #333f90,`,
302+
` 35: #404b9c,`,
303+
` 40: #4c57a9,`,
304+
` 50: #6570c4,`,
305+
` 60: #7f8ae0,`,
306+
` 70: #9aa5fd,`,
307+
` 80: #bcc2ff,`,
308+
` 87: #d5d7ff,`,
309+
` 90: #dfe0ff,`,
310+
` 92: #e6e6ff,`,
311+
` 94: #edecff,`,
312+
` 95: #f0efff,`,
313+
` 96: #f4f2ff,`,
314+
` 98: #fbf8ff,`,
315+
` 99: #fffbff,`,
316+
` 100: #ffffff,`,
317+
` ),`,
318+
].join('\n'),
319+
);
320+
});
278321
});
279322

280323
function getTestTheme() {

‎src/material/schematics/ng-generate/m3-theme/index.ts

+124-2
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,25 @@ import {
1919
// tonal palettes then get used to create the different color roles (ex.
2020
// on-primary) https://m3.material.io/styles/color/system/how-the-system-works
2121
const HUE_TONES = [0, 10, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100];
22+
// Map of neutral hues to the previous/next hues that
23+
// can be used to estimate them, in case they're missing.
24+
const NEUTRAL_HUES = new Map<number, {prev: number; next: number}>([
25+
[4, {prev: 0, next: 10}],
26+
[6, {prev: 0, next: 10}],
27+
[12, {prev: 10, next: 20}],
28+
[17, {prev: 10, next: 20}],
29+
[22, {prev: 20, next: 25}],
30+
[24, {prev: 20, next: 25}],
31+
[87, {prev: 80, next: 90}],
32+
[92, {prev: 90, next: 95}],
33+
[94, {prev: 90, next: 95}],
34+
[96, {prev: 95, next: 98}],
35+
]);
36+
2237
// Note: Some of the color tokens refer to additional hue tones, but this only
2338
// applies for the neutral color palette (ex. surface container is neutral
2439
// palette's 94 tone). https://m3.material.io/styles/color/static/baseline
25-
const NEUTRAL_HUE_TONES = HUE_TONES.concat([4, 6, 12, 17, 22, 24, 87, 92, 94, 96]);
40+
const NEUTRAL_HUE_TONES = [...HUE_TONES, ...NEUTRAL_HUES.keys()];
2641

2742
/**
2843
* Gets color tonal palettes generated by Material from the provided color.
@@ -117,7 +132,7 @@ export function generateSCSSTheme(
117132
"@use '@angular/material' as mat;",
118133
'',
119134
'// Note: ' + colorComment,
120-
'$_palettes: ' + getColorPalettesSCSS(colorPalettes),
135+
'$_palettes: ' + getColorPalettesSCSS(patchMissingHues(colorPalettes)),
121136
'',
122137
'$_rest: (',
123138
' secondary: map.get($_palettes, secondary),',
@@ -192,3 +207,110 @@ export default function (options: Schema): Rule {
192207
createThemeFile(themeScss, tree, options.directory);
193208
};
194209
}
210+
211+
/**
212+
* The hue map produced by `material-color-utilities` may miss some neutral hues depending on
213+
* the provided colors. This function estimates the missing hues based on the generated ones
214+
* to ensure that we always produce a full palette. See #29157.
215+
*
216+
* This is a TypeScript port of the logic in `core/theming/_palettes.scss#_patch-missing-hues`.
217+
*/
218+
function patchMissingHues(
219+
palettes: Map<string, Map<number, string>>,
220+
): Map<string, Map<number, string>> {
221+
const neutral = palettes.get('neutral');
222+
223+
if (!neutral) {
224+
return palettes;
225+
}
226+
227+
let newNeutral: Map<number, string> | null = null;
228+
229+
for (const [hue, {prev, next}] of NEUTRAL_HUES) {
230+
if (!neutral.has(hue) && neutral.has(prev) && neutral.has(next)) {
231+
const weight = (next - hue) / (next - prev);
232+
const result = mixColors(neutral.get(prev)!, neutral.get(next)!, weight);
233+
234+
if (result !== null) {
235+
newNeutral ??= new Map(neutral.entries());
236+
newNeutral.set(hue, result);
237+
}
238+
}
239+
}
240+
241+
if (!newNeutral) {
242+
return palettes;
243+
}
244+
245+
// Create a new map so we don't mutate the one that was passed in.
246+
const newPalettes = new Map<string, Map<number, string>>();
247+
for (const [key, value] of palettes) {
248+
if (key === 'neutral') {
249+
// Maps keep the order of their keys which can make the newly-added
250+
// ones look out of place. Re-sort the the keys in ascending order.
251+
const sortedNeutral = Array.from(newNeutral.keys())
252+
.sort((a, b) => a - b)
253+
.reduce((newHues, key) => {
254+
newHues.set(key, newNeutral.get(key)!);
255+
return newHues;
256+
}, new Map<number, string>());
257+
newPalettes.set(key, sortedNeutral);
258+
} else {
259+
newPalettes.set(key, value);
260+
}
261+
}
262+
263+
return newPalettes;
264+
}
265+
266+
/**
267+
* TypeScript port of the `color.mix` function from Sass, simplified to only deal with hex colors.
268+
* See https://github.com/sass/dart-sass/blob/main/lib/src/functions/color.dart#L803
269+
*
270+
* @param c1 First color to use in the mixture.
271+
* @param c2 Second color to use in the mixture.
272+
* @param weight Proportion of the first color to use in the mixture.
273+
* Should be a number between 0 and 1.
274+
*/
275+
function mixColors(c1: string, c2: string, weight: number): string | null {
276+
const normalizedWeight = weight * 2 - 1;
277+
const weight1 = (normalizedWeight + 1) / 2;
278+
const weight2 = 1 - weight1;
279+
const color1 = parseHexColor(c1);
280+
const color2 = parseHexColor(c2);
281+
282+
if (color1 === null || color2 === null) {
283+
return null;
284+
}
285+
286+
const red = Math.round(color1.red * weight1 + color2.red * weight2);
287+
const green = Math.round(color1.green * weight1 + color2.green * weight2);
288+
const blue = Math.round(color1.blue * weight1 + color2.blue * weight2);
289+
const intToHex = (value: number) => value.toString(16).padStart(2, '0');
290+
291+
return `#${intToHex(red)}${intToHex(green)}${intToHex(blue)}`;
292+
}
293+
294+
/** Parses a hex color to its numeric red, green and blue values. */
295+
function parseHexColor(value: string): {red: number; green: number; blue: number} | null {
296+
if (!/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(value)) {
297+
return null;
298+
}
299+
300+
const hexToInt = (value: string) => parseInt(value, 16);
301+
let red: number;
302+
let green: number;
303+
let blue: number;
304+
305+
if (value.length === 4) {
306+
red = hexToInt(value[1] + value[1]);
307+
green = hexToInt(value[2] + value[2]);
308+
blue = hexToInt(value[3] + value[3]);
309+
} else {
310+
red = hexToInt(value.slice(1, 3));
311+
green = hexToInt(value.slice(3, 5));
312+
blue = hexToInt(value.slice(5, 7));
313+
}
314+
315+
return {red, green, blue};
316+
}

0 commit comments

Comments
 (0)
Please sign in to comment.