feat: update css formatter to add keyframes rules (#2718)

Adds `@keyframes` animation rule support to CssFormatter, parsing
individual animation steps and their styled properties.

Co-authored-by: Samiya Caur <samiyac@chromium.org>
This commit is contained in:
Samiya Caur
2026-09-10 21:04:08 +00:00
committed by GitHub
co-authored by Samiya Caur
parent d4a0620ce6
commit cdc365ca95
4 changed files with 200 additions and 1 deletions
+84 -1
View File
@@ -126,6 +126,20 @@ export interface PseudoElementRule {
properties: StructuredCssProperty[];
}
export interface KeyframeStep {
keyText: string;
source?: string;
properties: StructuredCssProperty[];
}
export interface KeyframesRule {
type: 'keyframes';
name: string;
selector: string;
source?: string;
keyframes: KeyframeStep[];
}
export interface AtRule {
type: 'at-rule';
atRuleType: string;
@@ -169,6 +183,7 @@ export type CascadeRule =
| MatchedRule
| InheritedRule
| PseudoElementRule
| KeyframesRule
| AtRule
| PositionTryRule
| PropertyRule
@@ -511,6 +526,7 @@ function getCascadeRuleHeader(rule: CascadeRule): string {
case 'animation':
case 'attributes':
case 'matched':
case 'keyframes':
case 'at-rule':
case 'property':
case 'function':
@@ -579,7 +595,7 @@ function formatAncestorRuleHeader(ancestor: AncestorCSSRule): {
function appendRuleWithAncestors(
writer: IndentedWriter,
rule: CascadeRule,
rule: Exclude<CascadeRule, KeyframesRule>,
): void {
const ancestors = 'ancestors' in rule ? rule.ancestors : undefined;
let ancestorCount = 0;
@@ -611,6 +627,26 @@ function appendRuleWithAncestors(
}
}
function appendKeyframesRule(
writer: IndentedWriter,
rule: KeyframesRule,
): void {
const header = getCascadeRuleHeader(rule);
writer.writeLine(`${header} {`);
writer.indent();
for (const step of rule.keyframes) {
writer.writeLine(`${step.keyText} {`);
writer.indent();
for (const prop of step.properties) {
writer.writeLine(formatPropertyLine(prop));
}
writer.dedent();
writer.writeLine('}');
}
writer.dedent();
writer.writeLine('}');
}
function appendCssSectionsToString(
writer: IndentedWriter,
styles: StructuredCssStyles,
@@ -631,6 +667,8 @@ function appendCssSectionsToString(
}
writer.writeComment(`Pseudo ${rule.pseudoType} element${inheritedStr}`);
appendRuleWithAncestors(writer, rule);
} else if (rule.type === 'keyframes') {
appendKeyframesRule(writer, rule);
} else {
appendRuleWithAncestors(writer, rule);
}
@@ -654,6 +692,7 @@ export class CssFormatter {
const rules: CascadeRule[] = [];
CssFormatter.#collectNodeStyles(rules, matchedStyles, options);
CssFormatter.#collectPseudoStyles(rules, matchedStyles, options);
CssFormatter.#collectKeyframes(rules, matchedStyles);
CssFormatter.#collectAtRules(rules, matchedStyles);
CssFormatter.#collectPositionTryRules(rules, matchedStyles);
CssFormatter.#collectRegisteredProperties(rules, matchedStyles);
@@ -887,6 +926,50 @@ export class CssFormatter {
}
}
static #collectKeyframes(
rules: CascadeRule[],
matchedStyles: MatchedStyles,
): void {
const keyframesRules = matchedStyles.keyframes?.() ?? [];
for (const keyframesRule of keyframesRules) {
const name = keyframesRule.name?.()?.text ?? '';
const rawKeyframes = keyframesRule.keyframes?.() ?? [];
const steps: KeyframeStep[] = [];
let parentSource: string | undefined;
for (const keyframe of rawKeyframes) {
const properties = CssFormatter.#getStyleProperties(keyframe.style);
if (!properties.length) {
continue;
}
const keyText = keyframe.key?.()?.text ?? '';
const source = getSourceLocation(keyframe);
if (!parentSource && source) {
parentSource = source;
}
steps.push({
keyText,
...(source ? {source} : {}),
properties: CssFormatter.#formatProperties(properties, matchedStyles),
});
}
if (!steps.length) {
continue;
}
rules.push({
type: 'keyframes',
name,
selector: `@keyframes ${name}`,
...(parentSource ? {source: parentSource} : {}),
keyframes: steps,
});
}
}
static #collectAtRules(
rules: CascadeRule[],
matchedStyles: MatchedStyles,
@@ -107,6 +107,60 @@ Styles for button (uid: "elem-func"):
}
`;
exports[`CssFormatter > formats @keyframes rule with multiple steps and source location toJSON 1`] = `
{
"element": {
"uid": "elem-kf",
"selector": "button"
},
"rules": [
{
"type": "keyframes",
"name": "slideIn",
"selector": "@keyframes slideIn",
"source": "animations.css:11",
"keyframes": [
{
"keyText": "from",
"source": "animations.css:11",
"properties": [
{
"name": "opacity",
"value": "0",
"status": "active"
}
]
},
{
"keyText": "to",
"source": "animations.css:14",
"properties": [
{
"name": "opacity",
"value": "1",
"status": "active"
}
]
}
]
}
]
}
`;
exports[`CssFormatter > formats @keyframes rule with multiple steps and source location toString 1`] = `
Styles for button (uid: "elem-kf"):
@keyframes slideIn (animations.css:11) {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
exports[`CssFormatter > formats @layer, @media, @supports, and @starting-style ancestor rules toJSON 1`] = `
{
"element": {
+26
View File
@@ -25,6 +25,7 @@ import {
createMockCSSPositionTryRule,
createMockCSSRegisteredProperty,
createMockCSSFunctionRule,
createMockCSSKeyframesRule,
} from '../mocks.js';
describe('CssFormatter', () => {
@@ -622,4 +623,29 @@ describe('CssFormatter', () => {
return new CssFormatter(matchedStyles, {uid: 'elem-func'});
},
);
formatterTest(
'formats @keyframes rule with multiple steps and source location',
() => {
const keyframesRule = createMockCSSKeyframesRule('slideIn', [
{
key: 'from',
properties: [createMockCSSProperty('opacity', '0')],
sourceURL: 'animations.css',
range: {startLine: 10, startColumn: 2, endLine: 12, endColumn: 3},
},
{
key: 'to',
properties: [createMockCSSProperty('opacity', '1')],
sourceURL: 'animations.css',
range: {startLine: 13, startColumn: 2, endLine: 15, endColumn: 3},
},
]);
const matchedStyles = createMockCSSMatchedStyles({
keyframes: [keyframesRule],
});
return new CssFormatter(matchedStyles, {uid: 'elem-kf'});
},
);
});
+36
View File
@@ -45,6 +45,8 @@ export type MockCSSMatchedStyles =
sinon.SinonStubbedInstance<DevTools.CSSMatchedStyles.CSSMatchedStyles>;
export type MockCSSStyleRule =
sinon.SinonStubbedInstance<DevTools.CSSRule.CSSStyleRule>;
export type MockCSSKeyframesRule =
sinon.SinonStubbedInstance<DevTools.CSSRule.CSSKeyframesRule>;
export type MockCSSAtRule =
sinon.SinonStubbedInstance<DevTools.CSSRule.CSSAtRule>;
export type MockCSSPositionTryRule =
@@ -383,6 +385,38 @@ export function createMockCSSStyleRule(
return rule;
}
export function createMockCSSKeyframesRule(
name: string,
keyframes: Array<{
key: string;
properties: DevTools.CSSProperty.CSSProperty[];
sourceURL?: string;
range?: {
startLine: number;
startColumn: number;
endLine: number;
endColumn: number;
};
}>,
): MockCSSKeyframesRule {
const rule = sinon.createStubInstance(DevTools.CSSRule.CSSKeyframesRule);
const mockKeyframes = [];
for (const kf of keyframes) {
const kfMock = sinon.createStubInstance(DevTools.CSSRule.CSSKeyframeRule);
attachRuleMeta(kfMock, kf.sourceURL);
const style = createMockCSSStyleDeclaration(kf.properties, {
rule: kfMock,
range: kf.range,
});
kfMock.key.returns(createCSSValue(kf.key));
Object.assign(kfMock, {style});
mockKeyframes.push(kfMock);
}
rule.name.returns(createCSSValue(name));
rule.keyframes.returns(mockKeyframes);
return rule;
}
export function createMockCSSAtRule(
type: string,
options: {
@@ -524,6 +558,7 @@ export interface MockCSSMatchedStylesParams {
node?: string | DevTools.DOMModel.DOMNode;
nodeStyles?: DevTools.CSSStyleDeclaration.CSSStyleDeclaration[];
inheritedStyles?: DevTools.CSSStyleDeclaration.CSSStyleDeclaration[];
keyframes?: DevTools.CSSRule.CSSKeyframesRule[];
atRules?: DevTools.CSSRule.CSSAtRule[];
positionTryRules?: DevTools.CSSRule.CSSPositionTryRule[];
registeredProperties?: DevTools.CSSMatchedStyles.CSSRegisteredProperty[];
@@ -576,6 +611,7 @@ export function createMockCSSMatchedStyles(
mock.node.returns(mockNode);
mock.nodeStyles.returns(nodeStyles);
mock.inheritedStyles.returns(inheritedStyles);
mock.keyframes.returns(params.keyframes ?? []);
mock.atRules.returns(params.atRules ?? []);
mock.positionTryRules.returns(params.positionTryRules ?? []);
mock.registeredProperties.returns(params.registeredProperties ?? []);