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(¶ms.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}