Skip to main content

max / quasi

A table cell can hold a control, not only a string A cell was a String, so a table whose rows carry a button could not be described and had to become a list, losing the column headers that were the reason it was a table. That is what the MNW server's SSH-keys tab did, and why it read worse than the Askama original. Counted across the server's templates before adding the member: 30 table rows carry a control. 25 put it alone in the last cell, which a row-level actions list would have covered. Five put it beside a value: a position with reorder arrows, a slug with "Set slug", a use count that is itself the button. Neither a row-level list nor an actions column can say those, since both render as a cell of their own. So the acts sit on the cell, where the control actually is. Cells::new takes anything that becomes a Cell, so a row of plain text is unchanged and no existing caller moves. A table row carries its activate on the row itself, unlike a list row, which hangs it on the primary text. A click on a cell's button therefore bubbled to the row and htmx fired both. Acts now carry data-act and a row with controls in it filters its own trigger against them, rather than the button swallowing events everything above it may be listening for.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 22:55 UTC
Signed with PGP, not checked
Commit: b4e3e217e0ea85107857ec6f686036b9917b1f14
Parent: f5cf080
4 files changed, +210 insertions, -14 deletions
@@ -110,8 +110,8 @@
110 110 pub use crate::response::{Message, Outcome, Response};
111 111 pub use crate::router::{Handler, Router};
112 112 pub use crate::screen::{
113 - Act, Action, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, Prose, RegionKind,
114 - Rest, Row, Screen, Slot, Tag,
113 + Act, Action, Cell, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, Prose,
114 + RegionKind, Rest, Row, Screen, Slot, Tag,
115 115 };
116 116
117 117 #[cfg(test)]
@@ -1287,6 +1287,76 @@
1287 1287 }
1288 1288 }
1289 1289
1290 + /// One cell of a table row.
1291 + ///
1292 + /// `022f0c59`, decided 2026-08-10. A cell was a `String` until then, so a table
1293 + /// whose rows carry a control could not be described at all and had to become a
1294 + /// [`Node::List`], losing its column headers — which is what the MNW server's
1295 + /// SSH-keys tab did, and why it read worse than the Askama original it replaced.
1296 + ///
1297 + /// # Why the acts sit on the cell and not on the row
1298 + ///
1299 + /// Counted across MNW's templates, 30 table rows carry a control. 25 put it
1300 + /// alone in the last cell, which a row-level `actions` list would have covered.
1301 + /// The other five put it *beside a value*: `project_content`'s position cell is
1302 + /// the number plus two reorder arrows, `project_synckit`'s slug cell is the slug
1303 + /// plus "Set slug", `promo_codes_list`'s use count is a number that is itself
1304 + /// the button opening the redemptions. A row-level list renders as an appended
1305 + /// cell and cannot say any of those, and neither can an actions *column*, since
1306 + /// a column is a column. The control belongs where it actually is.
1307 + ///
1308 + /// An empty [`value`](Self::value) with acts is the common case, and
1309 + /// [`Cell::acts`] is the constructor for it. That is the trailing actions cell
1310 + /// the markup already writes an empty `<th>` for.
1311 + ///
1312 + /// A `Vec<Act>` and not a node: the 2026-08-08 ruling that a row holds no nodes
1313 + /// holds here for the same reason. Acts carry their own tone, state and
1314 + /// confirmation, and that is the whole of what these cells hold.
1315 + #[derive(Debug, Clone, PartialEq, Eq, Default)]
1316 + pub struct Cell {
1317 + /// The text, escaped by the renderer. Empty on an actions-only cell.
1318 + pub value: String,
1319 + /// The controls in this cell, in order.
1320 + pub actions: Vec<Act>,
1321 + }
1322 +
1323 + impl Cell {
1324 + /// A cell holding text.
1325 + pub fn new(value: impl Into<String>) -> Self {
1326 + Self {
1327 + value: value.into(),
1328 + actions: Vec::new(),
1329 + }
1330 + }
1331 +
1332 + /// A cell holding controls and no text.
1333 + pub fn acts(actions: impl IntoIterator<Item = Act>) -> Self {
1334 + Self {
1335 + value: String::new(),
1336 + actions: actions.into_iter().collect(),
1337 + }
1338 + }
1339 +
1340 + /// A control in this cell, chaining.
1341 + #[must_use]
1342 + pub fn act(mut self, act: Act) -> Self {
1343 + self.actions.push(act);
1344 + self
1345 + }
1346 + }
1347 +
1348 + impl From<String> for Cell {
1349 + fn from(value: String) -> Self {
1350 + Self::new(value)
1351 + }
1352 + }
1353 +
1354 + impl From<&str> for Cell {
1355 + fn from(value: &str) -> Self {
1356 + Self::new(value)
1357 + }
1358 + }
1359 +
1290 1360 /// One row of a table.
1291 1361 ///
1292 1362 /// Cells are positional against the table's columns, and the table is the only
@@ -1296,7 +1366,7 @@
1296 1366 #[derive(Debug, Clone, PartialEq, Eq, Default)]
1297 1367 pub struct Cells {
1298 1368 /// One entry per column, in the table's column order.
1299 - pub values: Vec<String>,
1369 + pub values: Vec<Cell>,
1300 1370 /// The route that opens this row.
1301 1371 pub activate: Option<Action>,
1302 1372 /// Whether this is the row currently being shown elsewhere.
@@ -1316,8 +1386,12 @@
1316 1386 }
1317 1387
1318 1388 impl Cells {
1319 - /// A row of values in column order.
1320 - pub fn new(values: impl IntoIterator<Item = impl Into<String>>) -> Self {
1389 + /// A row of cells in column order.
1390 + ///
1391 + /// Takes anything that becomes a [`Cell`], so a row of plain text is still
1392 + /// `Cells::new(["kick.wav", "2.1 MB"])` and a row with a control mixes the
1393 + /// two: `Cells::new([Cell::new(name), Cell::acts([remove])])`.
1394 + pub fn new(values: impl IntoIterator<Item = impl Into<Cell>>) -> Self {
1321 1395 Self {
1322 1396 values: values.into_iter().map(Into::into).collect(),
1323 1397 activate: None,
@@ -26,7 +26,10 @@
26 26 use makeover_webview::Emit;
27 27 use makeover_webview::figure::figure_html;
28 28 use makeover_webview::form::{Filling, Markup, Value, escape, field_html};
29 - use makeover_webview::list::{Cell, cells_html};
29 + // `Cell` is a name both crates use: makeover's is the emitted table cell, ours
30 + // is the described one. Aliased rather than qualified at the call site, so the
31 + // two never read as the same type.
32 + use makeover_webview::list::{Cell as Emitted, cells_html};
30 33 use makeover_webview::meter::meter_html;
31 34 use makeover_webview::placeholder::placeholder_html;
32 35 use quasi_router::screen::{Act, Cells, Destination, Field, Node, Prose, Row, Slot, Tag};
@@ -221,6 +224,16 @@
221 224 /// catches it whichever of input, select or textarea the field turned out to
222 225 /// be, and the value is found back rather than assumed.
223 226 ChangeInside,
227 + /// The user clicking the element, but not a control inside it.
228 + ///
229 + /// `022f0c59`. A table row carries its `activate` on the row itself, unlike
230 + /// a list row, which hangs it on the primary text and so has never had this
231 + /// problem. Once a cell can hold a control, a click on that control bubbles
232 + /// to the row and htmx fires both: pressing Remove would delete the key and
233 + /// open it. The filter is on the row rather than a `stopPropagation` on the
234 + /// button because the row is the element making the wrong assumption, and a
235 + /// button that swallows events breaks anything else listening above it.
236 + ClickBeside,
224 237 }
225 238
226 239 /// The transport attributes for one action.
@@ -297,6 +310,13 @@
297 310 // markup says what it does rather than resting on a default holding.
298 311 match fires {
299 312 Fires::Click => {}
313 + // `data-act` and not the class, so the filter does not depend on
314 + // `Emit::class_prefix` and does not break when a host sets one. Same
315 + // reasoning as `data-menu` on a row's menu.
316 + Fires::ClickBeside => out.push_str(concat!(
317 + " hx-trigger=\"click[!event.target.closest(",
318 + "&#39;[data-act]&#39;)]\""
319 + )),
300 320 Fires::Change => out.push_str(" hx-trigger=\"change\""),
301 321 Fires::ChangeInside => {
302 322 out.push_str(" hx-trigger=\"change\"");
@@ -363,6 +383,11 @@
363 383 let (open, close) = control_tag(&act.action);
364 384 out.push_str(open);
365 385 class_attr(&classes, opts, out);
386 + // Named rather than found by class, so anything binding to "this is a
387 + // control" survives a host setting `Emit::class_prefix`. `Fires::ClickBeside`
388 + // is the first reader; a table row uses it to tell its own click apart from
389 + // a press on a button sitting inside one of its cells.
390 + out.push_str(" data-act");
366 391
367 392 match act.state {
368 393 Some(layout::State::Disabled) => {
@@ -576,23 +601,49 @@
576 601 out.push_str(" aria-current=\"true\"");
577 602 }
578 603 if let Some(action) = &cells.activate {
579 - action_attrs(action, Fires::Click, None, morphs, out);
604 + let fires = if cells.values.iter().any(|cell| !cell.actions.is_empty()) {
605 + Fires::ClickBeside
606 + } else {
607 + Fires::Click
608 + };
609 + action_attrs(action, fires, None, morphs, out);
580 610 }
581 611 out.push('>');
582 612
583 613 // The cell contents are escaped here and handed over as Markup, which is
584 614 // makeover-webview's contract: it owns the structure, the caller owns what
585 - // goes in. Ours is always text from a description, so it is always escaped,
586 - // and `cells_html` is what knows the column classes and the narrowing.
587 - let escaped: Vec<String> = cells.values.iter().map(|value| escape(value)).collect();
615 + // goes in. Ours is text from a description plus, since `022f0c59`, whatever
616 + // controls the cell carries, and `cells_html` is what knows the column
617 + // classes and the narrowing.
618 + let filled: Vec<String> = cells
619 + .values
620 + .iter()
621 + .map(|cell| {
622 + let mut inner = escape(&cell.value);
623 + if !cell.actions.is_empty() {
624 + // Deliberately not `row-actions`: makeover hides that one until
625 + // the row is hovered or focused, which is defensible on a dense
626 + // list and wrong for a table whose last column exists to hold
627 + // the button. A cell's controls are always shown.
628 + inner.push_str("<span");
629 + class_attr(&["cell-actions"], opts, &mut inner);
630 + inner.push('>');
631 + for act in &cell.actions {
632 + act_html(act, morphs, opts, &mut inner);
633 + }
634 + inner.push_str("</span>");
635 + }
636 + inner
637 + })
638 + .collect();
588 639 let borrowed: Vec<layout::Column<'_>> = columns
589 640 .iter()
590 641 .map(quasi_router::screen::Column::as_layout)
591 642 .collect();
592 - let cells: Vec<Cell<'_>> = borrowed
643 + let cells: Vec<Emitted<'_>> = borrowed
593 644 .iter()
594 - .zip(escaped.iter())
595 - .map(|(column, value)| Cell::new(column.name, Markup(value)))
645 + .zip(filled.iter())
646 + .map(|(column, value)| Emitted::new(column.name, Markup(value)))
596 647 .collect();
597 648 out.push_str(&cells_html(&borrowed, &cells, opts));
598 649
@@ -10,7 +10,7 @@
10 10 use makeover_layout as layout;
11 11 use quasi_http::Render;
12 12 use quasi_router::screen::{
13 - Act, Cells, Choice, Column, Field, Figure, Meter, Prose, Rest, Row, Tag,
13 + Act, Cell, Cells, Choice, Column, Field, Figure, Meter, Prose, Rest, Row, Tag,
14 14 };
15 15 use quasi_router::{Action, Node, RegionKind, Screen, Slot};
16 16
@@ -678,6 +678,77 @@
678 678 assert!(html.contains("&lt;img"));
679 679 }
680 680
681 + #[test]
682 + fn a_table_row_carries_its_controls_in_the_cell_they_belong_to() {
683 + // `022f0c59`. The SSH-keys table: three values and a Remove, which had to be
684 + // described as a list until a cell could hold the button, losing the column
685 + // headers that were the reason it was a table.
686 + let html = fragment(&Node::Table {
687 + columns: vec![
688 + Column::new("Fingerprint").width(layout::Width::Fill),
689 + Column::new("Label").width(layout::Width::Content),
690 + Column::new("").width(layout::Width::Content),
691 + ],
692 + rows: vec![Cells::new([
693 + Cell::new("SHA256:abc"),
694 + Cell::new("fw13"),
695 + Cell::acts([
696 + Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger),
697 + ]),
698 + ])],
699 + });
700 +
701 + assert!(html.contains("role=\"table\""));
702 + assert!(html.contains("SHA256:abc"));
703 + assert!(html.contains("hx-post=\"/keys/7/delete\""));
704 + assert!(html.contains("cell-actions"));
705 +
706 + // Not `row-actions`, which makeover holds at `opacity: 0` until the row is
707 + // hovered. A table's actions column exists to show the button.
708 + assert!(!html.contains("row-actions"));
709 + }
710 +
711 + #[test]
712 + fn a_control_beside_a_value_does_not_also_open_the_row() {
713 + // The five of MNW's thirty action-bearing rows that put a control next to a
714 + // value rather than alone in the last cell: a position with reorder arrows,
715 + // a slug with "Set slug", a use count that is itself the button. The row is
716 + // openable too, and a click on the button bubbles to it.
717 + let html = fragment(&Node::Table {
718 + columns: vec![Column::new("Slug").width(layout::Width::Fill)],
719 + rows: vec![
720 + Cells::new([Cell::new("my-app").act(Act::new(
721 + "Set slug",
722 + Action::post("/apps/3/slug"),
723 + ))])
724 + .activate(Action::get("/apps/3")),
725 + ],
726 + });
727 +
728 + assert!(html.contains("hx-get=\"/apps/3\""));
729 + assert!(html.contains("hx-post=\"/apps/3/slug\""));
730 + assert!(html.contains("data-act"));
731 + assert!(html.contains("closest("));
732 +
733 + // A row with no controls in it keeps htmx's bare default, so the filter is
734 + // paid for only where it is needed.
735 + let plain = fragment(&Node::Table {
736 + columns: vec![Column::new("Slug").width(layout::Width::Fill)],
737 + rows: vec![Cells::new(["my-app"]).activate(Action::get("/apps/3"))],
738 + });
739 + assert!(!plain.contains("hx-trigger"));
740 + }
741 +
742 + #[test]
743 + fn a_cell_of_plain_text_is_still_a_string() {
744 + // The `From<&str>` that keeps every value-only table unchanged. Without it
745 + // the member would have cost every existing caller a rewrite for a feature
746 + // it does not use.
747 + let cells = Cells::new(["kick.wav", "2.1 MB"]);
748 + assert_eq!(cells.values, vec![Cell::new("kick.wav"), Cell::new("2.1 MB")]);
749 + assert!(cells.values.iter().all(|cell| cell.actions.is_empty()));
750 + }
751 +
681 752 #[test]
682 753 fn a_form_borrows_its_fields_rather_than_emitting_them_twice() {
683 754 let html = fragment(&Node::Form {