Skip to content

Commit 697e440

Browse files
authored
calc-mix (#1862)
1 parent 28d00d2 commit 697e440

9 files changed

Lines changed: 265 additions & 3 deletions

File tree

packages/css-calc/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changes to CSS Calc
22

3+
### Unreleased (minor)
4+
5+
- Add support for `calc-mix()`
6+
37
### 3.2.1
48

59
_May 13, 2026_

packages/css-calc/dist/index.mjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { Calculation } from '../calculation';
2+
import type { FunctionNode, TokenNode } from '@csstools/css-parser-algorithms';
3+
import { convertUnit } from '../unit-conversions';
4+
import { resultToCalculation } from './result-to-calculation';
5+
import { arrayOfSameNumeric } from '../util/kind-of-number';
6+
import type { CSSToken, NumericToken} from '@csstools/css-tokenizer';
7+
import { isTokenNumeric } from '@csstools/css-tokenizer';
8+
9+
export function solveCalcMix(calcMixNode: FunctionNode, solvedNodes: Array<{ calcSum: TokenNode, percentage: number | false }>): Calculation | -1 {
10+
const firstSolvedNode = solvedNodes[0];
11+
12+
const firstSolvedToken = firstSolvedNode.calcSum.value;
13+
if (!isTokenNumeric(firstSolvedToken)) {
14+
return -1;
15+
}
16+
17+
const list = solvedNodes.map((x) => {
18+
return {
19+
calcSum: convertUnit(firstSolvedToken, x.calcSum.value),
20+
percentage: x.percentage
21+
};
22+
});
23+
24+
const tokens = list.map((x) => x.calcSum);
25+
if (!arrayOfSameNumeric(tokens)) {
26+
return -1;
27+
}
28+
29+
const { items: normalizedItems } = normalizeMixPercentages(list, false);
30+
const values = normalizedItems.map((item) => {
31+
return ((item.calcSum as NumericToken)[4].value * item.percentage) / 100;
32+
});
33+
34+
let sum = 0;
35+
for (let i = 0; i < values.length; i++) {
36+
sum += values[i];
37+
}
38+
39+
return resultToCalculation(calcMixNode, firstSolvedToken, sum);
40+
}
41+
42+
43+
// https://drafts.csswg.org/css-values-5/#normalize-mix-percentages
44+
function normalizeMixPercentages(mix_items: Array<{ calcSum: CSSToken, percentage: number | false }>, force_normalization: boolean = false): { items: Array<{ calcSum: CSSToken, percentage: number }>, leftover: number } {
45+
// 1. Let specified sum be the sum of the percentages specified in items (clamped to 100%), or 0% if the percentages are omitted for all items.
46+
let specified_sum = 0;
47+
let number_of_omitted_percentages = 0;
48+
for (const item of mix_items) {
49+
if (item.percentage) {
50+
specified_sum += item.percentage;
51+
}
52+
53+
if (item.percentage === false) {
54+
number_of_omitted_percentages++;
55+
}
56+
}
57+
58+
// (clamped to 100%)
59+
specified_sum = Math.min(100, specified_sum);
60+
61+
// 2. For each omitted percentage in items, set it to (100% - specified sum) / (number of omitted percentages).
62+
for (const item of mix_items) {
63+
if (item.percentage === false) {
64+
item.percentage = (100 - specified_sum) / (number_of_omitted_percentages);
65+
}
66+
}
67+
68+
const mix_items_with_percentages = (mix_items as Array<{ calcSum: CSSToken, percentage: number }>).slice();
69+
70+
// 3. Let total be the sum of the percentages of all the items.
71+
let total = 0;
72+
for (const item of mix_items_with_percentages) {
73+
total += item.percentage;
74+
}
75+
76+
// 4. If total is greater than 100%, or if total is greater than 0% and the force normalization flag is true, multiply every percentage in items by (100% / total).
77+
if (total > 100 || (total > 0 && force_normalization)) {
78+
for (const item of mix_items_with_percentages) {
79+
item.percentage = item.percentage * (100 / total);
80+
}
81+
}
82+
83+
let leftover = 0;
84+
if (total < 100) {
85+
leftover = 100 - total;
86+
}
87+
88+
return {
89+
items: mix_items_with_percentages,
90+
leftover: leftover,
91+
};
92+
}

packages/css-calc/src/functions/calc.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Calculation } from '../calculation';
22
import type { ComponentValue, SimpleBlockNode } from '@csstools/css-parser-algorithms';
33
import type { Globals } from '../util/globals';
44
import type { CSSToken } from '@csstools/css-tokenizer';
5-
import { TokenType, NumberType, isTokenOpenParen, isTokenDelim, isTokenComma, isTokenIdent, isTokenNumber, isTokenDimension } from '@csstools/css-tokenizer';
5+
import { TokenType, NumberType, isTokenOpenParen, isTokenDelim, isTokenComma, isTokenIdent, isTokenNumber, isTokenDimension, isTokenPercentage } from '@csstools/css-tokenizer';
66
import { addition } from '../operation/addition';
77
import { division } from '../operation/division';
88
import { isCalculation, solve } from '../calculation';
@@ -37,6 +37,7 @@ import type { RandomValueSharing} from './random';
3737
import { solveRandom } from './random';
3838
import { snapAsBorderWidth } from '../util/snap-to-border-width';
3939
import { convertUnit } from '../unit-conversions';
40+
import { solveCalcMix } from './calc-mix';
4041

