adui_dioxus/components/
affix.rs

1//! Affix component - wraps content and pins it to the viewport when scrolling.
2//!
3//! Ported from Ant Design's Affix component.
4
5use dioxus::prelude::*;
6
7/// Props for the Affix component.
8#[derive(Props, Clone, PartialEq)]
9pub struct AffixProps {
10    /// Offset from the top of the viewport (in pixels) when to start affixing.
11    /// If neither `offset_top` nor `offset_bottom` is set, defaults to 0.
12    #[props(optional)]
13    pub offset_top: Option<f64>,
14
15    /// Offset from the bottom of the viewport (in pixels) when to start affixing.
16    #[props(optional)]
17    pub offset_bottom: Option<f64>,
18
19    /// Callback fired when the affix state changes.
20    #[props(optional)]
21    pub on_change: Option<EventHandler<bool>>,
22
23    /// Additional CSS class for the wrapper element.
24    #[props(optional)]
25    pub class: Option<String>,
26
27    /// Additional inline styles for the wrapper element.
28    #[props(optional)]
29    pub style: Option<String>,
30
31    /// The content to be affixed.
32    pub children: Element,
33}
34
35/// Internal state for affix positioning.
36#[derive(Clone, Copy, Debug, PartialEq)]
37struct AffixState {
38    /// Whether the content is currently affixed.
39    affixed: bool,
40    /// The fixed position style (top or bottom offset).
41    fixed_top: Option<f64>,
42    fixed_bottom: Option<f64>,
43    /// Placeholder dimensions to prevent layout shift.
44    placeholder_width: f64,
45    placeholder_height: f64,
46    /// Left position to maintain horizontal alignment.
47    placeholder_left: f64,
48}
49
50impl Default for AffixState {
51    fn default() -> Self {
52        Self {
53            affixed: false,
54            fixed_top: None,
55            fixed_bottom: None,
56            placeholder_width: 0.0,
57            placeholder_height: 0.0,
58            placeholder_left: 0.0,
59        }
60    }
61}
62
63/// Affix component - pins its children to the viewport when scrolling past a threshold.
64///
65/// # Example
66///
67/// ```rust,ignore
68/// use adui_dioxus::Affix;
69///
70/// rsx! {
71///     Affix { offset_top: 10.0,
72///         div { "This content will stick to the top when scrolled" }
73///     }
74/// }
75/// ```
76#[component]
77pub fn Affix(props: AffixProps) -> Element {
78    let AffixProps {
79        offset_top,
80        offset_bottom,
81        on_change,
82        class,
83        style,
84        children,
85    } = props;
86
87    // Default to offset_top = 0 if neither is specified
88    let effective_offset_top = if offset_bottom.is_none() && offset_top.is_none() {
89        Some(0.0)
90    } else {
91        offset_top
92    };
93
94    let affix_state: Signal<AffixState> = use_signal(AffixState::default);
95    let last_affixed: Signal<bool> = use_signal(|| false);
96
97    // Silence warnings for non-wasm targets
98    let _ = (&effective_offset_top, &on_change, &last_affixed);
99
100    // Unique ID for the placeholder element
101    let placeholder_id = use_signal(|| format!("adui-affix-{}", rand_id()));
102    let fixed_id = use_signal(|| format!("adui-affix-fixed-{}", rand_id()));
103
104    // Set up scroll/resize listeners
105    #[cfg(target_arch = "wasm32")]
106    {
107        let placeholder_id_for_effect = placeholder_id.read().clone();
108        let offset_top_val = effective_offset_top;
109        let offset_bottom_val = offset_bottom;
110        let on_change_cb = on_change;
111        let mut state_signal = affix_state;
112        let mut last_affixed_signal = last_affixed;
113
114        use_effect(move || {
115            use wasm_bindgen::{JsCast, closure::Closure};
116
117            let window = match web_sys::window() {
118                Some(w) => w,
119                None => return,
120            };
121
122            let document = match window.document() {
123                Some(d) => d,
124                None => return,
125            };
126
127            let placeholder_id_clone = placeholder_id_for_effect.clone();
128
129            // Create the event handler closure
130            let handler = Closure::<dyn FnMut(web_sys::Event)>::wrap(Box::new(
131                move |_evt: web_sys::Event| {
132                    let Some(window) = web_sys::window() else {
133                        return;
134                    };
135                    let Some(document) = window.document() else {
136                        return;
137                    };
138
139                    let placeholder = match document.get_element_by_id(&placeholder_id_clone) {
140                        Some(el) => el,
141                        None => return,
142                    };
143
144                    let placeholder_rect = placeholder.get_bounding_client_rect();
145
146                    // Skip if element is not visible
147                    if placeholder_rect.width() == 0.0 && placeholder_rect.height() == 0.0 {
148                        return;
149                    }
150
151                    let window_height = window
152                        .inner_height()
153                        .ok()
154                        .and_then(|v| v.as_f64())
155                        .unwrap_or(0.0);
156
157                    let mut new_state = AffixState::default();
158                    new_state.placeholder_width = placeholder_rect.width();
159                    new_state.placeholder_height = placeholder_rect.height();
160                    new_state.placeholder_left = placeholder_rect.left();
161
162                    // Check if should affix to top
163                    if let Some(top_offset) = offset_top_val {
164                        if placeholder_rect.top() < top_offset {
165                            new_state.affixed = true;
166                            new_state.fixed_top = Some(top_offset);
167                        }
168                    }
169
170                    // Check if should affix to bottom
171                    // Element should be fixed when its bottom edge goes below the viewport bottom minus offset
172                    if let Some(bottom_offset) = offset_bottom_val {
173                        if placeholder_rect.bottom() > window_height - bottom_offset {
174                            new_state.affixed = true;
175                            new_state.fixed_bottom = Some(bottom_offset);
176                            new_state.fixed_top = None; // Bottom takes precedence
177                        }
178                    }
179
180                    let prev_affixed = *last_affixed_signal.read();
181                    if prev_affixed != new_state.affixed {
182                        last_affixed_signal.set(new_state.affixed);
183                        if let Some(cb) = on_change_cb {
184                            cb.call(new_state.affixed);
185                        }
186                    }
187
188                    state_signal.set(new_state);
189                },
190            ));
191
192            // Add event listeners
193            let _ =
194                window.add_event_listener_with_callback("scroll", handler.as_ref().unchecked_ref());
195            let _ =
196                window.add_event_listener_with_callback("resize", handler.as_ref().unchecked_ref());
197
198            // Keep the closure alive
199            handler.forget();
200        });
201    }
202
203    let state = *affix_state.read();
204    let placeholder_id_val = placeholder_id.read().clone();
205    let fixed_id_val = fixed_id.read().clone();
206
207    // Build class names
208    let mut class_list = vec!["adui-affix-wrapper".to_string()];
209    if let Some(extra) = class {
210        class_list.push(extra);
211    }
212    let class_attr = class_list.join(" ");
213
214    // Build wrapper style
215    let style_attr = style.unwrap_or_default();
216
217    // Build placeholder style (maintains layout when content is fixed)
218    let placeholder_style = if state.affixed {
219        format!(
220            "width: {}px; height: {}px;",
221            state.placeholder_width, state.placeholder_height
222        )
223    } else {
224        String::new()
225    };
226
227    // Build fixed element style
228    let fixed_style = if state.affixed {
229        let mut style_parts = vec![
230            "position: fixed".to_string(),
231            format!("left: {}px", state.placeholder_left),
232            format!("width: {}px", state.placeholder_width),
233            "z-index: 10".to_string(),
234        ];
235
236        if let Some(top) = state.fixed_top {
237            style_parts.push(format!("top: {}px", top));
238        }
239        if let Some(bottom) = state.fixed_bottom {
240            style_parts.push(format!("bottom: {}px", bottom));
241        }
242
243        style_parts.join("; ") + ";"
244    } else {
245        String::new()
246    };
247
248    let fixed_class = if state.affixed { "adui-affix" } else { "" };
249
250    rsx! {
251        div {
252            class: "{class_attr}",
253            style: "{style_attr}",
254            id: "{placeholder_id_val}",
255
256            // Placeholder to maintain layout
257            if state.affixed {
258                div {
259                    style: "{placeholder_style}",
260                    "aria-hidden": "true",
261                }
262            }
263
264            // The actual content
265            div {
266                id: "{fixed_id_val}",
267                class: "{fixed_class}",
268                style: "{fixed_style}",
269                {children}
270            }
271        }
272    }
273}
274
275/// Generate a simple random ID for element identification.
276fn rand_id() -> u32 {
277    // Simple pseudo-random based on current time
278    #[cfg(target_arch = "wasm32")]
279    {
280        use js_sys::Math;
281        (Math::random() * 1_000_000.0) as u32
282    }
283
284    #[cfg(not(target_arch = "wasm32"))]
285    {
286        use std::time::{SystemTime, UNIX_EPOCH};
287        SystemTime::now()
288            .duration_since(UNIX_EPOCH)
289            .map(|d| d.subsec_nanos())
290            .unwrap_or(0)
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn affix_state_default_is_not_affixed() {
300        let state = AffixState::default();
301        assert!(!state.affixed);
302        assert!(state.fixed_top.is_none());
303        assert!(state.fixed_bottom.is_none());
304    }
305
306    #[test]
307    fn affix_state_default_all_fields() {
308        let state = AffixState::default();
309        assert!(!state.affixed);
310        assert!(state.fixed_top.is_none());
311        assert!(state.fixed_bottom.is_none());
312        assert_eq!(state.placeholder_width, 0.0);
313        assert_eq!(state.placeholder_height, 0.0);
314        assert_eq!(state.placeholder_left, 0.0);
315    }
316
317    #[test]
318    fn affix_state_clone_and_copy() {
319        let state1 = AffixState {
320            affixed: true,
321            fixed_top: Some(10.0),
322            fixed_bottom: None,
323            placeholder_width: 100.0,
324            placeholder_height: 50.0,
325            placeholder_left: 20.0,
326        };
327        let state2 = state1; // Copy
328        assert_eq!(state1, state2);
329        assert_eq!(state1.affixed, state2.affixed);
330        assert_eq!(state1.placeholder_width, state2.placeholder_width);
331    }
332
333    #[test]
334    fn affix_state_partial_eq() {
335        let state1 = AffixState::default();
336        let state2 = AffixState::default();
337        assert_eq!(state1, state2);
338
339        let state3 = AffixState {
340            affixed: true,
341            fixed_top: Some(10.0),
342            fixed_bottom: None,
343            placeholder_width: 0.0,
344            placeholder_height: 0.0,
345            placeholder_left: 0.0,
346        };
347        assert_ne!(state1, state3);
348    }
349
350    #[test]
351    fn affix_state_debug() {
352        let state = AffixState::default();
353        let debug_str = format!("{:?}", state);
354        assert!(debug_str.contains("AffixState"));
355    }
356
357    #[test]
358    fn rand_id_produces_values() {
359        let id1 = rand_id();
360        // Ensure it doesn't panic and produces a valid u32 value
361        // Note: On wasm32 targets, values are <= 1_000_000
362        // On non-wasm32 targets, this uses subsec_nanos which can be any u32 value
363        // We just verify it's a valid u32 (no panic)
364        let _ = id1;
365        assert!(true);
366    }
367
368    #[test]
369    fn rand_id_generates_different_values() {
370        // Test that rand_id can generate values (may be same or different)
371        let id1 = rand_id();
372        let id2 = rand_id();
373        // Both should be valid u32 values
374        // Note: Values might be the same if called very quickly, but that's acceptable
375        // On wasm32, values are <= 1_000_000, on other targets they use subsec_nanos
376        let _ = (id1, id2);
377        assert!(true);
378    }
379
380    #[test]
381    fn affix_props_optional_fields() {
382        // Test that optional fields can be None
383        // Note: AffixProps requires children, so we can't create a full instance
384        let _offset_top: Option<f64> = None;
385        let _offset_bottom: Option<f64> = None;
386        let _on_change: Option<EventHandler<bool>> = None;
387        let _class: Option<String> = None;
388        let _style: Option<String> = None;
389        // All optional fields can be None
390        assert!(true);
391    }
392
393    #[test]
394    fn affix_props_with_values() {
395        // Test that optional fields can have values
396        let offset_top = Some(10.0);
397        assert_eq!(offset_top.unwrap(), 10.0);
398
399        let offset_bottom = Some(20.0);
400        assert_eq!(offset_bottom.unwrap(), 20.0);
401    }
402}