leptos_motion_scroll/
lib.rs

1//! Scroll animations for Leptos Motion
2
3#![warn(missing_docs)]
4
5use std::collections::HashMap;
6
7// Include the scroll integration tests
8#[cfg(test)]
9mod scroll_integration_tests;
10
11/// Scroll direction
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ScrollDirection {
14    /// Scrolling up
15    Up,
16    /// Scrolling down
17    Down,
18    /// Scrolling left
19    Left,
20    /// Scrolling right
21    Right,
22}
23
24/// Scroll trigger configuration
25#[derive(Debug, Clone)]
26pub struct ScrollTrigger {
27    /// Trigger threshold (0.0 to 1.0)
28    pub threshold: f64,
29    /// Whether trigger should repeat
30    pub repeat: bool,
31    /// Element ID to observe
32    pub element_id: String,
33}
34
35impl Default for ScrollTrigger {
36    fn default() -> Self {
37        Self {
38            threshold: 0.5,
39            repeat: false,
40            element_id: String::new(),
41        }
42    }
43}
44
45impl ScrollTrigger {
46    /// Create new scroll trigger
47    pub fn new(element_id: String) -> Self {
48        Self {
49            element_id,
50            ..Self::default()
51        }
52    }
53
54    /// Set threshold
55    pub fn with_threshold(mut self, threshold: f64) -> Self {
56        self.threshold = threshold.clamp(0.0, 1.0);
57        self
58    }
59
60    /// Set repeat
61    pub fn with_repeat(mut self, repeat: bool) -> Self {
62        self.repeat = repeat;
63        self
64    }
65}
66
67/// Scroll animation state
68#[derive(Debug, Clone)]
69pub struct ScrollAnimationState {
70    /// Current scroll position
71    pub scroll_y: f64,
72    /// Previous scroll position
73    pub previous_scroll_y: f64,
74    /// Scroll direction
75    pub direction: ScrollDirection,
76    /// Scroll velocity
77    pub velocity: f64,
78}
79
80impl Default for ScrollAnimationState {
81    fn default() -> Self {
82        Self {
83            scroll_y: 0.0,
84            previous_scroll_y: 0.0,
85            direction: ScrollDirection::Down,
86            velocity: 0.0,
87        }
88    }
89}
90
91impl ScrollAnimationState {
92    /// Create new scroll state
93    pub fn new() -> Self {
94        Self::default()
95    }
96
97    /// Update scroll position
98    pub fn update_position(&mut self, new_y: f64) {
99        self.previous_scroll_y = self.scroll_y;
100        self.scroll_y = new_y;
101
102        let delta = new_y - self.previous_scroll_y;
103        self.velocity = delta;
104
105        self.direction = if delta > 0.0 {
106            ScrollDirection::Down
107        } else if delta < 0.0 {
108            ScrollDirection::Up
109        } else {
110            self.direction // Keep previous direction if no change
111        };
112    }
113
114    /// Get scroll progress (0.0 to 1.0)
115    pub fn get_progress(&self, viewport_height: f64, document_height: f64) -> f64 {
116        if document_height <= viewport_height {
117            return 0.0;
118        }
119        let max_scroll = document_height - viewport_height;
120        (self.scroll_y / max_scroll).clamp(0.0, 1.0)
121    }
122
123    /// Check if scrolling in direction
124    pub fn is_scrolling(&self, direction: ScrollDirection) -> bool {
125        self.direction == direction && self.velocity.abs() > 0.1
126    }
127}
128
129/// Scroll animator
130#[derive(Default)]
131pub struct ScrollAnimator {
132    /// Active animations
133    pub active: bool,
134    /// Scroll triggers
135    triggers: HashMap<String, ScrollTrigger>,
136    /// Current scroll state
137    scroll_state: ScrollAnimationState,
138    /// Triggered elements
139    triggered: HashMap<String, bool>,
140}
141
142impl ScrollAnimator {
143    /// Create new scroll animator
144    pub fn new() -> Self {
145        Self {
146            active: false,
147            triggers: HashMap::new(),
148            scroll_state: ScrollAnimationState::new(),
149            triggered: HashMap::new(),
150        }
151    }
152
153    /// Add scroll trigger
154    pub fn add_trigger(&mut self, trigger: ScrollTrigger) {
155        let id = trigger.element_id.clone();
156        self.triggers.insert(id.clone(), trigger);
157        self.triggered.insert(id, false);
158    }
159
160    /// Remove scroll trigger
161    pub fn remove_trigger(&mut self, element_id: &str) -> bool {
162        let removed = self.triggers.remove(element_id).is_some();
163        self.triggered.remove(element_id);
164        removed
165    }
166
167    /// Update scroll position
168    pub fn update_scroll(&mut self, scroll_y: f64) {
169        self.scroll_state.update_position(scroll_y);
170        self.active = self.scroll_state.velocity.abs() > 0.1;
171    }
172
173    /// Check triggers and return activated ones
174    pub fn check_triggers(&mut self, viewport_height: f64, document_height: f64) -> Vec<String> {
175        let mut activated = Vec::new();
176        let progress = self
177            .scroll_state
178            .get_progress(viewport_height, document_height);
179
180        for (id, trigger) in &self.triggers {
181            let was_triggered = self.triggered.get(id).copied().unwrap_or(false);
182            let should_trigger = progress >= trigger.threshold;
183
184            if should_trigger && (!was_triggered || trigger.repeat) {
185                activated.push(id.clone());
186                self.triggered.insert(id.clone(), true);
187            } else if !should_trigger && trigger.repeat {
188                self.triggered.insert(id.clone(), false);
189            }
190        }
191
192        activated
193    }
194
195    /// Get current scroll state
196    pub fn get_scroll_state(&self) -> &ScrollAnimationState {
197        &self.scroll_state
198    }
199
200    /// Get trigger count
201    pub fn trigger_count(&self) -> usize {
202        self.triggers.len()
203    }
204
205    /// Clear all triggers
206    pub fn clear_triggers(&mut self) {
207        self.triggers.clear();
208        self.triggered.clear();
209    }
210
211    /// Check if element was triggered
212    pub fn was_triggered(&self, element_id: &str) -> bool {
213        self.triggered.get(element_id).copied().unwrap_or(false)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn test_scroll_direction_values() {
223        assert_eq!(ScrollDirection::Up, ScrollDirection::Up);
224        assert_eq!(ScrollDirection::Down, ScrollDirection::Down);
225        assert_eq!(ScrollDirection::Left, ScrollDirection::Left);
226        assert_eq!(ScrollDirection::Right, ScrollDirection::Right);
227    }
228
229    #[test]
230    fn test_scroll_trigger_new() {
231        let trigger = ScrollTrigger::new("element1".to_string());
232        assert_eq!(trigger.element_id, "element1");
233        assert_eq!(trigger.threshold, 0.5);
234        assert!(!trigger.repeat);
235    }
236
237    #[test]
238    fn test_scroll_trigger_default() {
239        let trigger = ScrollTrigger::default();
240        assert_eq!(trigger.element_id, "");
241        assert_eq!(trigger.threshold, 0.5);
242        assert!(!trigger.repeat);
243    }
244
245    #[test]
246    fn test_scroll_trigger_builder() {
247        let trigger = ScrollTrigger::new("element1".to_string())
248            .with_threshold(0.8)
249            .with_repeat(true);
250
251        assert_eq!(trigger.element_id, "element1");
252        assert_eq!(trigger.threshold, 0.8);
253        assert!(trigger.repeat);
254    }
255
256    #[test]
257    fn test_scroll_trigger_threshold_clamping() {
258        let trigger1 = ScrollTrigger::new("element1".to_string()).with_threshold(-0.5);
259        assert_eq!(trigger1.threshold, 0.0);
260
261        let trigger2 = ScrollTrigger::new("element2".to_string()).with_threshold(1.5);
262        assert_eq!(trigger2.threshold, 1.0);
263    }
264
265    #[test]
266    fn test_scroll_animation_state_new() {
267        let state = ScrollAnimationState::new();
268        assert_eq!(state.scroll_y, 0.0);
269        assert_eq!(state.previous_scroll_y, 0.0);
270        assert_eq!(state.direction, ScrollDirection::Down);
271        assert_eq!(state.velocity, 0.0);
272    }
273
274    #[test]
275    fn test_scroll_animation_state_default() {
276        let state = ScrollAnimationState::default();
277        assert_eq!(state.scroll_y, 0.0);
278        assert_eq!(state.previous_scroll_y, 0.0);
279        assert_eq!(state.direction, ScrollDirection::Down);
280        assert_eq!(state.velocity, 0.0);
281    }
282
283    #[test]
284    fn test_scroll_animation_state_update_position_down() {
285        let mut state = ScrollAnimationState::new();
286        state.update_position(100.0);
287
288        assert_eq!(state.scroll_y, 100.0);
289        assert_eq!(state.previous_scroll_y, 0.0);
290        assert_eq!(state.direction, ScrollDirection::Down);
291        assert_eq!(state.velocity, 100.0);
292    }
293
294    #[test]
295    fn test_scroll_animation_state_update_position_up() {
296        let mut state = ScrollAnimationState::new();
297        state.update_position(100.0);
298        state.update_position(50.0);
299
300        assert_eq!(state.scroll_y, 50.0);
301        assert_eq!(state.previous_scroll_y, 100.0);
302        assert_eq!(state.direction, ScrollDirection::Up);
303        assert_eq!(state.velocity, -50.0);
304    }
305
306    #[test]
307    fn test_scroll_animation_state_get_progress() {
308        let mut state = ScrollAnimationState::new();
309        state.update_position(200.0);
310
311        // Viewport: 600px, Document: 1200px, Scroll: 200px
312        // Max scroll: 1200 - 600 = 600px
313        // Progress: 200 / 600 = 0.33...
314        let progress = state.get_progress(600.0, 1200.0);
315        assert!((progress - 0.3333333333333333).abs() < 0.0001);
316    }
317
318    #[test]
319    fn test_scroll_animation_state_get_progress_no_scroll() {
320        let state = ScrollAnimationState::new();
321
322        // Document fits in viewport
323        let progress = state.get_progress(1200.0, 800.0);
324        assert_eq!(progress, 0.0);
325    }
326
327    #[test]
328    fn test_scroll_animation_state_is_scrolling() {
329        let mut state = ScrollAnimationState::new();
330
331        // Not scrolling initially
332        assert!(!state.is_scrolling(ScrollDirection::Down));
333        assert!(!state.is_scrolling(ScrollDirection::Up));
334
335        // Scroll down with significant velocity
336        state.update_position(10.0);
337        assert!(state.is_scrolling(ScrollDirection::Down));
338        assert!(!state.is_scrolling(ScrollDirection::Up));
339
340        // Scroll up
341        state.update_position(0.0);
342        assert!(!state.is_scrolling(ScrollDirection::Down));
343        assert!(state.is_scrolling(ScrollDirection::Up));
344    }
345
346    #[test]
347    fn test_scroll_animator_new() {
348        let animator = ScrollAnimator::new();
349        assert!(!animator.active);
350        assert_eq!(animator.trigger_count(), 0);
351    }
352
353    #[test]
354    fn test_scroll_animator_default() {
355        let animator = ScrollAnimator::default();
356        assert!(!animator.active);
357        assert_eq!(animator.trigger_count(), 0);
358    }
359
360    #[test]
361    fn test_scroll_animator_add_trigger() {
362        let mut animator = ScrollAnimator::new();
363        let trigger = ScrollTrigger::new("element1".to_string());
364
365        animator.add_trigger(trigger);
366        assert_eq!(animator.trigger_count(), 1);
367        assert!(!animator.was_triggered("element1"));
368    }
369
370    #[test]
371    fn test_scroll_animator_remove_trigger() {
372        let mut animator = ScrollAnimator::new();
373        let trigger = ScrollTrigger::new("element1".to_string());
374
375        animator.add_trigger(trigger);
376        assert_eq!(animator.trigger_count(), 1);
377
378        let removed = animator.remove_trigger("element1");
379        assert!(removed);
380        assert_eq!(animator.trigger_count(), 0);
381
382        let not_removed = animator.remove_trigger("nonexistent");
383        assert!(!not_removed);
384    }
385
386    #[test]
387    fn test_scroll_animator_update_scroll() {
388        let mut animator = ScrollAnimator::new();
389
390        animator.update_scroll(100.0);
391        assert!(animator.active); // Should be active due to velocity
392        assert_eq!(animator.get_scroll_state().scroll_y, 100.0);
393    }
394
395    #[test]
396    fn test_scroll_animator_check_triggers() {
397        let mut animator = ScrollAnimator::new();
398
399        // Add trigger at 50% threshold
400        let trigger = ScrollTrigger::new("element1".to_string()).with_threshold(0.5);
401        animator.add_trigger(trigger);
402
403        // Scroll to 30% - should not trigger
404        animator.update_scroll(180.0); // 30% of 600px max scroll (1200-600)
405        let activated = animator.check_triggers(600.0, 1200.0);
406        assert!(activated.is_empty());
407        assert!(!animator.was_triggered("element1"));
408
409        // Scroll to 60% - should trigger
410        animator.update_scroll(360.0); // 60% of 600px max scroll
411        let activated = animator.check_triggers(600.0, 1200.0);
412        assert_eq!(activated.len(), 1);
413        assert_eq!(activated[0], "element1");
414        assert!(animator.was_triggered("element1"));
415    }
416
417    #[test]
418    fn test_scroll_animator_check_triggers_repeat() {
419        let mut animator = ScrollAnimator::new();
420
421        // Add repeating trigger at 50% threshold
422        let trigger = ScrollTrigger::new("element1".to_string())
423            .with_threshold(0.5)
424            .with_repeat(true);
425        animator.add_trigger(trigger);
426
427        // Scroll past threshold
428        animator.update_scroll(360.0); // 60% of 600px max scroll
429        let activated = animator.check_triggers(600.0, 1200.0);
430        assert_eq!(activated.len(), 1);
431
432        // Scroll back below threshold
433        animator.update_scroll(240.0); // 40% of 600px max scroll
434        let activated = animator.check_triggers(600.0, 1200.0);
435        assert!(activated.is_empty());
436
437        // Scroll past threshold again - should retrigger due to repeat
438        animator.update_scroll(360.0); // 60% of 600px max scroll
439        let activated = animator.check_triggers(600.0, 1200.0);
440        assert_eq!(activated.len(), 1);
441    }
442
443    #[test]
444    fn test_scroll_animator_clear_triggers() {
445        let mut animator = ScrollAnimator::new();
446
447        let trigger1 = ScrollTrigger::new("element1".to_string());
448        let trigger2 = ScrollTrigger::new("element2".to_string());
449
450        animator.add_trigger(trigger1);
451        animator.add_trigger(trigger2);
452        assert_eq!(animator.trigger_count(), 2);
453
454        animator.clear_triggers();
455        assert_eq!(animator.trigger_count(), 0);
456    }
457
458    #[test]
459    fn test_scroll_animator_get_scroll_state() {
460        let mut animator = ScrollAnimator::new();
461        animator.update_scroll(150.0);
462
463        let state = animator.get_scroll_state();
464        assert_eq!(state.scroll_y, 150.0);
465        assert_eq!(state.direction, ScrollDirection::Down);
466    }
467}