use autumn_web::{Markup, html};
const DEFAULT_DURATION_SECS: f32 = 0.6;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Ease {
Linear,
In,
Out,
InOut,
CircIn,
CircOut,
CircInOut,
BackIn,
BackOut,
BackInOut,
Anticipate,
CubicBezier(f32, f32, f32, f32),
Spring {
stiffness: f32,
damping: f32,
mass: f32,
},
}
impl Ease {
fn attr_value(self) -> String {
match self {
Self::Linear => "linear".to_owned(),
Self::In => "ease-in".to_owned(),
Self::Out => "ease-out".to_owned(),
Self::InOut => "ease-in-out".to_owned(),
Self::CircIn => "circ-in".to_owned(),
Self::CircOut => "circ-out".to_owned(),
Self::CircInOut => "circ-in-out".to_owned(),
Self::BackIn => "back-in".to_owned(),
Self::BackOut => "back-out".to_owned(),
Self::BackInOut => "back-in-out".to_owned(),
Self::Anticipate => "anticipate".to_owned(),
Self::CubicBezier(x1, y1, x2, y2) => {
format!("cubic-bezier({x1},{y1},{x2},{y2})")
}
Self::Spring {
stiffness,
damping,
mass,
} => format!("spring({stiffness},{damping},{mass})"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepeatType {
Loop,
Reverse,
Mirror,
}
impl RepeatType {
const fn attr(self) -> &'static str {
match self {
Self::Loop => "loop",
Self::Reverse => "reverse",
Self::Mirror => "mirror",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InViewAmount {
Some,
All,
Fraction(f32),
}
impl InViewAmount {
fn attr_value(self) -> String {
match self {
Self::Some => "some".to_string(),
Self::All => "all".to_string(),
Self::Fraction(v) => format!("{}", v.clamp(0.0, 1.0)),
}
}
}
impl From<f32> for InViewAmount {
fn from(v: f32) -> Self {
Self::Fraction(v)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaggerFrom {
First,
Last,
Center,
Edges,
Index(u32),
}
impl StaggerFrom {
fn attr_value(self) -> String {
match self {
Self::First => "first".to_string(),
Self::Last => "last".to_string(),
Self::Center => "center".to_string(),
Self::Edges => "edges".to_string(),
Self::Index(i) => i.to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Preset {
FadeUp,
FadeDown,
FadeLeft,
FadeRight,
Fade,
Scale,
ZoomIn,
ZoomOut,
SlideLeft,
SlideRight,
SlideUp,
SlideDown,
RotateIn,
BlurIn,
Parallax,
}
impl Preset {
const fn attr(self) -> &'static str {
match self {
Self::FadeUp => "fade-up",
Self::FadeDown => "fade-down",
Self::FadeLeft => "fade-left",
Self::FadeRight => "fade-right",
Self::Fade => "fade",
Self::Scale => "scale",
Self::ZoomIn => "zoom-in",
Self::ZoomOut => "zoom-out",
Self::SlideLeft => "slide-left",
Self::SlideRight => "slide-right",
Self::SlideUp => "slide-up",
Self::SlideDown => "slide-down",
Self::RotateIn => "rotate-in",
Self::BlurIn => "blur-in",
Self::Parallax => "parallax",
}
}
}
#[derive(Debug, Clone)]
pub struct Motion {
preset: Preset,
delay_ms: u32,
duration_secs: Option<f32>,
ease: Option<Ease>,
repeat: Option<u32>,
repeat_type: Option<RepeatType>,
amount: Option<InViewAmount>,
margin: Option<String>,
scroll_target: Option<String>,
scroll_offset: Option<[String; 2]>,
parallax_factor: Option<f32>,
stagger_from: Option<StaggerFrom>,
stagger_ease: Option<Ease>,
hover_scale: Option<f32>,
hover_brightness: Option<f32>,
press_scale: Option<f32>,
once: bool,
stagger_ms: Option<u32>,
scroll: bool,
}
impl Motion {
#[must_use]
pub const fn fade_up() -> Self {
Self::preset(Preset::FadeUp)
}
#[must_use]
pub const fn fade_down() -> Self {
Self::preset(Preset::FadeDown)
}
#[must_use]
pub const fn fade_left() -> Self {
Self::preset(Preset::FadeLeft)
}
#[must_use]
pub const fn fade_right() -> Self {
Self::preset(Preset::FadeRight)
}
#[must_use]
pub const fn fade() -> Self {
Self::preset(Preset::Fade)
}
#[must_use]
pub const fn scale() -> Self {
Self::preset(Preset::Scale)
}
#[must_use]
pub const fn zoom_in() -> Self {
Self::preset(Preset::ZoomIn)
}
#[must_use]
pub const fn zoom_out() -> Self {
Self::preset(Preset::ZoomOut)
}
#[must_use]
pub const fn slide_left() -> Self {
Self::preset(Preset::SlideLeft)
}
#[must_use]
pub const fn slide_right() -> Self {
Self::preset(Preset::SlideRight)
}
#[must_use]
pub const fn slide_up() -> Self {
Self::preset(Preset::SlideUp)
}
#[must_use]
pub const fn slide_down() -> Self {
Self::preset(Preset::SlideDown)
}
#[must_use]
pub const fn rotate_in() -> Self {
Self::preset(Preset::RotateIn)
}
#[must_use]
pub const fn blur_in() -> Self {
Self::preset(Preset::BlurIn)
}
#[must_use]
pub const fn parallax(factor: f32) -> Self {
let mut motion = Self::preset(Preset::Parallax);
motion.parallax_factor = Some(factor);
motion
}
#[must_use]
pub fn scroll_progress() -> Markup {
html! {
div class="motion-progress" data-motion="scroll-progress" {}
}
}
const fn preset(preset: Preset) -> Self {
Self {
preset,
delay_ms: 0,
duration_secs: None,
ease: None,
repeat: None,
repeat_type: None,
amount: None,
margin: None,
scroll_target: None,
scroll_offset: None,
parallax_factor: None,
stagger_from: None,
stagger_ease: None,
hover_scale: None,
hover_brightness: None,
press_scale: None,
once: true,
stagger_ms: None,
scroll: false,
}
}
#[must_use]
pub const fn delay(mut self, ms: u32) -> Self {
self.delay_ms = ms;
self
}
#[must_use]
pub const fn duration(mut self, secs: f32) -> Self {
self.duration_secs = Some(secs);
self
}
#[must_use]
pub const fn ease(mut self, ease: Ease) -> Self {
self.ease = Some(ease);
self
}
#[must_use]
pub const fn repeat(mut self, n: u32) -> Self {
self.repeat = Some(n);
self
}
#[must_use]
pub const fn repeat_type(mut self, repeat_type: RepeatType) -> Self {
self.repeat_type = Some(repeat_type);
self
}
#[must_use]
pub fn amount(mut self, amount: impl Into<InViewAmount>) -> Self {
self.amount = Some(amount.into());
self
}
#[must_use]
pub fn margin(mut self, margin: impl Into<String>) -> Self {
self.margin = Some(margin.into());
self
}
#[must_use]
pub const fn once(mut self, once: bool) -> Self {
self.once = once;
self
}
#[must_use]
pub const fn stagger(mut self, ms: u32) -> Self {
self.stagger_ms = Some(ms);
self
}
#[must_use]
pub const fn stagger_from(mut self, from: StaggerFrom) -> Self {
self.stagger_from = Some(from);
self
}
#[must_use]
pub const fn stagger_ease(mut self, ease: Ease) -> Self {
self.stagger_ease = Some(ease);
self
}
#[must_use]
pub const fn scroll(mut self) -> Self {
self.scroll = true;
self
}
#[must_use]
pub fn scroll_target(mut self, selector: impl Into<String>) -> Self {
self.scroll_target = Some(selector.into());
self.scroll = true;
self
}
#[must_use]
pub fn scroll_offset(mut self, offset: [&str; 2]) -> Self {
self.scroll_offset = Some([offset[0].into(), offset[1].into()]);
self.scroll = true;
self
}
#[must_use]
pub const fn hover_scale(mut self, scale: f32) -> Self {
self.hover_scale = Some(scale);
self
}
#[must_use]
pub const fn hover_brightness(mut self, brightness: f32) -> Self {
self.hover_brightness = Some(brightness);
self
}
#[must_use]
pub const fn press_scale(mut self, scale: f32) -> Self {
self.press_scale = Some(scale);
self
}
#[allow(clippy::needless_pass_by_value)]
#[must_use]
pub fn wrap(self, markup: Markup) -> Markup {
let preset = self.preset.attr();
let delay = self.delay_ms.ne(&0).then(|| self.delay_ms.to_string());
let duration = self
.duration_secs
.filter(|d| *d != DEFAULT_DURATION_SECS)
.map(|d| d.to_string());
let once = (!self.once).then_some("false");
let stagger = self.stagger_ms.map(|ms| ms.to_string());
let scroll = self.scroll;
let ease = self.ease.map(Ease::attr_value);
let repeat = self.repeat.map(|n| n.to_string());
let repeat_type = self.repeat_type.map(RepeatType::attr);
let amount = self.amount.map(InViewAmount::attr_value);
let margin = self.margin;
let scroll_target = self.scroll_target;
let scroll_offset = self.scroll_offset.map(|[a, b]| format!("{a},{b}"));
let parallax = self.parallax_factor.map(|f| format!("{f}"));
let stagger_from = self.stagger_from.map(StaggerFrom::attr_value);
let stagger_ease = self.stagger_ease.map(Ease::attr_value);
let mut hover_parts = Vec::new();
if let Some(scale) = self.hover_scale {
hover_parts.push(format!("scale({scale})"));
}
if let Some(brightness) = self.hover_brightness {
hover_parts.push(format!("brightness({brightness})"));
}
let hover = (!hover_parts.is_empty()).then(|| hover_parts.join(" "));
let press = self.press_scale.map(|scale| format!("scale({scale})"));
html! {
div data-motion=(preset)
data-motion-delay=[delay.as_deref()]
data-motion-duration=[duration.as_deref()]
data-motion-ease=[ease.as_deref()]
data-motion-repeat=[repeat.as_deref()]
data-motion-repeat-type=[repeat_type]
data-motion-amount=[amount.as_deref()]
data-motion-margin=[margin.as_deref()]
data-motion-parallax=[parallax.as_deref()]
data-motion-scroll-target=[scroll_target.as_deref()]
data-motion-scroll-offset=[scroll_offset.as_deref()]
data-motion-stagger-from=[stagger_from.as_deref()]
data-motion-stagger-ease=[stagger_ease.as_deref()]
data-motion-hover=[hover.as_deref()]
data-motion-press=[press.as_deref()]
data-motion-once=[once]
data-motion-stagger=[stagger.as_deref()]
data-motion-scroll[scroll]
{
(markup)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn render(motion: Motion) -> String {
motion.wrap(html! {}).into_string()
}
#[test]
fn all_presets_render_their_attribute() {
let cases = [
(Motion::fade_up(), "fade-up"),
(Motion::fade_down(), "fade-down"),
(Motion::fade_left(), "fade-left"),
(Motion::fade_right(), "fade-right"),
(Motion::fade(), "fade"),
(Motion::scale(), "scale"),
(Motion::zoom_in(), "zoom-in"),
(Motion::zoom_out(), "zoom-out"),
(Motion::slide_left(), "slide-left"),
(Motion::slide_right(), "slide-right"),
(Motion::slide_up(), "slide-up"),
(Motion::slide_down(), "slide-down"),
(Motion::rotate_in(), "rotate-in"),
(Motion::blur_in(), "blur-in"),
];
for (motion, attr) in cases {
let html = render(motion);
assert!(
html.contains(&format!(r#"data-motion="{attr}""#)),
"preset renders data-motion=\"{attr}\": {html}"
);
}
}
#[test]
fn defaults_omit_optional_attributes() {
let html = render(Motion::fade_up());
for attr in [
"data-motion-delay",
"data-motion-duration",
"data-motion-ease",
"data-motion-repeat",
"data-motion-amount",
"data-motion-margin",
"data-motion-parallax",
"data-motion-scroll-target",
"data-motion-scroll-offset",
"data-motion-stagger-from",
"data-motion-stagger-ease",
"data-motion-hover",
"data-motion-press",
"data-motion-once",
"data-motion-stagger",
"data-motion-scroll",
] {
assert!(!html.contains(attr), "default omits {attr}: {html}");
}
}
#[test]
fn modifiers_render_their_attributes() {
let html = render(
Motion::scale()
.delay(150)
.duration(1.2)
.once(false)
.stagger(60),
);
assert!(html.contains(r#"data-motion-delay="150""#), "{html}");
assert!(html.contains(r#"data-motion-duration="1.2""#), "{html}");
assert!(html.contains(r#"data-motion-once="false""#), "{html}");
assert!(html.contains(r#"data-motion-stagger="60""#), "{html}");
}
#[test]
fn default_duration_is_omitted() {
let html = render(Motion::fade().duration(DEFAULT_DURATION_SECS));
assert!(
!html.contains("data-motion-duration"),
"explicit default duration is omitted: {html}"
);
}
#[test]
fn ease_renders_its_attribute() {
let cases = [
(Ease::Linear, r#"data-motion-ease="linear""#),
(Ease::Out, r#"data-motion-ease="ease-out""#),
(Ease::InOut, r#"data-motion-ease="ease-in-out""#),
(Ease::CircOut, r#"data-motion-ease="circ-out""#),
(Ease::BackIn, r#"data-motion-ease="back-in""#),
(Ease::Anticipate, r#"data-motion-ease="anticipate""#),
(
Ease::CubicBezier(0.16, 1.0, 0.3, 1.0),
r#"data-motion-ease="cubic-bezier(0.16,1,0.3,1)""#,
),
(
Ease::Spring {
stiffness: 300.0,
damping: 20.0,
mass: 1.0,
},
r#"data-motion-ease="spring(300,20,1)""#,
),
];
for (ease, attr) in cases {
let html = render(Motion::fade_up().ease(ease));
assert!(html.contains(attr), "ease renders {attr}: {html}");
}
}
#[test]
fn repeat_renders_its_attributes() {
let html = render(Motion::scale().repeat(2).repeat_type(RepeatType::Mirror));
assert!(html.contains(r#"data-motion-repeat="2""#), "{html}");
assert!(
html.contains(r#"data-motion-repeat-type="mirror""#),
"{html}"
);
}
#[test]
fn amount_variants_render() {
let html = render(Motion::fade_up().amount(0.5));
assert!(html.contains(r#"data-motion-amount="0.5""#), "{html}");
let html = render(Motion::fade_up().amount(InViewAmount::All));
assert!(html.contains(r#"data-motion-amount="all""#), "{html}");
let html = render(Motion::fade_up().amount(InViewAmount::Some));
assert!(html.contains(r#"data-motion-amount="some""#), "{html}");
}
#[test]
fn amount_fraction_clamps_to_unit_range() {
let html = render(Motion::fade_up().amount(1.5));
assert!(html.contains(r#"data-motion-amount="1""#), "{html}");
let html = render(Motion::fade_up().amount(-0.2));
assert!(html.contains(r#"data-motion-amount="0""#), "{html}");
}
#[test]
fn margin_renders() {
let html = render(Motion::fade_up().margin("-100px"));
assert!(html.contains(r#"data-motion-margin="-100px""#), "{html}");
}
#[test]
fn parallax_renders_kind_and_factor() {
let html = render(Motion::parallax(0.3));
assert!(html.contains(r#"data-motion="parallax""#), "{html}");
assert!(html.contains(r#"data-motion-parallax="0.3""#), "{html}");
}
#[test]
fn scroll_target_renders_and_implies_scroll() {
let html = render(Motion::fade().scroll_target("#hero"));
assert!(html.contains("data-motion-scroll-target"), "{html}");
assert!(html.contains("#hero"), "{html}");
assert!(html.contains("data-motion-scroll"), "{html}");
}
#[test]
fn scroll_offset_renders_comma_separated() {
let html = render(Motion::fade().scroll_offset(["start end", "center center"]));
assert!(
html.contains(r#"data-motion-scroll-offset="start end,center center""#),
"{html}"
);
}
#[test]
fn scroll_progress_renders_opinionated_bar() {
let html = Motion::scroll_progress().into_string();
assert!(html.contains(r#"data-motion="scroll-progress""#), "{html}");
assert!(html.contains(r#"class="motion-progress""#), "{html}");
}
#[test]
fn stagger_from_and_ease_render() {
let html = render(
Motion::fade_up()
.stagger(90)
.stagger_from(StaggerFrom::Center)
.stagger_ease(Ease::Out),
);
assert!(html.contains(r#"data-motion-stagger="90""#), "{html}");
assert!(
html.contains(r#"data-motion-stagger-from="center""#),
"{html}"
);
assert!(
html.contains(r#"data-motion-stagger-ease="ease-out""#),
"{html}"
);
}
#[test]
fn stagger_from_index_renders_number() {
let html = render(Motion::fade_up().stagger_from(StaggerFrom::Index(2)));
assert!(html.contains(r#"data-motion-stagger-from="2""#), "{html}");
}
#[test]
fn hover_and_press_render() {
let html = render(
Motion::fade_up()
.hover_scale(1.05)
.hover_brightness(1.1)
.press_scale(0.97),
);
assert!(
html.contains(r#"data-motion-hover="scale(1.05) brightness(1.1)""#),
"{html}"
);
assert!(
html.contains(r#"data-motion-press="scale(0.97)""#),
"{html}"
);
}
#[test]
fn scroll_renders_bare_attribute() {
let html = render(Motion::fade_up().scroll());
assert!(
html.contains("data-motion-scroll"),
"scroll renders the bare attribute: {html}"
);
}
#[test]
fn wrap_keeps_inner_markup() {
let html = Motion::fade_up()
.wrap(html! { p { "inner text" } })
.into_string();
assert!(html.starts_with("<div"), "{html}");
assert!(html.contains("<p>inner text</p>"), "{html}");
assert!(html.contains(r#"data-motion="fade-up""#), "{html}");
}
}