max / quasi
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 | + | "'[data-act]')]\"" | |
| 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("<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 { |