Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions src/css/rules/keyframes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,43 @@ 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),
/// The `from` keyword. Equivalent to 0%.
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::<KeyframeSelector>() == 8);

impl KeyframeSelector {
fn to_css(&self, dest: &mut Printer) -> core::result::Result<(), PrintErr> {
match self {
Expand All @@ -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(())
}
Expand All @@ -141,15 +174,17 @@ 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,
},
}
}
}

// ─── 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<KeyframeSelector> {
if let Ok(p) = input.try_parse(Percentage::parse) {
return Ok(KeyframeSelector::Percentage(p));
Expand All @@ -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) =
<TimelineRangeName as css::EnumProperty>::from_ascii_case_insensitive(ident)
{
Ok(KeyframeSelector::TimelineRangePercentage {
name,
percentage: Percentage::parse(input)?,
})
} else {
Err(location.new_unexpected_token_error(css::Token::Ident(ident)))
}
Expand Down
31 changes: 31 additions & 0 deletions test/bundler/css/css-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 101 additions & 0 deletions test/bundler/esbuild/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2288,4 +2288,105 @@ c {
"/across-files-url.css",
],
});

// `<timeline-range-name> <percentage>` 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=");
}
}
`);
},
});
});
Loading
Loading