1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
//! A spreadsheet-like data grid from grid.css: sticky headers, one tab stop, rectangular
//! selection, and in-cell editing, on upstream's `fc-grid` cell and range model.
//!
//! It is the UI element, not a spreadsheet: no formulas. Cells are controlled, so an
//! edit is reported through `onedit` and shows once the caller stores it.
use crate::{
core::{
assets,
attrs::{component_attributes, GridData},
measure::grid_columns,
},
models::grid::{CellRange, CellRef},
prelude::*,
theme::{use_direction, use_stylesheets, Direction},
};
use std::rc::Rc;
const BEM: Bem = Bem::new("fc-grid");
/// A rows-per-press step for Page Up and Page Down, as upstream's binding uses.
const PAGE_ROWS: usize = 10;
/// One column's header.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridColumn {
/// The header text, which also names the cell editor.
pub label: String,
/// Aligns the column to the end with tabular figures.
pub numeric: bool,
}
impl GridColumn {
/// A text column.
#[must_use]
pub fn text(label: impl Into<String>) -> Self {
Self {
label: label.into(),
numeric: false,
}
}
/// A numeric column.
#[must_use]
pub fn numeric(label: impl Into<String>) -> Self {
Self {
label: label.into(),
numeric: true,
}
}
}
/// One cell's value and state.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GridCell {
/// The displayed value.
pub value: String,
/// Edited and not yet saved: a corner tick.
pub dirty: bool,
/// Failed validation.
pub invalid: bool,
/// Cannot be edited.
pub readonly: bool,
}
impl GridCell {
/// A plain cell.
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
..Self::default()
}
}
}
impl<T: Into<String>> From<T> for GridCell {
fn from(value: T) -> Self {
Self::new(value)
}
}
/// An edit the user committed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GridEdit {
/// The cell, zero-based within the rendered rows.
pub cell: CellRef,
/// The new value.
pub value: String,
}
/// The data grid. Arrows move, Shift extends the selection, Home and End go to the row
/// ends (with Ctrl, the grid's corners), Page Up and Page Down move ten rows, Ctrl+Space
/// selects the row, and Enter or F2 edits. Tab leaves the grid.
#[component]
pub fn DataGrid(
label: AccessibleName,
columns: Vec<GridColumn>,
/// A signal, so a large sheet is shared rather than copied into each render.
rows: ReadSignal<Vec<Vec<GridCell>>>,
/// Numbers each row in a sticky header column.
#[props(default = true)]
row_headers: bool,
/// The true row count when only a window of rows is rendered, so a screen reader
/// is told the real size.
#[props(default)]
total_rows: Option<usize>,
/// The zero-based index of the first rendered row within `total_rows`.
#[props(default)]
first_row: usize,
/// The cell active on first render.
#[props(default = CellRef::new(0, 0))]
active: CellRef,
/// Where the first render's selection starts; the active cell when omitted.
#[props(default)]
anchor: Option<CellRef>,
/// A cell that starts in edit mode, such as the first cell of a new row.
#[props(default)]
editing: Option<CellRef>,
#[props(default)] onedit: Option<EventHandler<GridEdit>>,
#[props(default)] onselect: Option<EventHandler<CellRange>>,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let width = columns.len();
let height = rows.read().len();
// Within the rendered rows, and within the row's own cells: a row shorter than the
// header has no cell to hold the tab stop past its end.
let clamp = move |cell: CellRef| {
let sheet = rows.peek();
let row = cell.row.min(sheet.len().saturating_sub(1));
let cells = sheet.get(row).map_or(0, Vec::len).min(width);
CellRef::new(row, cell.col.min(cells.saturating_sub(1)))
};
let cell_at = move |cell: CellRef| {
rows.peek()
.get(cell.row)
.and_then(|r| r.get(cell.col))
.cloned()
};
let mut current = use_signal(|| clamp(active));
let mut origin = use_signal(|| clamp(anchor.unwrap_or(active)));
// A read-only cell never opens an editor, however the edit was asked for.
let mut edit = use_signal(|| {
editing
.map(clamp)
.filter(|at| cell_at(*at).is_some_and(|c| !c.readonly))
});
let mut draft = use_signal(String::new);
// Only an edit the user starts takes focus; one present on first render must not
// pull focus from wherever the page put it.
let mut claim = use_signal(|| false);
let mut nodes = use_signal(Vec::<(CellRef, Rc<MountedData>)>::new);
let direction = use_direction();
let range = CellRange::from_corners(origin(), current());
let focus = move |cell: CellRef| {
let node = nodes
.peek()
.iter()
.find(|(at, _)| *at == cell)
.map(|(_, m)| m.clone());
if let Some(node) = node {
spawn(async move {
let _ = node.set_focus(true).await;
});
}
};
let mut go = move |cell: CellRef, extend: bool| {
let cell = clamp(cell);
if !extend {
origin.set(cell);
}
current.set(cell);
if let Some(onselect) = onselect {
onselect.call(CellRange::from_corners(origin(), cell));
}
focus(cell);
};
let mut begin = move |cell: CellRef| {
if let Some(found) = cell_at(cell).filter(|c| !c.readonly) {
draft.set(found.value);
claim.set(true);
edit.set(Some(cell));
}
};
// A cell that starts in edit mode starts from its value.
use_hook(|| {
if let Some(found) = edit.peek().and_then(cell_at) {
draft.set(found.value);
}
});
// A window that moves under an open editor would put it on a different row, and
// committing would overwrite that row with the old draft. The edit is dropped.
let mut seen_first = use_signal(|| first_row);
if *seen_first.peek() != first_row {
seen_first.set(first_row);
edit.set(None);
}
// `refocus` is false when focus left the editor on its own: it went somewhere the
// user chose, and taking it back would be theft.
let mut finish = move |commit: bool, refocus: bool| {
let Some(cell) = edit() else { return };
edit.set(None);
if commit {
if let Some(onedit) = onedit {
onedit.call(GridEdit {
cell,
value: draft(),
});
}
}
if refocus {
focus(cell);
}
};
let mut attributes = component_attributes(
attributes,
ClassList::new(BEM.block()).extend(class.as_deref()),
&[],
);
attributes.push(grid_columns(row_headers, width));
let total = total_rows.unwrap_or(height);
attributes.push(GridData::RowCount.attribute(&total));
attributes.push(GridData::ColCount.attribute(&width));
// Declared only when rows are missing from the DOM: then the count is the only way
// to learn that row 40 is not the end. It counts the header row.
let virtual_rows = total > height;
let ctrl = |event: &KeyboardEvent| event.modifiers().ctrl() || event.modifiers().meta();
rsx! {
if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_GRID_CSS } }
div { role: "grid", aria_label: "{label}", aria_multiselectable: "true",
aria_rowcount: if virtual_rows { Some((total + 1).to_string()) } else { None },
onkeydown: move |event| {
if edit.peek().is_some() { return; }
let at = current();
let extend = event.modifiers().shift();
let (back, forward) = if direction == Direction::Rtl { (Key::ArrowRight, Key::ArrowLeft) } else { (Key::ArrowLeft, Key::ArrowRight) };
let key = event.key();
let target = if key == Key::ArrowUp {
Some(CellRef::new(at.row.saturating_sub(1), at.col))
} else if key == Key::ArrowDown {
Some(CellRef::new(at.row + 1, at.col))
} else if key == back {
Some(CellRef::new(at.row, at.col.saturating_sub(1)))
} else if key == forward {
Some(CellRef::new(at.row, at.col + 1))
} else if key == Key::Home {
Some(if ctrl(&event) { CellRef::new(0, 0) } else { CellRef::new(at.row, 0) })
} else if key == Key::End {
Some(if ctrl(&event) { CellRef::new(usize::MAX, usize::MAX) } else { CellRef::new(at.row, usize::MAX) })
} else if key == Key::PageUp {
Some(CellRef::new(at.row.saturating_sub(PAGE_ROWS), at.col))
} else if key == Key::PageDown {
Some(CellRef::new(at.row + PAGE_ROWS, at.col))
} else {
None
};
if let Some(target) = target {
event.prevent_default();
go(target, extend);
} else if key == Key::Enter || key == Key::F2 {
event.prevent_default();
begin(at);
} else if key == Key::Character(" ".into()) && ctrl(&event) {
// Ctrl+Space selects the row; a bare space is left to scroll.
event.prevent_default();
origin.set(CellRef::new(at.row, 0));
go(CellRef::new(at.row, usize::MAX), true);
}
},
..attributes,
div { role: "row", aria_rowindex: if virtual_rows { Some("1") } else { None },
if row_headers { span { class: BEM.element("corner"), role: "columnheader" } }
for column in columns.iter() {
span { class: ClassList::new(&BEM.element("col-header")).with_if(column.numeric, BEM.element("col-header--numeric")).to_string(), role: "columnheader",
"{column.label}"
}
}
}
for (r, row) in rows.read().iter().enumerate() {
div { key: "{first_row + r}", role: "row",
aria_rowindex: if virtual_rows { Some((first_row + r + 2).to_string()) } else { None },
if row_headers { span { class: BEM.element("row-header"), role: "rowheader", "{first_row + r + 1}" } }
for (c, cell) in row.iter().enumerate() {
{
let at = CellRef::new(r, c);
let numeric = columns.get(c).is_some_and(|col| col.numeric);
let editing = edit() == Some(at);
let is_active = current() == at;
let name = format!("{} {}", columns.get(c).map_or("", |col| col.label.as_str()), first_row + r + 1);
let mut data = Spread::default();
if is_active { data.push(GridData::Active.attribute("true")); }
if cell.dirty { data.push(GridData::Dirty.attribute("true")); }
let classes = ClassList::new(&BEM.element("cell"))
.with_if(numeric, BEM.element("cell--numeric"))
.with_if(editing, BEM.element("cell--editing"))
.to_string();
rsx! {
span { key: "{c}", class: classes, role: "gridcell",
// -1, not absent, while editing: focus returns here before the editor
// unmounts, and the input is then the grid's only tab stop.
tabindex: if is_active && !editing { "0" } else { "-1" },
aria_selected: range.contains(at).to_string(),
aria_invalid: if cell.invalid { Some("true") } else { None },
aria_readonly: if cell.readonly { Some("true") } else { None },
onmounted: move |event: MountedEvent| {
let mut nodes = nodes.write();
nodes.retain(|(cell, _)| *cell != at);
nodes.push((at, event.data()));
},
onclick: move |event: MouseEvent| {
let extend = event.modifiers().shift() || event.modifiers().ctrl() || event.modifiers().meta();
go(at, extend);
},
ondoubleclick: move |_| begin(at),
..data,
if editing {
input { class: BEM.element("input"), r#type: "text", aria_label: "{name}", value: "{draft}",
onmounted: move |event: MountedEvent| async move {
if claim() {
let _ = event.data().set_focus(true).await;
}
},
oninput: move |event| draft.set(event.value()),
// The cell's handlers would move focus to the cell and blur
// (so commit) the editor under the pointer.
onclick: move |event: MouseEvent| event.stop_propagation(),
ondoubleclick: move |event: MouseEvent| event.stop_propagation(),
onblur: move |_| finish(true, false),
onkeydown: move |event: KeyboardEvent| {
// Arrows stay with the text: the grid's keys would
// trap the user in the editor or move under them.
event.stop_propagation();
if event.key() == Key::Enter {
event.prevent_default();
finish(true, true);
} else if event.key() == Key::Escape {
// A half-typed value is discarded, as in
// every spreadsheet.
event.prevent_default();
finish(false, true);
}
},
}
} else {
"{cell.value}"
}
}
}
}
}
}
}
}
}
}