Skip to content

Commit f15c4bb

Browse files
committed
Add support for calc() expressions in gradient positions
1 parent 791e97b commit f15c4bb

File tree

5 files changed

+224
-0
lines changed

5 files changed

+224
-0
lines changed

build/node.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ GradientParser.stringify = (function() {
9090
return node.value + 'px';
9191
},
9292

93+
'visit_calc': function(node) {
94+
return 'calc(' + node.value + ')';
95+
},
96+
9397
'visit_literal': function(node) {
9498
return visitor.visit_color(node.value, node);
9599
},
@@ -215,6 +219,7 @@ GradientParser.parse = (function() {
215219
rgbColor: /^rgb/i,
216220
rgbaColor: /^rgba/i,
217221
varColor: /^var/i,
222+
calcValue: /^calc/i,
218223
variableName: /^(--[a-zA-Z0-9-,\s\#]+)/,
219224
number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/,
220225
hslColor: /^hsl/i,
@@ -557,13 +562,49 @@ GradientParser.parse = (function() {
557562
function matchDistance() {
558563
return match('%', tokens.percentageValue, 1) ||
559564
matchPositionKeyword() ||
565+
matchCalc() ||
560566
matchLength();
561567
}
562568

563569
function matchPositionKeyword() {
564570
return match('position-keyword', tokens.positionKeywords, 1);
565571
}
566572

573+
function matchCalc() {
574+
return matchCall(tokens.calcValue, function() {
575+
var openParenCount = 1; // Start with the opening parenthesis from calc(
576+
var calcContentStart = input.length;
577+
var i = 0;
578+
579+
// Parse through the content looking for balanced parentheses
580+
while (openParenCount > 0 && i < input.length) {
581+
var char = input.charAt(i);
582+
if (char === '(') {
583+
openParenCount++;
584+
} else if (char === ')') {
585+
openParenCount--;
586+
}
587+
i++;
588+
}
589+
590+
// If we exited because we ran out of input but still have open parentheses, error
591+
if (openParenCount > 0) {
592+
error('Missing closing parenthesis in calc() expression');
593+
}
594+
595+
// Get the content inside the calc() without the last closing paren
596+
var calcContent = input.substring(0, i - 1);
597+
598+
// Consume the calc expression content
599+
consume(i - 1); // -1 because we don't want to consume the closing parenthesis
600+
601+
return {
602+
type: 'calc',
603+
value: calcContent
604+
};
605+
});
606+
}
607+
567608
function matchLength() {
568609
return match('px', tokens.pixelValue, 1) ||
569610
match('em', tokens.emValue, 1);

build/web.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ GradientParser.parse = (function() {
2929
rgbColor: /^rgb/i,
3030
rgbaColor: /^rgba/i,
3131
varColor: /^var/i,
32+
calcValue: /^calc/i,
3233
variableName: /^(--[a-zA-Z0-9-,\s\#]+)/,
3334
number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/,
3435
hslColor: /^hsl/i,
@@ -371,13 +372,49 @@ GradientParser.parse = (function() {
371372
function matchDistance() {
372373
return match('%', tokens.percentageValue, 1) ||
373374
matchPositionKeyword() ||
375+
matchCalc() ||
374376
matchLength();
375377
}
376378

377379
function matchPositionKeyword() {
378380
return match('position-keyword', tokens.positionKeywords, 1);
379381
}
380382

383+
function matchCalc() {
384+
return matchCall(tokens.calcValue, function() {
385+
var openParenCount = 1; // Start with the opening parenthesis from calc(
386+
var calcContentStart = input.length;
387+
var i = 0;
388+
389+
// Parse through the content looking for balanced parentheses
390+
while (openParenCount > 0 && i < input.length) {
391+
var char = input.charAt(i);
392+
if (char === '(') {
393+
openParenCount++;
394+
} else if (char === ')') {
395+
openParenCount--;
396+
}
397+
i++;
398+
}
399+
400+
// If we exited because we ran out of input but still have open parentheses, error
401+
if (openParenCount > 0) {
402+
error('Missing closing parenthesis in calc() expression');
403+
}
404+
405+
// Get the content inside the calc() without the last closing paren
406+
var calcContent = input.substring(0, i - 1);
407+
408+
// Consume the calc expression content
409+
consume(i - 1); // -1 because we don't want to consume the closing parenthesis
410+
411+
return {
412+
type: 'calc',
413+
value: calcContent
414+
};
415+
});
416+
}
417+
381418
function matchLength() {
382419
return match('px', tokens.pixelValue, 1) ||
383420
match('em', tokens.emValue, 1);
@@ -516,6 +553,10 @@ GradientParser.stringify = (function() {
516553
return node.value + 'px';
517554
},
518555

556+
'visit_calc': function(node) {
557+
return 'calc(' + node.value + ')';
558+
},
559+
519560
'visit_literal': function(node) {
520561
return visitor.visit_color(node.value, node);
521562
},

lib/parser.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ GradientParser.parse = (function() {
2727
rgbColor: /^rgb/i,
2828
rgbaColor: /^rgba/i,
2929
varColor: /^var/i,
30+
calcValue: /^calc/i,
3031
variableName: /^(--[a-zA-Z0-9-,\s\#]+)/,
3132
number: /^(([0-9]*\.[0-9]+)|([0-9]+\.?))/,
3233
hslColor: /^hsl/i,
@@ -369,13 +370,49 @@ GradientParser.parse = (function() {
369370
function matchDistance() {
370371
return match('%', tokens.percentageValue, 1) ||
371372
matchPositionKeyword() ||
373+
matchCalc() ||
372374
matchLength();
373375
}
374376

375377
function matchPositionKeyword() {
376378
return match('position-keyword', tokens.positionKeywords, 1);
377379
}
378380

381+
function matchCalc() {
382+
return matchCall(tokens.calcValue, function() {
383+
var openParenCount = 1; // Start with the opening parenthesis from calc(
384+
var calcContentStart = input.length;
385+
var i = 0;
386+
387+
// Parse through the content looking for balanced parentheses
388+
while (openParenCount > 0 && i < input.length) {
389+
var char = input.charAt(i);
390+
if (char === '(') {
391+
openParenCount++;
392+
} else if (char === ')') {
393+
openParenCount--;
394+
}
395+
i++;
396+
}
397+
398+
// If we exited because we ran out of input but still have open parentheses, error
399+
if (openParenCount > 0) {
400+
error('Missing closing parenthesis in calc() expression');
401+
}
402+
403+
// Get the content inside the calc() without the last closing paren
404+
var calcContent = input.substring(0, i - 1);
405+
406+
// Consume the calc expression content
407+
consume(i - 1); // -1 because we don't want to consume the closing parenthesis
408+
409+
return {
410+
type: 'calc',
411+
value: calcContent
412+
};
413+
});
414+
}
415+
379416
function matchLength() {
380417
return match('px', tokens.pixelValue, 1) ||
381418
match('em', tokens.emValue, 1);

lib/stringify.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ GradientParser.stringify = (function() {
9090
return node.value + 'px';
9191
},
9292

93+
'visit_calc': function(node) {
94+
return 'calc(' + node.value + ')';
95+
},
96+
9397
'visit_literal': function(node) {
9498
return visitor.visit_color(node.value, node);
9599
},

spec/parser.spec.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,107 @@ describe('lib/parser.js', function () {
376376
});
377377
});
378378
});
379+
380+
describe('parse calc expressions', function() {
381+
it('should parse linear gradient with calc in color stop position', function() {
382+
const gradient = 'linear-gradient(to right, red calc(10% + 20px), blue 50%)';
383+
const ast = gradients.parse(gradient);
384+
385+
expect(ast[0].type).to.equal('linear-gradient');
386+
expect(ast[0].orientation.type).to.equal('directional');
387+
expect(ast[0].orientation.value).to.equal('right');
388+
389+
expect(ast[0].colorStops).to.have.length(2);
390+
expect(ast[0].colorStops[0].type).to.equal('literal');
391+
expect(ast[0].colorStops[0].value).to.equal('red');
392+
expect(ast[0].colorStops[0].length.type).to.equal('calc');
393+
expect(ast[0].colorStops[0].length.value).to.equal('10% + 20px');
394+
395+
expect(ast[0].colorStops[1].type).to.equal('literal');
396+
expect(ast[0].colorStops[1].value).to.equal('blue');
397+
expect(ast[0].colorStops[1].length.type).to.equal('%');
398+
expect(ast[0].colorStops[1].length.value).to.equal('50');
399+
});
400+
401+
it('should parse radial gradient with calc in position', function() {
402+
const gradient = 'radial-gradient(circle at calc(50% + 25px) 50%, red, blue)';
403+
const ast = gradients.parse(gradient);
404+
405+
expect(ast[0].type).to.equal('radial-gradient');
406+
expect(ast[0].orientation[0].type).to.equal('shape');
407+
expect(ast[0].orientation[0].value).to.equal('circle');
408+
409+
// Check the position
410+
expect(ast[0].orientation[0].at.type).to.equal('position');
411+
expect(ast[0].orientation[0].at.value.x.type).to.equal('calc');
412+
expect(ast[0].orientation[0].at.value.x.value).to.equal('50% + 25px');
413+
expect(ast[0].orientation[0].at.value.y.type).to.equal('%');
414+
expect(ast[0].orientation[0].at.value.y.value).to.equal('50');
415+
416+
// Check the color stops
417+
expect(ast[0].colorStops).to.have.length(2);
418+
expect(ast[0].colorStops[0].value).to.equal('red');
419+
expect(ast[0].colorStops[1].value).to.equal('blue');
420+
});
421+
422+
it('should parse calc expressions with multiple operations', function() {
423+
const gradient = 'linear-gradient(90deg, yellow calc(100% - 50px), green calc(100% - 20px))';
424+
const ast = gradients.parse(gradient);
425+
426+
expect(ast[0].type).to.equal('linear-gradient');
427+
expect(ast[0].orientation.type).to.equal('angular');
428+
expect(ast[0].orientation.value).to.equal('90');
429+
430+
expect(ast[0].colorStops).to.have.length(2);
431+
expect(ast[0].colorStops[0].type).to.equal('literal');
432+
expect(ast[0].colorStops[0].value).to.equal('yellow');
433+
expect(ast[0].colorStops[0].length.type).to.equal('calc');
434+
expect(ast[0].colorStops[0].length.value).to.equal('100% - 50px');
435+
436+
expect(ast[0].colorStops[1].type).to.equal('literal');
437+
expect(ast[0].colorStops[1].value).to.equal('green');
438+
expect(ast[0].colorStops[1].length.type).to.equal('calc');
439+
expect(ast[0].colorStops[1].length.value).to.equal('100% - 20px');
440+
});
441+
442+
it('should parse calc expressions with nested parentheses', function() {
443+
const gradient = 'linear-gradient(to bottom, red calc(50% + (25px * 2)), blue)';
444+
const ast = gradients.parse(gradient);
445+
446+
expect(ast[0].type).to.equal('linear-gradient');
447+
expect(ast[0].orientation.type).to.equal('directional');
448+
expect(ast[0].orientation.value).to.equal('bottom');
449+
450+
expect(ast[0].colorStops).to.have.length(2);
451+
expect(ast[0].colorStops[0].type).to.equal('literal');
452+
expect(ast[0].colorStops[0].value).to.equal('red');
453+
expect(ast[0].colorStops[0].length.type).to.equal('calc');
454+
expect(ast[0].colorStops[0].length.value).to.equal('50% + (25px * 2)');
455+
});
456+
457+
it('should parse multiple calc expressions in the same gradient', function() {
458+
const gradient = 'radial-gradient(circle at calc(50% - 10px) calc(50% + 10px), red calc(20% + 10px), blue)';
459+
const ast = gradients.parse(gradient);
460+
461+
expect(ast[0].type).to.equal('radial-gradient');
462+
expect(ast[0].orientation[0].type).to.equal('shape');
463+
expect(ast[0].orientation[0].value).to.equal('circle');
464+
465+
// Check the position
466+
expect(ast[0].orientation[0].at.type).to.equal('position');
467+
expect(ast[0].orientation[0].at.value.x.type).to.equal('calc');
468+
expect(ast[0].orientation[0].at.value.x.value).to.equal('50% - 10px');
469+
expect(ast[0].orientation[0].at.value.y.type).to.equal('calc');
470+
expect(ast[0].orientation[0].at.value.y.value).to.equal('50% + 10px');
471+
472+
// Check the color stops
473+
expect(ast[0].colorStops).to.have.length(2);
474+
expect(ast[0].colorStops[0].type).to.equal('literal');
475+
expect(ast[0].colorStops[0].value).to.equal('red');
476+
expect(ast[0].colorStops[0].length.type).to.equal('calc');
477+
expect(ast[0].colorStops[0].length.value).to.equal('20% + 10px');
478+
});
479+
});
379480
});
380481

381482
});

0 commit comments

Comments
 (0)