1use crate::{
7 core::{
8 assets,
9 attrs::{component_attributes, GridData},
10 measure::grid_columns,
11 },
12 models::grid::{CellRange, CellRef},
13 prelude::*,
14 theme::{use_direction, use_stylesheets, Direction},
15};
16use std::rc::Rc;
17const BEM: Bem = Bem::new("fc-grid");
18
19const PAGE_ROWS: usize = 10;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct GridColumn {
25 pub label: String,
27 pub numeric: bool,
29}
30impl GridColumn {
31 #[must_use]
33 pub fn text(label: impl Into<String>) -> Self {
34 Self {
35 label: label.into(),
36 numeric: false,
37 }
38 }
39 #[must_use]
41 pub fn numeric(label: impl Into<String>) -> Self {
42 Self {
43 label: label.into(),
44 numeric: true,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct GridCell {
52 pub value: String,
54 pub dirty: bool,
56 pub invalid: bool,
58 pub readonly: bool,
60}
61impl GridCell {
62 #[must_use]
64 pub fn new(value: impl Into<String>) -> Self {
65 Self {
66 value: value.into(),
67 ..Self::default()
68 }
69 }
70}
71impl<T: Into<String>> From<T> for GridCell {
72 fn from(value: T) -> Self {
73 Self::new(value)
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct GridEdit {
80 pub cell: CellRef,
82 pub value: String,
84}
85
86#[component]
90pub fn DataGrid(
91 label: AccessibleName,
92 columns: Vec<GridColumn>,
93 rows: ReadSignal<Vec<Vec<GridCell>>>,
95 #[props(default = true)]
97 row_headers: bool,
98 #[props(default)]
101 total_rows: Option<usize>,
102 #[props(default)]
104 first_row: usize,
105 #[props(default = CellRef::new(0, 0))]
107 active: CellRef,
108 #[props(default)]
110 anchor: Option<CellRef>,
111 #[props(default)]
113 editing: Option<CellRef>,
114 #[props(default)] onedit: Option<EventHandler<GridEdit>>,
115 #[props(default)] onselect: Option<EventHandler<CellRange>>,
116 #[props(default)] class: Option<String>,
117 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
118) -> Element {
119 let width = columns.len();
120 let height = rows.read().len();
121 let clamp = move |cell: CellRef| {
124 let sheet = rows.peek();
125 let row = cell.row.min(sheet.len().saturating_sub(1));
126 let cells = sheet.get(row).map_or(0, Vec::len).min(width);
127 CellRef::new(row, cell.col.min(cells.saturating_sub(1)))
128 };
129 let cell_at = move |cell: CellRef| {
130 rows.peek()
131 .get(cell.row)
132 .and_then(|r| r.get(cell.col))
133 .cloned()
134 };
135 let mut current = use_signal(|| clamp(active));
136 let mut origin = use_signal(|| clamp(anchor.unwrap_or(active)));
137 let mut edit = use_signal(|| {
139 editing
140 .map(clamp)
141 .filter(|at| cell_at(*at).is_some_and(|c| !c.readonly))
142 });
143 let mut draft = use_signal(String::new);
144 let mut claim = use_signal(|| false);
147 let mut nodes = use_signal(Vec::<(CellRef, Rc<MountedData>)>::new);
148 let direction = use_direction();
149 let range = CellRange::from_corners(origin(), current());
150
151 let focus = move |cell: CellRef| {
152 let node = nodes
153 .peek()
154 .iter()
155 .find(|(at, _)| *at == cell)
156 .map(|(_, m)| m.clone());
157 if let Some(node) = node {
158 spawn(async move {
159 let _ = node.set_focus(true).await;
160 });
161 }
162 };
163 let mut go = move |cell: CellRef, extend: bool| {
164 let cell = clamp(cell);
165 if !extend {
166 origin.set(cell);
167 }
168 current.set(cell);
169 if let Some(onselect) = onselect {
170 onselect.call(CellRange::from_corners(origin(), cell));
171 }
172 focus(cell);
173 };
174 let mut begin = move |cell: CellRef| {
175 if let Some(found) = cell_at(cell).filter(|c| !c.readonly) {
176 draft.set(found.value);
177 claim.set(true);
178 edit.set(Some(cell));
179 }
180 };
181 use_hook(|| {
183 if let Some(found) = edit.peek().and_then(cell_at) {
184 draft.set(found.value);
185 }
186 });
187 let mut seen_first = use_signal(|| first_row);
190 if *seen_first.peek() != first_row {
191 seen_first.set(first_row);
192 edit.set(None);
193 }
194 let mut finish = move |commit: bool, refocus: bool| {
197 let Some(cell) = edit() else { return };
198 edit.set(None);
199 if commit {
200 if let Some(onedit) = onedit {
201 onedit.call(GridEdit {
202 cell,
203 value: draft(),
204 });
205 }
206 }
207 if refocus {
208 focus(cell);
209 }
210 };
211
212 let mut attributes = component_attributes(
213 attributes,
214 ClassList::new(BEM.block()).extend(class.as_deref()),
215 &[],
216 );
217 attributes.push(grid_columns(row_headers, width));
218 let total = total_rows.unwrap_or(height);
219 attributes.push(GridData::RowCount.attribute(&total));
220 attributes.push(GridData::ColCount.attribute(&width));
221 let virtual_rows = total > height;
224 let ctrl = |event: &KeyboardEvent| event.modifiers().ctrl() || event.modifiers().meta();
225
226 rsx! {
227 if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_GRID_CSS } }
228 div { role: "grid", aria_label: "{label}", aria_multiselectable: "true",
229 aria_rowcount: if virtual_rows { Some((total + 1).to_string()) } else { None },
230 onkeydown: move |event| {
231 if edit.peek().is_some() { return; }
232 let at = current();
233 let extend = event.modifiers().shift();
234 let (back, forward) = if direction == Direction::Rtl { (Key::ArrowRight, Key::ArrowLeft) } else { (Key::ArrowLeft, Key::ArrowRight) };
235 let key = event.key();
236 let target = if key == Key::ArrowUp {
237 Some(CellRef::new(at.row.saturating_sub(1), at.col))
238 } else if key == Key::ArrowDown {
239 Some(CellRef::new(at.row + 1, at.col))
240 } else if key == back {
241 Some(CellRef::new(at.row, at.col.saturating_sub(1)))
242 } else if key == forward {
243 Some(CellRef::new(at.row, at.col + 1))
244 } else if key == Key::Home {
245 Some(if ctrl(&event) { CellRef::new(0, 0) } else { CellRef::new(at.row, 0) })
246 } else if key == Key::End {
247 Some(if ctrl(&event) { CellRef::new(usize::MAX, usize::MAX) } else { CellRef::new(at.row, usize::MAX) })
248 } else if key == Key::PageUp {
249 Some(CellRef::new(at.row.saturating_sub(PAGE_ROWS), at.col))
250 } else if key == Key::PageDown {
251 Some(CellRef::new(at.row + PAGE_ROWS, at.col))
252 } else {
253 None
254 };
255 if let Some(target) = target {
256 event.prevent_default();
257 go(target, extend);
258 } else if key == Key::Enter || key == Key::F2 {
259 event.prevent_default();
260 begin(at);
261 } else if key == Key::Character(" ".into()) && ctrl(&event) {
262 event.prevent_default();
264 origin.set(CellRef::new(at.row, 0));
265 go(CellRef::new(at.row, usize::MAX), true);
266 }
267 },
268 ..attributes,
269 div { role: "row", aria_rowindex: if virtual_rows { Some("1") } else { None },
270 if row_headers { span { class: BEM.element("corner"), role: "columnheader" } }
271 for column in columns.iter() {
272 span { class: ClassList::new(&BEM.element("col-header")).with_if(column.numeric, BEM.element("col-header--numeric")).to_string(), role: "columnheader",
273 "{column.label}"
274 }
275 }
276 }
277 for (r, row) in rows.read().iter().enumerate() {
278 div { key: "{first_row + r}", role: "row",
279 aria_rowindex: if virtual_rows { Some((first_row + r + 2).to_string()) } else { None },
280 if row_headers { span { class: BEM.element("row-header"), role: "rowheader", "{first_row + r + 1}" } }
281 for (c, cell) in row.iter().enumerate() {
282 {
283 let at = CellRef::new(r, c);
284 let numeric = columns.get(c).is_some_and(|col| col.numeric);
285 let editing = edit() == Some(at);
286 let is_active = current() == at;
287 let name = format!("{} {}", columns.get(c).map_or("", |col| col.label.as_str()), first_row + r + 1);
288 let mut data = Spread::default();
289 if is_active { data.push(GridData::Active.attribute("true")); }
290 if cell.dirty { data.push(GridData::Dirty.attribute("true")); }
291 let classes = ClassList::new(&BEM.element("cell"))
292 .with_if(numeric, BEM.element("cell--numeric"))
293 .with_if(editing, BEM.element("cell--editing"))
294 .to_string();
295 rsx! {
296 span { key: "{c}", class: classes, role: "gridcell",
297 tabindex: if is_active && !editing { "0" } else { "-1" },
300 aria_selected: range.contains(at).to_string(),
301 aria_invalid: if cell.invalid { Some("true") } else { None },
302 aria_readonly: if cell.readonly { Some("true") } else { None },
303 onmounted: move |event: MountedEvent| {
304 let mut nodes = nodes.write();
305 nodes.retain(|(cell, _)| *cell != at);
306 nodes.push((at, event.data()));
307 },
308 onclick: move |event: MouseEvent| {
309 let extend = event.modifiers().shift() || event.modifiers().ctrl() || event.modifiers().meta();
310 go(at, extend);
311 },
312 ondoubleclick: move |_| begin(at),
313 ..data,
314 if editing {
315 input { class: BEM.element("input"), r#type: "text", aria_label: "{name}", value: "{draft}",
316 onmounted: move |event: MountedEvent| async move {
317 if claim() {
318 let _ = event.data().set_focus(true).await;
319 }
320 },
321 oninput: move |event| draft.set(event.value()),
322 onclick: move |event: MouseEvent| event.stop_propagation(),
325 ondoubleclick: move |event: MouseEvent| event.stop_propagation(),
326 onblur: move |_| finish(true, false),
327 onkeydown: move |event: KeyboardEvent| {
328 event.stop_propagation();
331 if event.key() == Key::Enter {
332 event.prevent_default();
333 finish(true, true);
334 } else if event.key() == Key::Escape {
335 event.prevent_default();
338 finish(false, true);
339 }
340 },
341 }
342 } else {
343 "{cell.value}"
344 }
345 }
346 }
347 }
348 }
349 }
350 }
351 }
352 }
353}