diff --git a/src/css/rules/keyframes.rs b/src/css/rules/keyframes.rs index d6a344715eae..779e1d64472e 100644 --- a/src/css/rules/keyframes.rs +++ b/src/css/rules/keyframes.rs @@ -101,6 +101,25 @@ impl KeyframesName { // KeyframeSelector // ────────────────────────────────────────────────────────────────────────── +/// A [named timeline range](https://drafts.csswg.org/scroll-animations-1/#view-timelines-ranges). +#[derive(Clone, Copy, css::DefineEnumProperty)] +pub enum TimelineRangeName { + /// The full range of the view progress timeline. + Cover, + /// The range in which the principal box is contained by, or covers, its visibility range. + Contain, + /// The range in which the principal box enters the view progress visibility range. + Entry, + /// The range in which the principal box exits the view progress visibility range. + Exit, + /// The range in which the principal box crosses the end border edge. + EntryCrossing, + /// The range in which the principal box crosses the start border edge. + ExitCrossing, + /// The full range of the scroll container. In the spec and Chromium, not in lightningcss. + Scroll, +} + pub enum KeyframeSelector { /// An explicit percentage. Percentage(Percentage), @@ -108,8 +127,17 @@ pub enum KeyframeSelector { From, /// The `to` keyword. Equivalent to 100%. To, + /// A [named timeline range selector](https://drafts.csswg.org/scroll-animations-1/#named-range-keyframes). + TimelineRangePercentage { + /// The name of the timeline range. + name: TimelineRangeName, + /// The progress between the start and the end of the range. Not limited to 0%..100%. + percentage: Percentage, + }, } +const _: () = assert!(core::mem::size_of::() == 8); + impl KeyframeSelector { fn to_css(&self, dest: &mut Printer) -> core::result::Result<(), PrintErr> { match self { @@ -130,6 +158,11 @@ impl KeyframeSelector { KeyframeSelector::To => { dest.write_str("to")?; } + KeyframeSelector::TimelineRangePercentage { name, percentage } => { + name.to_css(dest)?; + dest.write_char(b' ')?; + percentage.to_css(dest)?; + } } Ok(()) } @@ -141,6 +174,10 @@ impl KeyframeSelector { Self::Percentage(p) => Self::Percentage(*p), Self::From => Self::From, Self::To => Self::To, + Self::TimelineRangePercentage { name, percentage } => Self::TimelineRangePercentage { + name: *name, + percentage: *percentage, + }, } } } @@ -148,8 +185,6 @@ impl KeyframeSelector { // ─── KeyframeSelector parse ─────────────────────────────────────────────── impl KeyframeSelector { - // Try the tuple variant (`Percentage`) first, then fall back to keyword - // idents (`from`/`to`). fn parse(input: &mut css::Parser) -> css::Result { if let Ok(p) = input.try_parse(Percentage::parse) { return Ok(KeyframeSelector::Percentage(p)); @@ -160,6 +195,13 @@ impl KeyframeSelector { Ok(KeyframeSelector::From) } else if bun_core::eql_case_insensitive_ascii_check_length(ident, b"to") { Ok(KeyframeSelector::To) + } else if let Some(name) = + ::from_ascii_case_insensitive(ident) + { + Ok(KeyframeSelector::TimelineRangePercentage { + name, + percentage: Percentage::parse(input)?, + }) } else { Err(location.new_unexpected_token_error(css::Token::Ident(ident))) } diff --git a/test/bundler/css/css-modules.test.ts b/test/bundler/css/css-modules.test.ts index 81c28666e055..c87966a163af 100644 --- a/test/bundler/css/css-modules.test.ts +++ b/test/bundler/css/css-modules.test.ts @@ -148,6 +148,37 @@ describe("css", () => { }, }); + // A timeline range name in a keyframe selector (`entry 0%`) is a keyword. + // Only the `@keyframes` name is scoped. + itBundled("css-module/KeyframesTimelineRangeSelectorNotScoped", { + files: { + "/entry.js": ` + import styles from './styles.module.css'; + console.log(styles.box); + `, + "/styles.module.css": ` + .box { animation: reveal linear both; animation-timeline: view(); } + @keyframes reveal { entry 0% { opacity: 0 } exit 100% { opacity: 1 } } + `, + }, + entryPoints: ["/entry.js"], + outdir: "/out", + onAfterBundle(api) { + const css = api.readFile("/out/entry.css"); + const keyframes = css.match(/@keyframes\s+(reveal_[A-Za-z0-9_-]+)\s*\{/); + expect(keyframes, "@keyframes reveal should be scoped").not.toBeNull(); + expect(css).toContain(`@keyframes ${keyframes![1]} { + entry 0% { + opacity: 0; + } + + exit 100% { + opacity: 1; + } +}`); + }, + }); + // The parser dedupes repeated class/id names through a borrowed lookup // (`add_symbol_for_name`); many references to the same names must all map // to a single hashed symbol each. diff --git a/test/bundler/esbuild/css.test.ts b/test/bundler/esbuild/css.test.ts index 5e6a6f91ca01..36cccf9958a7 100644 --- a/test/bundler/esbuild/css.test.ts +++ b/test/bundler/esbuild/css.test.ts @@ -2288,4 +2288,105 @@ c { "/across-files-url.css", ], }); + + // ` ` keyframe selectors: + // https://drafts.csswg.org/scroll-animations-1/#named-range-keyframes + for (const backend of ["cli", "api"] as const) { + const files = { + "/entry.css": /* css */ ` + @keyframes reveal { entry 0% { opacity: 0 } entry 100% { opacity: 1 } } + @keyframes mixed { from { opacity: 0 } cover 50% { opacity: .5 } to { opacity: 1 } } + @keyframes plain { from { opacity: 0 } 50% { opacity: .5 } to { opacity: 1 } } + `, + }; + itBundled(`css/KeyframesTimelineRangeSelectors_${backend}`, { + backend, + files, + outdir: "/out", + onAfterBundle(api) { + api.expectFile("/out/entry.css").toBe(`/* entry.css */ +@keyframes reveal { + entry 0% { + opacity: 0; + } + + entry 100% { + opacity: 1; + } +} + +@keyframes mixed { + from { + opacity: 0; + } + + cover 50% { + opacity: .5; + } + + to { + opacity: 1; + } +} + +@keyframes plain { + from { + opacity: 0; + } + + 50% { + opacity: .5; + } + + to { + opacity: 1; + } +} +`); + }, + }); + itBundled(`css/KeyframesTimelineRangeSelectorsMinified_${backend}`, { + backend, + files, + minifyWhitespace: true, + minifySyntax: true, + outdir: "/out", + onAfterBundle(api) { + api + .expectFile("/out/entry.css") + .toBe( + "@keyframes reveal{entry 0%{opacity:0}entry 100%{opacity:1}}" + + "@keyframes mixed{0%{opacity:0}cover 50%{opacity:.5}to{opacity:1}}" + + "@keyframes plain{0%{opacity:0}50%{opacity:.5}to{opacity:1}}\n", + ); + }, + }); + } + + // The body of a range block is parsed like the body of any other keyframe, so its url() is bundled too. + itBundled("css/KeyframesTimelineRangeSelectorURL", { + files: { + "/entry.css": /* css */ ` + @keyframes test { + from { background: url(./example.png) } + entry 50% { background: url(./example.png) } + } + `, + "/example.png": Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + }, + outdir: "/out", + onAfterBundle(api) { + api.expectFile("/out/entry.css").toBe(`/* entry.css */ +@keyframes test { + from { + background: url("data:image/png;base64,iVBORw0KGgo="); + } + + entry 50% { + background: url("data:image/png;base64,iVBORw0KGgo="); + } +} +`); + }, + }); }); diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 6e7b32d3b5e6..a37a8bc91ae7 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -2,6 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { cssInternals } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; import { join } from "path"; @@ -7201,6 +7202,163 @@ describe("css tests", () => { minify_test(".foo { animation-name: initial }", ".foo{animation-name:initial}"); }); + describe("keyframes", () => { + // https://drafts.csswg.org/scroll-animations-1/#named-range-keyframes + // = from | to | | + minify_test( + ` + @keyframes test { + entry 0% { + background: blue + } + exit 100% { + background: green + } + } + `, + "@keyframes test{entry 0%{background:#00f}exit 100%{background:green}}", + ); + + for (const name of ["cover", "contain", "entry", "exit", "entry-crossing", "exit-crossing", "scroll"]) { + minify_test(`@keyframes test { ${name} 25% { opacity: 0 } }`, `@keyframes test{${name} 25%{opacity:0}}`); + } + + // The range name is ASCII case-insensitive and serializes in lower case. + minify_test( + "@keyframes test { ENTRY 25% { opacity: 0 } Exit-Crossing 75% { opacity: 1 } }", + "@keyframes test{entry 25%{opacity:0}exit-crossing 75%{opacity:1}}", + ); + + // The `from` -> `0%` and `100%` -> `to` rewrites apply to plain selectors only. + minify_test( + "@keyframes test { entry 100% { opacity: 0 } exit 0% { opacity: 1 } }", + "@keyframes test{entry 100%{opacity:0}exit 0%{opacity:1}}", + ); + minify_test( + "@keyframes test { from, entry 0% { opacity: 0 } 100%, exit 100% { opacity: 1 } }", + "@keyframes test{0%,entry 0%{opacity:0}to,exit 100%{opacity:1}}", + ); + minify_test( + "@keyframes test { contain 0%, 50% { opacity: 0 } to { opacity: 1 } }", + "@keyframes test{contain 0%,50%{opacity:0}to{opacity:1}}", + ); + minify_test( + "@keyframes test { entry-crossing 10%, exit-crossing 90% { opacity: 0 } }", + "@keyframes test{entry-crossing 10%,exit-crossing 90%{opacity:0}}", + ); + + // The percentage of a range is not limited to 0%..100%. + minify_test("@keyframes test { entry 150% { opacity: 0 } }", "@keyframes test{entry 150%{opacity:0}}"); + minify_test("@keyframes test { entry -10% { opacity: 0 } }", "@keyframes test{entry -10%{opacity:0}}"); + minify_test("@keyframes test { entry 50.5% { opacity: 0 } }", "@keyframes test{entry 50.5%{opacity:0}}"); + minify_test("@keyframes test { entry +50% { opacity: 0 } }", "@keyframes test{entry 50%{opacity:0}}"); + // calc() folds as it does in a plain percentage selector. + minify_test("@keyframes test { entry calc(10% + 5%) { opacity: 0 } }", "@keyframes test{entry 15%{opacity:0}}"); + + // A comment or a newline separates the name from the percentage as well as a space does. + minify_test("@keyframes test { entry/**/0% { opacity: 0 } }", "@keyframes test{entry 0%{opacity:0}}"); + minify_test("@keyframes test { entry\n0% { opacity: 0 } }", "@keyframes test{entry 0%{opacity:0}}"); + + minify_test("@-webkit-keyframes test { exit 0% { opacity: 0 } }", "@-webkit-keyframes test{exit 0%{opacity:0}}"); + minify_test( + "@media (min-width: 100px) { @keyframes test { cover 50% { opacity: 0 } } }", + "@media (width>=100px){@keyframes test{cover 50%{opacity:0}}}", + ); + + // The valid rows of WPT css/css-animations/parsing/keyframe-selectors.html at 32c412f351, + // in the rule that file uses: `@keyframes --foo { {} }`. + for (const [selector, printed] of [ + ["0%", "0%"], + ["10%", "10%"], + ["100%", "to"], + ["from", "0%"], + ["to", "to"], + ["entry 10%", "entry 10%"], + ["exit 60%", "exit 60%"], + ["calc(10%)", "10%"], + ["entry calc(10%)", "entry 10%"], + ["exit calc(60%)", "exit 60%"], + ]) { + minify_test(`@keyframes --foo { ${selector} {} }`, `@keyframes --foo{${printed}{}}`); + } + // Valid in that file too. The percentage does not fold at build time, so the block is dropped. + for (const selector of ["calc(10% * sibling-index())", "calc(10% * sign(1em - 1px))"]) { + test.todo(`@keyframes --foo { ${selector} {} }`, () => { + expect(cssInternals.minifyTest(`@keyframes --foo { ${selector} {} }`, "")).not.toBe("@keyframes --foo{}"); + }); + } + + // The range arm accepts the grammar and no more. Today the `@keyframes` loop drops a block + // whose selector does not parse, as lightningcss and Chrome do. The `exit 100%` block shows + // that the rest of the rule stays. + for (const selector of [ + "entry to", + "entry from", + "from 50%", + "bogus 50%", + "entry", + "entry 0", + "entry 50 %", + "entry 0% 50%", + "50% entry", + "entry 0%, bogus 50%", + '"entry" 0%', + ]) { + minify_test( + `@keyframes test { ${selector} { opacity: 0 } exit 100% { opacity: 1 } }`, + "@keyframes test{exit 100%{opacity:1}}", + ); + } + + test("pretty output keeps every block and the space in a range selector", () => { + const source = [ + "@keyframes reveal { entry 0% { opacity: 0 } entry 100% { opacity: 1 } }", + "@keyframes mixed { from, exit-crossing 0% { opacity: 0 } cover 50% { opacity: .5 } to { opacity: 1 } }", + "@keyframes plain { from { opacity: 0 } 50% { opacity: .5 } to { opacity: 1 } }", + ].join("\n"); + const expected = indoc` + @keyframes reveal { + entry 0% { + opacity: 0; + } + + entry 100% { + opacity: 1; + } + } + + @keyframes mixed { + from, exit-crossing 0% { + opacity: 0; + } + + cover 50% { + opacity: .5; + } + + to { + opacity: 1; + } + } + + @keyframes plain { + from { + opacity: 0; + } + + 50% { + opacity: .5; + } + + to { + opacity: 1; + } + } + `; + expect(cssInternals._test(source, "")).toBe(expected + "\n"); + }); + }); + describe("transform", () => { minify_test(".foo { transform: translate(2px, 3px)", ".foo{transform:translate(2px,3px)}"); minify_test(".foo { transform: translate(2px, 0px)", ".foo{transform:translate(2px)}");