1use dioxus::prelude::*;
6
7#[derive(Props, Clone, PartialEq)]
9pub struct AffixProps {
10 #[props(optional)]
13 pub offset_top: Option<f64>,
14
15 #[props(optional)]
17 pub offset_bottom: Option<f64>,
18
19 #[props(optional)]
21 pub on_change: Option<EventHandler<bool>>,
22
23 #[props(optional)]
25 pub class: Option<String>,
26
27 #[props(optional)]
29 pub style: Option<String>,
30
31 pub children: Element,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq)]
37struct AffixState {
38 affixed: bool,
40 fixed_top: Option<f64>,
42 fixed_bottom: Option<f64>,
43 placeholder_width: f64,
45 placeholder_height: f64,
46 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#[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 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 let _ = (&effective_offset_top, &on_change, &last_affixed);
99
100 let placeholder_id = use_signal(|| format!("adui-affix-{}", rand_id()));
102 let fixed_id = use_signal(|| format!("adui-affix-fixed-{}", rand_id()));
103
104 #[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 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 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 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 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; }
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 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 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 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 let style_attr = style.unwrap_or_default();
216
217 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 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 if state.affixed {
258 div {
259 style: "{placeholder_style}",
260 "aria-hidden": "true",
261 }
262 }
263
264 div {
266 id: "{fixed_id_val}",
267 class: "{fixed_class}",
268 style: "{fixed_style}",
269 {children}
270 }
271 }
272 }
273}
274
275fn rand_id() -> u32 {
277 #[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; 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 let _ = id1;
365 assert!(true);
366 }
367
368 #[test]
369 fn rand_id_generates_different_values() {
370 let id1 = rand_id();
372 let id2 = rand_id();
373 let _ = (id1, id2);
377 assert!(true);
378 }
379
380 #[test]
381 fn affix_props_optional_fields() {
382 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 assert!(true);
391 }
392
393 #[test]
394 fn affix_props_with_values() {
395 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}