use std::time::Duration;
use autumn_web::{Markup, html};
use crate::attrs::{Attr, push_flag, push_opt};
use crate::ease::Ease;
use crate::fmt::{num, secs};
use crate::options::{Common, common_setters};
use crate::props::Props;
use crate::timeline::Position;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Preset {
Fade,
FadeUp,
FadeDown,
FadeLeft,
FadeRight,
Scale,
ZoomIn,
ZoomOut,
SlideUp,
SlideDown,
SlideLeft,
SlideRight,
RotateIn,
BlurIn,
FlipX,
}
impl Preset {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Fade => "fade",
Self::FadeUp => "fade-up",
Self::FadeDown => "fade-down",
Self::FadeLeft => "fade-left",
Self::FadeRight => "fade-right",
Self::Scale => "scale",
Self::ZoomIn => "zoom-in",
Self::ZoomOut => "zoom-out",
Self::SlideUp => "slide-up",
Self::SlideDown => "slide-down",
Self::SlideLeft => "slide-left",
Self::SlideRight => "slide-right",
Self::RotateIn => "rotate-in",
Self::BlurIn => "blur-in",
Self::FlipX => "flip-x",
}
}
pub const ALL: &'static [Self] = &[
Self::Fade,
Self::FadeUp,
Self::FadeDown,
Self::FadeLeft,
Self::FadeRight,
Self::Scale,
Self::ZoomIn,
Self::ZoomOut,
Self::SlideUp,
Self::SlideDown,
Self::SlideLeft,
Self::SlideRight,
Self::RotateIn,
Self::BlurIn,
Self::FlipX,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StaggerFrom {
Start,
Center,
End,
Edges,
Random,
Index(u32),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Split {
Chars,
Words,
Lines,
}
#[derive(Debug, Clone, PartialEq)]
#[must_use]
pub struct Gsap {
preset: Option<Preset>,
from: Option<Props>,
to: Option<Props>,
stagger: Option<Duration>,
stagger_from: Option<StaggerFrom>,
stagger_ease: Option<Ease>,
split: Option<Split>,
split_mask: bool,
position: Option<Position>,
common: Common,
}
macro_rules! presets {
($($(#[$doc:meta])* $fn_name:ident => $variant:ident;)*) => {
$(
$(#[$doc])*
pub fn $fn_name() -> Self {
Self::preset(Preset::$variant)
}
)*
};
}
impl Gsap {
pub fn preset(preset: Preset) -> Self {
Self {
preset: Some(preset),
from: None,
to: None,
stagger: None,
stagger_from: None,
stagger_ease: None,
split: None,
split_mask: false,
position: None,
common: Common::default(),
}
}
presets! {
fade => Fade;
fade_up => FadeUp;
fade_down => FadeDown;
fade_left => FadeLeft;
fade_right => FadeRight;
scale => Scale;
zoom_in => ZoomIn;
zoom_out => ZoomOut;
slide_up => SlideUp;
slide_down => SlideDown;
slide_left => SlideLeft;
slide_right => SlideRight;
rotate_in => RotateIn;
blur_in => BlurIn;
flip_x => FlipX;
}
fn custom(from: Option<Props>, to: Option<Props>) -> Self {
let mut g = Self::preset(Preset::Fade);
g.preset = None;
g.from = from.filter(|p| !p.is_empty());
g.to = to.filter(|p| !p.is_empty());
g
}
pub fn from_props(props: Props) -> Self {
Self::custom(Some(props), None)
}
pub fn to_props(props: Props) -> Self {
Self::custom(None, Some(props))
}
pub fn from_to(from: Props, to: Props) -> Self {
Self::custom(Some(from), Some(to))
}
pub const fn parallax(factor: f32) -> Parallax {
Parallax {
factor,
id: None,
class: None,
animate_reduced: false,
}
}
#[must_use]
pub fn scroll_progress() -> Markup {
html! {
div class="gsap-progress" data-gsap="scroll-progress" aria-hidden="true" {}
}
}
common_setters!();
pub const fn stagger(mut self, each: Duration) -> Self {
self.stagger = Some(each);
self
}
pub const fn stagger_from(mut self, from: StaggerFrom) -> Self {
self.stagger_from = Some(from);
self
}
pub const fn stagger_ease(mut self, ease: Ease) -> Self {
self.stagger_ease = Some(ease);
self
}
pub const fn split(mut self, unit: Split) -> Self {
self.split = Some(unit);
self
}
pub const fn split_mask(mut self) -> Self {
self.split_mask = true;
self
}
pub const fn position(mut self, position: Position) -> Self {
self.position = Some(position);
self
}
#[must_use]
pub fn attributes(&self) -> Vec<Attr> {
let kind = self.preset.map_or("custom", Preset::name);
let mut out = vec![("data-gsap", kind.to_owned())];
push_opt(
&mut out,
"data-gsap-from",
self.from.as_ref().map(Props::to_json),
);
push_opt(
&mut out,
"data-gsap-to",
self.to.as_ref().map(Props::to_json),
);
self.common.push(&mut out);
push_opt(&mut out, "data-gsap-stagger", self.stagger.map(secs));
push_opt(
&mut out,
"data-gsap-stagger-from",
self.stagger_from.map(|f| match f {
StaggerFrom::Start => "start".to_owned(),
StaggerFrom::Center => "center".to_owned(),
StaggerFrom::End => "end".to_owned(),
StaggerFrom::Edges => "edges".to_owned(),
StaggerFrom::Random => "random".to_owned(),
StaggerFrom::Index(i) => i.to_string(),
}),
);
push_opt(
&mut out,
"data-gsap-stagger-ease",
self.stagger_ease.map(|e| e.to_string()),
);
push_opt(
&mut out,
"data-gsap-split",
self.split.map(|s| {
match s {
Split::Chars => "chars",
Split::Words => "words",
Split::Lines => "lines",
}
.to_owned()
}),
);
push_flag(
&mut out,
"data-gsap-split-mask",
self.split_mask && self.split.is_some(),
);
push_opt(
&mut out,
"data-gsap-position",
self.position.map(|p| p.to_string()),
);
out
}
}
#[derive(Debug, Clone, PartialEq)]
#[must_use]
pub struct Parallax {
factor: f32,
id: Option<String>,
class: Option<String>,
animate_reduced: bool,
}
impl Parallax {
pub fn id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into()).filter(|s| !s.trim().is_empty());
self
}
pub fn class(mut self, class: impl Into<String>) -> Self {
self.class = Some(class.into()).filter(|s| !s.trim().is_empty());
self
}
pub const fn animate_on_reduced_motion(mut self) -> Self {
self.animate_reduced = true;
self
}
#[must_use]
pub fn attributes(&self) -> Vec<Attr> {
let mut out = vec![("data-gsap", "parallax".to_owned())];
push_opt(&mut out, "data-gsap-parallax", num(self.factor));
push_opt(
&mut out,
"data-gsap-reduced",
self.animate_reduced.then(|| "animate".to_owned()),
);
out
}
#[allow(clippy::needless_pass_by_value)]
#[must_use]
pub fn wrap(self, markup: Markup) -> Markup {
self.wrap_in(crate::Tag::Div, markup)
}
#[allow(clippy::needless_pass_by_value)]
#[must_use]
pub fn wrap_in(self, tag: crate::Tag, markup: Markup) -> Markup {
let mut attrs = Vec::new();
push_opt(&mut attrs, "id", self.id.clone());
push_opt(&mut attrs, "class", self.class.clone());
attrs.extend(self.attributes());
crate::attrs::wrap_el(tag, &attrs, &markup)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scroll::{Action, Edge, Play, ScrollPos, Scrub, ToggleActions};
use crate::{EaseDir, Repeat};
fn attrs(g: &Gsap) -> Vec<(String, String)> {
g.attributes()
.into_iter()
.map(|(k, v)| (k.to_owned(), v))
.collect()
}
fn has(g: &Gsap, name: &str, value: &str) -> bool {
attrs(g).iter().any(|(k, v)| k == name && v == value)
}
#[test]
fn presets_write_their_names() {
let cases = [
(Gsap::fade(), "fade"),
(Gsap::fade_up(), "fade-up"),
(Gsap::fade_down(), "fade-down"),
(Gsap::fade_left(), "fade-left"),
(Gsap::fade_right(), "fade-right"),
(Gsap::scale(), "scale"),
(Gsap::zoom_in(), "zoom-in"),
(Gsap::zoom_out(), "zoom-out"),
(Gsap::slide_up(), "slide-up"),
(Gsap::slide_down(), "slide-down"),
(Gsap::slide_left(), "slide-left"),
(Gsap::slide_right(), "slide-right"),
(Gsap::rotate_in(), "rotate-in"),
(Gsap::blur_in(), "blur-in"),
(Gsap::flip_x(), "flip-x"),
];
for (g, name) in cases {
assert_eq!(attrs(&g), vec![("data-gsap".to_owned(), name.to_owned())]);
}
}
#[test]
fn preset_names_are_unique_and_kebab_case() {
let names: Vec<_> = Preset::ALL.iter().map(|p| p.name()).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), names.len());
for n in names {
assert_ne!(n, "");
assert!(
n.bytes().all(|b| b.is_ascii_lowercase() || b == b'-'),
"{n}"
);
}
}
#[test]
fn defaults_write_only_the_preset() {
let g = Gsap::fade_up().play(Play::Scroll);
assert_eq!(attrs(&g).len(), 1, "{:?}", attrs(&g));
}
#[test]
fn an_explicit_duration_is_always_written() {
let g = Gsap::fade().duration(Duration::from_millis(800));
assert!(has(&g, "data-gsap-duration", "0.8"));
}
#[test]
fn empty_strings_write_no_attribute() {
let g = Gsap::fade().id("").class(" ").trigger(" ");
assert_eq!(attrs(&g).len(), 1, "{:?}", attrs(&g));
let html = g.wrap(html! {}).into_string();
assert_eq!(html, r#"<div data-gsap="fade"></div>"#);
}
#[test]
fn empty_props_write_no_json() {
let g = Gsap::from_to(Props::new(), Props::new().x(f32::NAN));
assert_eq!(
attrs(&g),
vec![("data-gsap".to_owned(), "custom".to_owned())]
);
}
#[test]
fn timing_options_write_attributes() {
let g = Gsap::fade()
.play(Play::Load)
.delay(Duration::from_millis(200))
.duration(Duration::from_millis(1200))
.ease(Ease::Expo(EaseDir::Out))
.repeat(Repeat::Times(2))
.yoyo()
.repeat_delay(Duration::from_millis(100));
assert!(has(&g, "data-gsap-on", "load"));
assert!(has(&g, "data-gsap-delay", "0.2"));
assert!(has(&g, "data-gsap-duration", "1.2"));
assert!(has(&g, "data-gsap-ease", "expo.out"));
assert!(has(&g, "data-gsap-repeat", "2"));
assert!(has(&g, "data-gsap-yoyo", ""));
assert!(has(&g, "data-gsap-repeat-delay", "0.1"));
let forever = Gsap::fade().repeat(Repeat::Forever);
assert!(has(&forever, "data-gsap-repeat", "-1"));
}
#[test]
fn scroll_options_write_attributes() {
let g = Gsap::fade_up()
.trigger("#hero")
.start(ScrollPos::new(Edge::Top, Edge::Center))
.end(ScrollPos::Distance(400.0))
.toggle_actions(ToggleActions::new(
Action::Play,
Action::None,
Action::None,
Action::Reverse,
))
.replay()
.scrub(Scrub::Smooth(Duration::from_millis(500)))
.pin()
.markers()
.animate_on_reduced_motion();
assert!(has(&g, "data-gsap-trigger", "#hero"));
assert!(has(&g, "data-gsap-start", "top center"));
assert!(has(&g, "data-gsap-end", "+=400"));
assert!(has(
&g,
"data-gsap-toggle-actions",
"play none none reverse"
));
assert!(has(&g, "data-gsap-once", "false"));
assert!(has(&g, "data-gsap-scrub", "0.5"));
assert!(has(&g, "data-gsap-pin", ""));
assert!(has(&g, "data-gsap-markers", ""));
assert!(has(&g, "data-gsap-reduced", "animate"));
}
#[test]
fn stagger_and_split_write_attributes() {
let g = Gsap::fade_up()
.stagger(Duration::from_millis(80))
.stagger_from(StaggerFrom::Center)
.stagger_ease(Ease::Power2(EaseDir::In))
.split(Split::Words)
.split_mask();
assert!(has(&g, "data-gsap-stagger", "0.08"));
assert!(has(&g, "data-gsap-stagger-from", "center"));
assert!(has(&g, "data-gsap-stagger-ease", "power2.in"));
assert!(has(&g, "data-gsap-split", "words"));
assert!(has(&g, "data-gsap-split-mask", ""));
}
#[test]
fn stagger_from_values() {
let cases = [
(StaggerFrom::Start, "start"),
(StaggerFrom::Center, "center"),
(StaggerFrom::End, "end"),
(StaggerFrom::Edges, "edges"),
(StaggerFrom::Random, "random"),
(StaggerFrom::Index(3), "3"),
];
for (from, want) in cases {
assert!(has(
&Gsap::fade().stagger_from(from),
"data-gsap-stagger-from",
want
));
}
}
#[test]
fn split_units() {
for (unit, want) in [
(Split::Chars, "chars"),
(Split::Words, "words"),
(Split::Lines, "lines"),
] {
assert!(has(&Gsap::fade().split(unit), "data-gsap-split", want));
}
}
#[test]
fn custom_props_write_json() {
let from = Gsap::from_props(Props::new().x(-40.0));
assert!(has(&from, "data-gsap", "custom"));
assert!(has(&from, "data-gsap-from", r#"{"x":-40}"#));
assert!(!attrs(&from).iter().any(|(k, _)| k == "data-gsap-to"));
let to = Gsap::to_props(Props::new().rotation(360.0));
assert!(has(&to, "data-gsap-to", r#"{"rotation":360}"#));
assert!(!attrs(&to).iter().any(|(k, _)| k == "data-gsap-from"));
let both = Gsap::from_to(Props::new().scale(0.0), Props::new().scale(1.0));
assert!(has(&both, "data-gsap-from", r#"{"scale":0}"#));
assert!(has(&both, "data-gsap-to", r#"{"scale":1}"#));
}
#[test]
fn parallax_writes_the_factor() {
let p = Gsap::parallax(-0.25);
assert_eq!(
p.attributes(),
vec![
("data-gsap", "parallax".to_owned()),
("data-gsap-parallax", "-0.25".to_owned()),
]
);
assert_eq!(Gsap::parallax(f32::NAN).attributes().len(), 1);
}
#[test]
fn parallax_has_only_the_options_that_init_js_reads() {
let html = Gsap::parallax(0.3)
.id("art")
.class("hero-art")
.animate_on_reduced_motion()
.wrap_in(crate::Tag::Figure, html! {})
.into_string();
assert_eq!(
html,
concat!(
r#"<figure id="art" class="hero-art" data-gsap="parallax" "#,
r#"data-gsap-parallax="0.3" data-gsap-reduced="animate"></figure>"#
)
);
assert!(
Gsap::parallax(0.3)
.wrap(html! {})
.into_string()
.starts_with("<div")
);
}
#[test]
fn position_writes_an_attribute() {
let g = Gsap::fade().position(Position::WithPrevious);
assert!(has(&g, "data-gsap-position", "<"));
let after = Gsap::fade().position(Position::After);
assert!(has(&after, "data-gsap-position", ">"));
assert!(
!attrs(&Gsap::fade())
.iter()
.any(|(k, _)| k == "data-gsap-position")
);
}
#[test]
fn scroll_progress_renders_the_bar() {
let html = Gsap::scroll_progress().into_string();
assert_eq!(
html,
r#"<div class="gsap-progress" data-gsap="scroll-progress" aria-hidden="true"></div>"#
);
}
#[test]
fn wrap_in_writes_tag_id_and_class() {
let html = Gsap::fade_up()
.id("t1")
.class("title big")
.wrap_in(crate::Tag::H1, html! { "Hi" })
.into_string();
assert_eq!(
html,
r#"<h1 id="t1" class="title big" data-gsap="fade-up">Hi</h1>"#
);
let attrs = Gsap::fade_up().id("t1").class("x").attributes();
assert_eq!(attrs.len(), 1, "id and class are not data-gsap attributes");
}
#[test]
fn wrap_keeps_the_inner_markup() {
let html = Gsap::fade_up()
.delay(Duration::from_millis(100))
.wrap(html! { p { "inner" } })
.into_string();
assert_eq!(
html,
r#"<div data-gsap="fade-up" data-gsap-delay="0.1"><p>inner</p></div>"#
);
}
}