4142
type mathFunction = (node: FunctionNode, globals: Globals, options: conversionOptions) => Calculation | -1;
4243

@@ -47,6 +48,7 @@ export const mathFunctions: Map<string, mathFunction> = new Map([
4748
['atan', atan],
4849
['atan2', atan2],
4950
['calc', calc],
51+
['calc-mix', calcMix],
5052
['clamp', clamp],
5153
['cos', cos],
5254
['exp', exp],
@@ -349,6 +351,101 @@ function variadicArguments(fnNode: FunctionNode, values: Array<ComponentValue>,
349351
return solvedNodes;
350352
}
351353

354+
function calcMix(fnNode: FunctionNode, globals: Globals, options: conversionOptions): Calculation | -1 {
355+
const solvedNodes = variadicArgumentsCalcMix(fnNode, fnNode.value, globals, options);
356+
if (solvedNodes === -1) {
357+
return -1;
358+
}
359+
360+
return solveCalcMix(fnNode, solvedNodes);
361+
}
362+
363+
function variadicArgumentsCalcMix(fnNode: FunctionNode, values: Array<ComponentValue>, globals: Globals, options: conversionOptions): Array<{ calcSum: TokenNode, percentage: number | false }> | -1 {
364+
const nodes: Array<ComponentValue> = resolveGlobalsAndConstants(
365+
[...(values.filter(x => !isWhiteSpaceOrCommentNode(x)))],
366+
globals,
367+
);
368+
369+
const solvedNodes: Array<{ calcSum: TokenNode, percentage: number | false }> = [];
370+
371+
{
372+
const chunks: Array<Array<ComponentValue>> = [];
373+
let chunk: Array<ComponentValue> = [];
374+
for (let i = 0; i < nodes.length; i++) {
375+
const node = nodes[i];
376+
if (isTokenNode(node) && isTokenComma(node.value)) {
377+
chunks.push(chunk);
378+
chunk = [];
379+
continue;
380+
}
381+
382+
chunk.push(node);
383+
}
384+
385+
chunks.push(chunk);
386+
387+
for (let i = 0; i < chunks.length; i++) {
388+
if (chunks[i].length === 0) {
389+
return -1;
390+
}
391+
392+
let calcSum: TokenNode | -1 = -1;
393+
let percentage: number | false = false;
394+
395+
for (let j = (chunks[i].length - 1); j >= 0; j--) {
396+
if (isWhiteSpaceOrCommentNode(chunks[i][j])) {
397+
continue;
398+
}
399+
400+
calcSum = solve(calc(calcWrapper(fnNode, chunks[i].slice(0, j + 1)), globals, options), options);
401+
if (calcSum === -1) {
402+
continue;
403+
}
404+
405+
const remainder = chunks[i].slice(j + 1).filter((x) => {
406+
return !isWhiteSpaceOrCommentNode(x);
407+
});
408+
409+
if (remainder.length) {
410+
if (remainder.length === 1 && isTokenNode(remainder[0]) && isTokenPercentage(remainder[0].value)) {
411+
percentage = remainder[0].value[4].value;
412+
413+
if (percentage < 0 || percentage > 100) {
414+
return -1;
415+
}
416+
417+
} else {
418+
const solvedPercentage = solve(calc(calcWrapper(fnNode, remainder), globals, options), options);
419+
if (solvedPercentage === -1) {
420+
return -1;
421+
}
422+
423+
if (!isTokenPercentage(solvedPercentage.value)) {
424+
return -1;
425+
}
426+
427+
percentage = solvedPercentage.value[4].value;
428+
percentage = Math.min(100, Math.max(0, percentage));
429+
}
430+
}
431+
432+
break;
433+
}
434+
435+
if (calcSum === -1) {
436+
return -1;
437+
}
438+
439+
solvedNodes.push({
440+
calcSum: calcSum,
441+
percentage: percentage
442+
});
443+
}
444+
}
445+
446+
return solvedNodes;
447+
}
448+
352449
function clamp(clampNode: FunctionNode, globals: Globals, options: conversionOptions): Calculation | -1 {
353450
const nodes: Array<ComponentValue> = resolveGlobalsAndConstants(
354451
[...(clampNode.value.filter(x => !isWhiteSpaceOrCommentNode(x)))],
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { calc } from '@csstools/css-calc';
2+
import assert from 'node:assert';
3+
4+
// Basic
5+
assert.strictEqual(calc('calc-mix(1 100%)', { toCanonicalUnits: true }), '1');
6+
assert.strictEqual(calc('calc-mix(1 50%, 3 50%)', { toCanonicalUnits: true }), '2');
7+
assert.strictEqual(calc('calc-mix(1 25%, 3 25%, 5 50%)', { toCanonicalUnits: true }), '3.5');
8+
assert.strictEqual(calc('calc-mix(1 25%, 3 25%, 7 0%, 5 50%)', { toCanonicalUnits: true }), '3.5');
9+
10+
// Missing weight percentages
11+
assert.strictEqual(calc('calc-mix(1)', { toCanonicalUnits: true }), '1');
12+
assert.strictEqual(calc('calc-mix(1 50%, 3)', { toCanonicalUnits: true }), '2');
13+
assert.strictEqual(calc('calc-mix(1, 3, 5 50%)', { toCanonicalUnits: true }), '3.5');
14+
15+
// Weight percentages add up to more than 100%
16+
assert.strictEqual(calc('calc-mix(1 75%, 3 75%)', { toCanonicalUnits: true }), '2');
17+
assert.strictEqual(calc('calc-mix(1 50%, 3 50%, 5 50%)', { toCanonicalUnits: true }), '3');
18+
assert.strictEqual(calc('calc-mix(1 75%, 3 75%, 5, 7)', { toCanonicalUnits: true }), '2');
19+
20+
// Weight percentages add up to less than 100%
21+
assert.strictEqual(calc('calc-mix(1 25%, 3 25%)', { toCanonicalUnits: true }), '1');
22+
assert.strictEqual(calc('calc-mix(1 25%, 3 25%, 5, 7)', { toCanonicalUnits: true }), '4');
23+
24+
// Weight percentages are all known zero
25+
assert.strictEqual(calc('calc-mix(1 0%)', { toCanonicalUnits: true }), '0');
26+
assert.strictEqual(calc('calc-mix(1 0%, 3 0%, 5 0%)', { toCanonicalUnits: true }), '0');
27+
assert.strictEqual(calc('calc-mix(1px 0%)', { toCanonicalUnits: true }), '0px');
28+
assert.strictEqual(calc('calc-mix(1px 0%, 3px 0%, 5px 0%)', { toCanonicalUnits: true }), '0px');
29+
assert.strictEqual(calc('calc-mix(1% 0%)', { toCanonicalUnits: true }), '0%');
30+
assert.strictEqual(calc('calc-mix(1% 0%, 3% 0%, 5% 0%)', { toCanonicalUnits: true }), '0%');
31+
32+
// Mixed dimensions/numbers/percentages
33+
assert.strictEqual(calc('calc-mix(1% 0%, 3px 0%)', { toCanonicalUnits: true }), 'calc-mix(1% 0%, 3px 0%)');
34+
assert.strictEqual(calc('calc-mix(1px 0%, 3% 0%)', { toCanonicalUnits: true }), 'calc-mix(1px 0%, 3% 0%)');
35+
36+
// sum percentage greater than 100%
37+
assert.strictEqual(calc('calc-mix(1 50%, 3 100%)', { toCanonicalUnits: true }), '2.3333333333333');
38+
39+
// Weight percentage value greater than 100%
40+
assert.strictEqual(calc('calc-mix(1 calc(150%), 3 100%)', { toCanonicalUnits: true }), '2');
41+
42+
// Weight percentage value less than 0%
43+
assert.strictEqual(calc('calc-mix(1 calc(-50%), 3 100%)', { toCanonicalUnits: true }), '3');
44+
45+
// All values use dimension resolving (due to <length-percentage> property) percentages.
46+
assert.strictEqual(calc('calc(10px + calc-mix(10% 50%, 30% 50%))', { toCanonicalUnits: true }), 'calc(10px + 20%)');
47+
48+
// Computed value time resolving units
49+
assert.strictEqual(calc('calc-mix(10em 50%, 30px 50%)', { toCanonicalUnits: true }), 'calc-mix(10em 50%, 30px 50%)');
50+
51+
// Nested in other math functions
52+
assert.strictEqual(calc('calc(sign(calc-mix(1 50%, 3 50%)) + 10)', { toCanonicalUnits: true }), '11');
53+
assert.strictEqual(calc('calc(max(calc-mix(1 50%, 3 50%), 1))', { toCanonicalUnits: true }), '2');
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { calc } from '@csstools/css-calc';
2+
import assert from 'node:assert';
3+
4+
// Syntax checking
5+
assert.strictEqual(calc('calc-mix()', { toCanonicalUnits: true }), 'calc-mix()');
6+
assert.strictEqual(calc('calc-mix( )', { toCanonicalUnits: true }), 'calc-mix( )');
7+
assert.strictEqual(calc('calc-mix(,)', { toCanonicalUnits: true }), 'calc-mix(,)');
8+
assert.strictEqual(calc('calc-mix(1, )', { toCanonicalUnits: true }), 'calc-mix(1, )');
9+
assert.strictEqual(calc('calc-mix(0,1,)', { toCanonicalUnits: true }), 'calc-mix(0,1,)');
10+
assert.strictEqual(calc('calc-mix(0,, 0)', { toCanonicalUnits: true }), 'calc-mix(0,, 0)');
11+
assert.strictEqual(calc('calc-mix(1 50, 3)', { toCanonicalUnits: true }), 'calc-mix(1 50, 3)');
12+
assert.strictEqual(calc('calc-mix(1 150%, 3)', { toCanonicalUnits: true }), 'calc-mix(1 150%, 3)');
13+
assert.strictEqual(calc('calc-mix(1 -50%, 3)', { toCanonicalUnits: true }), 'calc-mix(1 -50%, 3)');

packages/css-calc/test/wpt/index.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import './calc-angle-values.mjs';
44
import './calc-catch-divide-by-0.mjs';
55
import './calc-in-calc.mjs';
66
import './calc-in-color.mjs';
7+
import './calc-mix-computed.mjs';
8+
import './calc-mix-invalid.mjs';
79
import './calc-nesting-002.mjs';
810
import './calc-parenthesis-stack.mjs';
911
import './calc-serialization-002.mjs';

packages/media-query-list-parser/dist/index.mjs

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/media-query-list-parser/src/util/component-value-is.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const mathFunctions = new Set([
2020
'atan',
2121
'atan2',
2222
'calc',
23+
'calc-mix',
2324
'clamp',
2425
'cos',
2526
'exp',

0 commit comments

Comments
 (0)