table_rs/yew/
table.rs

1use gloo_timers::callback::Timeout;
2use web_sys::UrlSearchParams;
3use web_sys::wasm_bindgen::JsValue;
4use yew::prelude::*;
5
6use crate::yew::body::TableBody;
7use crate::yew::controls::PaginationControls;
8use crate::yew::header::TableHeader;
9use crate::yew::types::SortOrder;
10use crate::yew::types::TableProps;
11
12/// A fully featured table component with pagination, sorting, and search support.
13///
14/// This component renders a complete `<table>` element, including headers (`<thead>`), body (`<tbody>`),
15/// and optional features such as client-side sorting, pagination, and search input.
16/// It is built using Yew and supports flexible styling and customization.
17///
18/// # Arguments
19/// * `props` - The properties passed to the component.
20///   - `data` - A `Vec<HashMap<&'static str, String>>` representing the table's row data.
21///   - `columns` - A `Vec<Column>` defining the structure and behavior of each column.
22///   - `page_size` - A `usize` defining how many rows to show per page.
23///   - `loading` - A `bool` indicating whether the table is in a loading state.
24///   - `classes` - A `TableClasses` struct for customizing class names of elements.
25///   - `styles` - A `HashMap<&'static str, &'static str>` for inline style overrides.
26///   - `paginate` - A `bool` controlling whether pagination controls are displayed.
27///   - `search` - A `bool` enabling a search input above the table.
28///   - `texts` - A `TableTexts` struct for customizing placeholder and fallback texts.
29///
30/// # Features
31/// - **Client-side search** with URL hydration via `?search=`
32/// - **Column sorting** (ascending/descending toggle)
33/// - **Pagination controls**
34/// - **Custom class and inline style support**
35/// - Displays a loading row or empty state message when appropriate
36///
37/// # Returns
38/// (Html): A complete, styled and interactive table component rendered in Yew.
39///
40/// # Examples
41/// ```rust
42/// use yew::prelude::*;
43/// use maplit::hashmap;
44/// use table_rs::yew::table::Table;
45/// use table_rs::yew::types::{Column, TableClasses, TableTexts};
46///
47/// #[function_component(App)]
48/// pub fn app() -> Html {
49///     let data = vec![
50///         hashmap! { "name" => "Ferris".into(), "email" => "ferris@opensass.org".into() },
51///         hashmap! { "name" => "Ferros".into(), "email" => "ferros@opensass.org".into() },
52///     ];
53///
54///     let columns = vec![
55///         Column { id: "name", header: "Name", sortable: true, ..Default::default() },
56///         Column { id: "email", header: "Email", sortable: false, ..Default::default() },
57///     ];
58///
59///     html! {
60///         <Table
61///             data={data}
62///             columns={columns}
63///             page_size={10}
64///             loading={false}
65///             paginate={true}
66///             search={true}
67///             classes={TableClasses::default()}
68///             texts={TableTexts::default()}
69///         />
70///     }
71/// }
72/// ```
73///
74/// # See Also
75/// - [MDN table Element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/table)
76#[function_component(Table)]
77pub fn table(props: &TableProps) -> Html {
78    let TableProps {
79        data,
80        columns,
81        page_size,
82        loading,
83        classes,
84        styles,
85        paginate,
86        search,
87        texts,
88    } = props;
89
90    let page = use_state(|| 0);
91    let sort_column = use_state(|| None::<&'static str>);
92    let sort_order = use_state(|| SortOrder::Asc);
93    let search_query = use_state(|| {
94        let window = web_sys::window().unwrap();
95        let search_params =
96            UrlSearchParams::new_with_str(&window.location().search().unwrap_or_default()).unwrap();
97        search_params.get("search").unwrap_or_default()
98    });
99
100    let debounced_search = use_state(|| None::<Timeout>);
101
102    let update_search_url = {
103        let search_query = search_query.clone();
104        Callback::from(move |query: String| {
105            let window = web_sys::window().unwrap();
106            let url = window.location().href().unwrap();
107            let url_obj = web_sys::Url::new(&url).unwrap();
108            let params = url_obj.search_params();
109            params.set("search", &query);
110            url_obj.set_search(&params.to_string().as_string().unwrap_or_default());
111            window
112                .history()
113                .unwrap()
114                .replace_state_with_url(&JsValue::NULL, "", Some(&url_obj.href()))
115                .unwrap();
116            search_query.set(query);
117        })
118    };
119
120    let on_search_change = {
121        let debounced_search = debounced_search.clone();
122        let update_search_url = update_search_url.clone();
123        Callback::from(move |e: InputEvent| {
124            let update_search_url = update_search_url.clone();
125            // TODO: Add debounce
126            // let debounced_search_ref = debounced_search.clone();
127            let input: web_sys::HtmlInputElement = e.target_unchecked_into();
128            let value = input.value();
129
130            // let prev_timeout = {
131            //     debounced_search_ref.take()
132            // };
133
134            // if let Some(prev) = prev_timeout {
135            //     prev.cancel();
136            // }
137
138            let timeout = Timeout::new(50, move || {
139                update_search_url.emit(value.clone());
140            });
141
142            debounced_search.set(Some(timeout));
143        })
144    };
145
146    let mut filtered_rows = data.clone();
147    if !search_query.is_empty() {
148        filtered_rows.retain(|row| {
149            columns.iter().any(|col| {
150                row.get(col.id)
151                    .map(|v| v.to_lowercase().contains(&search_query.to_lowercase()))
152                    .unwrap_or(false)
153            })
154        });
155    }
156
157    if let Some(col_id) = *sort_column {
158        if let Some(col) = columns.iter().find(|c| c.id == col_id) {
159            filtered_rows.sort_by(|a, b| {
160                let val = "".to_string();
161                let a_val = a.get(col.id).unwrap_or(&val);
162                let b_val = b.get(col.id).unwrap_or(&val);
163                match *sort_order {
164                    SortOrder::Asc => a_val.cmp(b_val),
165                    SortOrder::Desc => b_val.cmp(a_val),
166                }
167            });
168        }
169    }
170
171    let total_pages = (filtered_rows.len() as f64 / *page_size as f64).ceil() as usize;
172    let start = *page * page_size;
173    let end = ((*page + 1) * page_size).min(filtered_rows.len());
174    let page_rows = &filtered_rows[start..end];
175
176    let on_sort_column = {
177        let sort_column = sort_column.clone();
178        let sort_order = sort_order.clone();
179        Callback::from(move |id: &'static str| {
180            if Some(id) == *sort_column {
181                sort_order.set(match *sort_order {
182                    SortOrder::Asc => SortOrder::Desc,
183                    SortOrder::Desc => SortOrder::Asc,
184                });
185            } else {
186                sort_column.set(Some(id));
187                sort_order.set(SortOrder::Asc);
188            }
189        })
190    };
191
192    html! {
193        <div class={classes.container}>
194            { if *search {
195                    html! {
196                        <input
197                            class={classes.search_input}
198                            type="text"
199                            value={(*search_query).clone()}
200                            placeholder={texts.search_placeholder}
201                            aria-label="Search table"
202                            oninput={on_search_change}
203                        />
204                    }
205                } else {
206                    html! {}
207                } }
208            <table class={classes.table} style={*styles.get("table").unwrap_or(&"")} role="table">
209                <TableHeader
210                    columns={columns.clone()}
211                    {sort_column}
212                    {sort_order}
213                    {on_sort_column}
214                    classes={classes.clone()}
215                />
216                <TableBody
217                    columns={columns.clone()}
218                    rows={page_rows.to_vec()}
219                    loading={loading}
220                    classes={classes.clone()}
221                />
222            </table>
223            { if *paginate {
224                    html! {
225                        <PaginationControls {page} {total_pages} />
226                    }
227                } else {
228                    html! {}
229                } }
230        </div>
231    }
232}