Skip to main content

nightshade_api/web/
virtual_list.rs

1//! A windowed list that renders only the rows currently in view.
2
3use leptos::html;
4use leptos::prelude::*;
5use wasm_bindgen::JsCast;
6
7use crate::web::visible_range;
8
9/// A virtualized list that renders only the visible slice of `count` items,
10/// calling `render` with each item index. `item_height` (fixed row height),
11/// `height` (viewport height), and `overscan` (extra rows rendered off-screen)
12/// tune the windowing.
13#[component]
14pub fn VirtualList<F>(
15    #[prop(into)] count: Signal<usize>,
16    #[prop(default = 32.0)] item_height: f64,
17    #[prop(default = 360.0)] height: f64,
18    #[prop(default = 8)] overscan: usize,
19    render: F,
20) -> impl IntoView
21where
22    F: Fn(usize) -> AnyView + 'static,
23{
24    let scroll_top = RwSignal::new(0.0);
25    let viewport_height = RwSignal::new(height);
26    let render = StoredValue::new_local(render);
27    let wrap_ref = NodeRef::<html::Div>::new();
28
29    let on_scroll = move |event: web_sys::Event| {
30        if let Some(element) = event
31            .target()
32            .and_then(|target| target.dyn_into::<web_sys::HtmlElement>().ok())
33        {
34            scroll_top.set(element.scroll_top() as f64);
35            viewport_height.set(element.client_height() as f64);
36        }
37    };
38
39    Effect::new(move |_| {
40        if let Some(element) = wrap_ref.get() {
41            viewport_height.set(element.client_height() as f64);
42        }
43    });
44
45    view! {
46        <div
47            class="nightshade-virtual-list"
48            node_ref=wrap_ref
49            style=format!("height:{height}px")
50            on:scroll=on_scroll
51        >
52            {move || {
53                let total = count.get();
54                let (start, end) = visible_range(
55                    scroll_top.get(),
56                    viewport_height.get(),
57                    item_height,
58                    overscan,
59                    total,
60                );
61                let top_pad = start as f64 * item_height;
62                let bottom_pad = total.saturating_sub(end) as f64 * item_height;
63                let rows = (start..end)
64                    .map(|index| {
65                        let content = render.with_value(|render| render(index));
66                        view! {
67                            <div
68                                class="nightshade-virtual-row"
69                                style=format!("height:{item_height}px")
70                            >
71                                {content}
72                            </div>
73                        }
74                    })
75                    .collect_view();
76                view! {
77                    <div style=format!("height:{top_pad}px")></div>
78                    {rows}
79                    <div style=format!("height:{bottom_pad}px")></div>
80                }
81            }}
82        </div>
83    }
84}