Skip to main content

fc_dioxus/components/
grid.rs

1//! A spreadsheet-like data grid from grid.css: sticky headers, one tab stop, rectangular
2//! selection, and in-cell editing, on upstream's `fc-grid` cell and range model.
3//!
4//! It is the UI element, not a spreadsheet: no formulas. Cells are controlled, so an
5//! edit is reported through `onedit` and shows once the caller stores it.
6use 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
19/// A rows-per-press step for Page Up and Page Down, as upstream's binding uses.
20const PAGE_ROWS: usize = 10;
21
22/// One column's header.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct GridColumn {
25    /// The header text, which also names the cell editor.
26    pub label: String,
27    /// Aligns the column to the end with tabular figures.
28    pub numeric: bool,
29}
30impl GridColumn {
31    /// A text column.
32    #[must_use]
33    pub fn text(label: impl Into<String>) -> Self {
34        Self {
35            label: label.into(),
36            numeric: false,
37        }
38    }
39    /// A numeric column.
40    #[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/// One cell's value and state.
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct GridCell {
52    /// The displayed value.
53    pub value: String,
54    /// Edited and not yet saved: a corner tick.
55    pub dirty: bool,
56    /// Failed validation.
57    pub invalid: bool,
58    /// Cannot be edited.
59    pub readonly: bool,
60}
61impl GridCell {
62    /// A plain cell.
63    #[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/// An edit the user committed.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct GridEdit {
80    /// The cell, zero-based within the rendered rows.
81    pub cell: CellRef,
82    /// The new value.
83    pub value: String,
84}
85
86/// The data grid. Arrows move, Shift extends the selection, Home and End go to the row
87/// ends (with Ctrl, the grid's corners), Page Up and Page Down move ten rows, Ctrl+Space
88/// selects the row, and Enter or F2 edits. Tab leaves the grid.
89#[component]
90pub fn DataGrid(
91    label: AccessibleName,
92    columns: Vec<GridColumn>,
93    /// A signal, so a large sheet is shared rather than copied into each render.
94    rows: ReadSignal<Vec<Vec<GridCell>>>,
95    /// Numbers each row in a sticky header column.
96    #[props(default = true)]
97    row_headers: bool,
98    /// The true row count when only a window of rows is rendered, so a screen reader
99    /// is told the real size.
100    #[props(default)]
101    total_rows: Option<usize>,
102    /// The zero-based index of the first rendered row within `total_rows`.
103    #[props(default)]
104    first_row: usize,
105    /// The cell active on first render.
106    #[props(default = CellRef::new(0, 0))]
107    active: CellRef,
108    /// Where the first render's selection starts; the active cell when omitted.
109    #[props(default)]
110    anchor: Option<CellRef>,
111    /// A cell that starts in edit mode, such as the first cell of a new row.
112    #[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    // Within the rendered rows, and within the row's own cells: a row shorter than the
122    // header has no cell to hold the tab stop past its end.
123    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    // A read-only cell never opens an editor, however the edit was asked for.
138    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    // Only an edit the user starts takes focus; one present on first render must not
145    // pull focus from wherever the page put it.
146    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    // A cell that starts in edit mode starts from its value.
182    use_hook(|| {
183        if let Some(found) = edit.peek().and_then(cell_at) {
184            draft.set(found.value);
185        }
186    });
187    // A window that moves under an open editor would put it on a different row, and
188    // committing would overwrite that row with the old draft. The edit is dropped.
189    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    // `refocus` is false when focus left the editor on its own: it went somewhere the
195    // user chose, and taking it back would be theft.
196    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    // Declared only when rows are missing from the DOM: then the count is the only way
222    // to learn that row 40 is not the end. It counts the header row.
223    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                    // Ctrl+Space selects the row; a bare space is left to scroll.
263                    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                                    // -1, not absent, while editing: focus returns here before the editor
298                                    // unmounts, and the input is then the grid's only tab stop.
299                                    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                                            // The cell's handlers would move focus to the cell and blur
323                                            // (so commit) the editor under the pointer.
324                                            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                                                // Arrows stay with the text: the grid's keys would
329                                                // trap the user in the editor or move under them.
330                                                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                                                    // A half-typed value is discarded, as in
336                                                    // every spreadsheet.
337                                                    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}