Skip to main content

max / quasi

A cell's value can be a link
Author: Max Johnson <me@maxj.phd> · 2026-08-11 00:56 UTC
Signed with PGP, not checked
Commit: 4058403a4017ad212ad16b27e67e5bccab49381f
Parent: bfe40fe
4 files changed, +117 insertions, -9 deletions
M Cargo.lock +8 -8
@@ -4888,14 +4888,6 @@
4888 4888 source = "registry+https://github.com/rust-lang/crates.io-index"
4889 4889 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4890 4890
4891 - [[patch.unused]]
4892 - name = "synckit-client"
4893 - version = "0.8.0"
4894 -
4895 - [[patch.unused]]
4896 - name = "synckit-config"
4897 - version = "0.2.0"
4898 -
4899 4891 [[patch.unused]]
4900 4892 name = "kberg"
4901 4893 version = "0.1.0"
@@ -4907,3 +4899,11 @@
4907 4899 [[patch.unused]]
4908 4900 name = "tagtree"
4909 4901 version = "0.4.0"
4902 +
4903 + [[patch.unused]]
4904 + name = "synckit-client"
4905 + version = "0.8.0"
4906 +
4907 + [[patch.unused]]
4908 + name = "synckit-config"
4909 + version = "0.2.0"
@@ -1358,6 +1358,25 @@
1358 1358 pub tokens: Vec<Tag>,
1359 1359 /// The controls in this cell, in order.
1360 1360 pub actions: Vec<Act>,
1361 + /// Where this cell's value goes, when the value is itself a link.
1362 + ///
1363 + /// Counted before adding it, the same rule the other two members were held
1364 + /// to: 35 cells across 18 of the MNW server's templates are a value that is
1365 + /// a link, counting only anchors that are not button-styled, so the
1366 + /// `View`/`Edit` anchors [`actions`](Self::actions) already covers are
1367 + /// excluded. A title column is the common one.
1368 + ///
1369 + /// Distinct from [`Cells::activate`], which opens the whole row and can
1370 + /// therefore say only one destination. That is not enough: MNW's
1371 + /// `library_purchases` row carries four, to the item, the creator, the
1372 + /// project and the invoice, and a row-level link would have to drop three
1373 + /// of them.
1374 + ///
1375 + /// Distinct from an [`Act`] too, and the difference is what the reader
1376 + /// sees. An act is a control drawn as one, which is right for `Edit` and
1377 + /// wrong for a title: making every linked value a button would put a row of
1378 + /// bevels down the first column of half the dashboard.
1379 + pub activate: Option<Action>,
1361 1380 }
1362 1381
1363 1382 impl Cell {
@@ -1367,6 +1386,7 @@
1367 1386 value: value.into(),
1368 1387 tokens: Vec::new(),
1369 1388 actions: Vec::new(),
1389 + activate: None,
1370 1390 }
1371 1391 }
1372 1392
@@ -1379,6 +1399,7 @@
1379 1399 value: String::new(),
1380 1400 tokens: vec![tag],
1381 1401 actions: Vec::new(),
1402 + activate: None,
1382 1403 }
1383 1404 }
1384 1405
@@ -1395,6 +1416,7 @@
1395 1416 value: String::new(),
1396 1417 tokens: Vec::new(),
1397 1418 actions: actions.into_iter().collect(),
1419 + activate: None,
1398 1420 }
1399 1421 }
1400 1422
@@ -1404,6 +1426,16 @@
1404 1426 self.actions.push(act);
1405 1427 self
1406 1428 }
1429 +
1430 + /// Where this cell's value goes.
1431 + ///
1432 + /// The value becomes the link. A cell with no value and an `activate` is a
1433 + /// link with nothing to press, so give it text.
1434 + #[must_use]
1435 + pub fn activate(mut self, action: Action) -> Self {
1436 + self.activate = Some(action);
1437 + self
1438 + }
1407 1439 }
1408 1440
1409 1441 impl From<String> for Cell {
@@ -620,6 +620,7 @@
620 620 // click. A badge is not one and does not earn the filter.
621 621 let carries_control = cells.values.iter().any(|cell| {
622 622 !cell.actions.is_empty()
623 + || cell.activate.is_some()
623 624 || cell
624 625 .tokens
625 626 .iter()
@@ -643,7 +644,29 @@
643 644 .values
644 645 .iter()
645 646 .map(|cell| {
646 - let mut inner = escape(&cell.value);
647 + // A value that is a link is the link, rather than text with a
648 + // control beside it. The row is a `div` and not an anchor even when
649 + // it activates, so this nests nothing: the href lands on the row
650 + // element as an attribute htmx reads, and the only `<a>` in the row
651 + // is the one a cell asked for.
652 + //
653 + // `data-act` for the same reason a button carries it. The row's
654 + // `ClickBeside` filter keys on that attribute, so without it a click
655 + // on the title would follow the link and open the row underneath.
656 + let mut inner = match &cell.activate {
657 + Some(action) => {
658 + let (open, close) = control_tag(action);
659 + let mut linked = String::from(open);
660 + class_attr(&["cell-activate"], opts, &mut linked);
661 + linked.push_str(" data-act");
662 + action_attrs(action, Fires::Click, None, morphs, &mut linked);
663 + linked.push('>');
664 + linked.push_str(&escape(&cell.value));
665 + linked.push_str(close);
666 + linked
667 + }
668 + None => escape(&cell.value),
669 + };
647 670 // Tags after the text and before the controls, which is the
648 671 // emphasis order a list row already falls in: the thing itself,
649 672 // then what has a standing of its own, then what can be done to it.
@@ -731,6 +731,59 @@
731 731 assert!(!html.contains("row-actions"));
732 732 }
733 733
734 + #[test]
735 + fn a_cell_value_that_is_a_link_is_the_link() {
736 + // 35 cells across 18 of MNW's templates are a title that goes somewhere.
737 + // The value carries the address itself rather than growing an `Edit` button
738 + // beside it, which is what a title column looks like.
739 + let html = fragment(&Node::Table {
740 + columns: vec![
741 + Column::new("Title").width(layout::Width::Fill),
742 + Column::new("Status").width(layout::Width::Content),
743 + ],
744 + rows: vec![Cells::new([
745 + Cell::new("Release notes").activate(Action::get("/blog/7")),
746 + Cell::tag(Tag::badge("Published")),
747 + ])],
748 + });
749 +
750 + // A read is an anchor with a real href, so middle-click and copy-link work
751 + // and the page is still navigable with JS off.
752 + assert!(html.contains("href=\"/blog/7\""), "{html}");
753 + assert!(html.contains("cell-activate"), "{html}");
754 + assert!(html.contains(">Release notes</a>"), "{html}");
755 +
756 + // The value is still text, whatever the value happens to say.
757 + let hostile = fragment(&Node::Table {
758 + columns: vec![Column::new("Title").width(layout::Width::Fill)],
759 + rows: vec![Cells::new([
760 + Cell::new("<img src=x onerror=alert(1)>").activate(Action::get("/blog/7"))
761 + ])],
762 + });
763 + assert!(!hostile.contains("<img"), "{hostile}");
764 + }
765 +
766 + #[test]
767 + fn a_linked_value_does_not_also_open_the_row() {
768 + // The same double-fire `Cell::acts` has, through a different member: a click
769 + // on the title would follow the link and swap the row's destination in
770 + // underneath it. The filter keys on `data-act`, so the link carries it.
771 + let html = fragment(&Node::Table {
772 + columns: vec![Column::new("Title").width(layout::Width::Fill)],
773 + rows: vec![
774 + Cells::new([Cell::new("Release notes").activate(Action::get("/blog/7"))])
775 + .activate(Action::get("/blog/7/edit")),
776 + ],
777 + });
778 +
779 + assert!(
780 + html.contains("closest("),
781 + "the row filters the link out: {html}"
782 + );
783 + assert!(html.contains("hx-get=\"/blog/7/edit\""), "{html}");
784 + assert!(html.contains("hx-get=\"/blog/7\""), "{html}");
785 + }
786 +
734 787 #[test]
735 788 fn a_control_beside_a_value_does_not_also_open_the_row() {
736 789 // The five of MNW's thirty action-bearing rows that put a control next to a