max / quasi
4 files changed,
+120 insertions,
-9 deletions
| @@ -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" |
| @@ -1316,6 +1316,21 @@ | |||
| 1316 | 1316 | pub struct Cell { | |
| 1317 | 1317 | /// The text, escaped by the renderer. Empty on an actions-only cell. | |
| 1318 | 1318 | pub value: String, | |
| 1319 | + | /// The tags in this cell, in order. | |
| 1320 | + | /// | |
| 1321 | + | /// Counted before adding it, the same rule [`actions`](Self::actions) was | |
| 1322 | + | /// held to: 33 table cells across 22 of the MNW server's templates carry a | |
| 1323 | + | /// badge or a chip, which is more sites than the acts earned. A status | |
| 1324 | + | /// column is the common one and it is the reason this cannot be folded into | |
| 1325 | + | /// [`value`](Self::value) as text: `Refunded` and `Paid` are the same | |
| 1326 | + | /// string to a renderer and different tones to a reader, and flattening | |
| 1327 | + | /// them loses exactly what the column is for. | |
| 1328 | + | /// | |
| 1329 | + | /// The same part [`Row::tokens`] carries, under the same name and holding | |
| 1330 | + | /// the same type, so the two arrangements of a row do not disagree about | |
| 1331 | + | /// what a tag is. That symmetry is also the whole argument for the member: | |
| 1332 | + | /// nothing new is named here. | |
| 1333 | + | pub tokens: Vec<Tag>, | |
| 1319 | 1334 | /// The controls in this cell, in order. | |
| 1320 | 1335 | pub actions: Vec<Act>, | |
| 1321 | 1336 | } | |
| @@ -1325,14 +1340,35 @@ | |||
| 1325 | 1340 | pub fn new(value: impl Into<String>) -> Self { | |
| 1326 | 1341 | Self { | |
| 1327 | 1342 | value: value.into(), | |
| 1343 | + | tokens: Vec::new(), | |
| 1328 | 1344 | actions: Vec::new(), | |
| 1329 | 1345 | } | |
| 1330 | 1346 | } | |
| 1331 | 1347 | ||
| 1348 | + | /// A cell holding one tag and no text. | |
| 1349 | + | /// | |
| 1350 | + | /// What a status column is: the cell is the badge. `Cell::new("")` with a | |
| 1351 | + | /// token would say the same thing and reads as an oversight. | |
| 1352 | + | pub fn tag(tag: Tag) -> Self { | |
| 1353 | + | Self { | |
| 1354 | + | value: String::new(), | |
| 1355 | + | tokens: vec![tag], | |
| 1356 | + | actions: Vec::new(), | |
| 1357 | + | } | |
| 1358 | + | } | |
| 1359 | + | ||
| 1360 | + | /// A tag in this cell, chaining. | |
| 1361 | + | #[must_use] | |
| 1362 | + | pub fn token(mut self, tag: Tag) -> Self { | |
| 1363 | + | self.tokens.push(tag); | |
| 1364 | + | self | |
| 1365 | + | } | |
| 1366 | + | ||
| 1332 | 1367 | /// A cell holding controls and no text. | |
| 1333 | 1368 | pub fn acts(actions: impl IntoIterator<Item = Act>) -> Self { | |
| 1334 | 1369 | Self { | |
| 1335 | 1370 | value: String::new(), | |
| 1371 | + | tokens: Vec::new(), | |
| 1336 | 1372 | actions: actions.into_iter().collect(), | |
| 1337 | 1373 | } | |
| 1338 | 1374 | } |
| @@ -614,7 +614,16 @@ | |||
| 614 | 614 | out.push_str(" aria-current=\"true\""); | |
| 615 | 615 | } | |
| 616 | 616 | if let Some(action) = &cells.activate { | |
| 617 | - | let fires = if cells.values.iter().any(|cell| !cell.actions.is_empty()) { | |
| 617 | + | // Any control in any cell, which is acts plus the chips that answer a | |
| 618 | + | // click. A badge is not one and does not earn the filter. | |
| 619 | + | let carries_control = cells.values.iter().any(|cell| { | |
| 620 | + | !cell.actions.is_empty() | |
| 621 | + | || cell | |
| 622 | + | .tokens | |
| 623 | + | .iter() | |
| 624 | + | .any(|tag| tag.kind.interactive() && tag.action.is_some()) | |
| 625 | + | }); | |
| 626 | + | let fires = if carries_control { | |
| 618 | 627 | Fires::ClickBeside | |
| 619 | 628 | } else { | |
| 620 | 629 | Fires::Click | |
| @@ -633,6 +642,18 @@ | |||
| 633 | 642 | .iter() | |
| 634 | 643 | .map(|cell| { | |
| 635 | 644 | let mut inner = escape(&cell.value); | |
| 645 | + | // Tags after the text and before the controls, which is the | |
| 646 | + | // emphasis order a list row already falls in: the thing itself, | |
| 647 | + | // then what has a standing of its own, then what can be done to it. | |
| 648 | + | if !cell.tokens.is_empty() { | |
| 649 | + | inner.push_str("<span"); | |
| 650 | + | class_attr(&["cell-tokens"], opts, &mut inner); | |
| 651 | + | inner.push('>'); | |
| 652 | + | for tag in &cell.tokens { | |
| 653 | + | tag_html(tag, morphs, opts, &mut inner); | |
| 654 | + | } | |
| 655 | + | inner.push_str("</span>"); | |
| 656 | + | } | |
| 636 | 657 | if !cell.actions.is_empty() { | |
| 637 | 658 | // Deliberately not `row-actions`: makeover hides that one until | |
| 638 | 659 | // the row is hovered or focused, which is defensible on a dense | |
| @@ -977,6 +998,11 @@ | |||
| 977 | 998 | tone_attr(tone, out); | |
| 978 | 999 | ||
| 979 | 1000 | if interactive { | |
| 1001 | + | // `data-act` for the same reason a button carries it: a chip that | |
| 1002 | + | // answers a click is a control, and a table row filtering its own | |
| 1003 | + | // trigger has to be able to tell one apart from its own text. A badge | |
| 1004 | + | // never gets it, because a badge answers nothing. | |
| 1005 | + | out.push_str(" data-act"); | |
| 980 | 1006 | if latched { | |
| 981 | 1007 | // A chip standing for a filter is on or off, and its latched class | |
| 982 | 1008 | // carries that fact visually through Depth::pressed either way. |
| @@ -790,6 +790,55 @@ | |||
| 790 | 790 | } | |
| 791 | 791 | } | |
| 792 | 792 | ||
| 793 | + | #[test] | |
| 794 | + | fn a_status_column_keeps_its_tone_instead_of_flattening_to_text() { | |
| 795 | + | // 33 table cells across 22 of MNW's templates carry a badge or a chip, more | |
| 796 | + | // sites than the acts earned. A status column is the common one, and it is | |
| 797 | + | // why this cannot be folded into the cell's text: `Refunded` and `Paid` are | |
| 798 | + | // the same string to a renderer and different tones to a reader. | |
| 799 | + | let html = fragment(&Node::Table { | |
| 800 | + | columns: vec![ | |
| 801 | + | Column::new("Amount").width(layout::Width::Content), | |
| 802 | + | Column::new("Status").width(layout::Width::Content), | |
| 803 | + | ], | |
| 804 | + | rows: vec![Cells::new([ | |
| 805 | + | Cell::new("$12.00"), | |
| 806 | + | Cell::tag(Tag::badge("Refunded").tone(layout::Tone::Warning)), | |
| 807 | + | ])], | |
| 808 | + | }); | |
| 809 | + | ||
| 810 | + | assert!(html.contains("cell-tokens"), "{html}"); | |
| 811 | + | assert!( | |
| 812 | + | html.contains("class=\"badge\" data-tone=\"warning\""), | |
| 813 | + | "{html}" | |
| 814 | + | ); | |
| 815 | + | assert!(html.contains("Refunded"), "{html}"); | |
| 816 | + | } | |
| 817 | + | ||
| 818 | + | #[test] | |
| 819 | + | fn a_chip_in_a_cell_is_a_control_and_a_badge_is_not() { | |
| 820 | + | // The bubbling guard has to cover both kinds of control a cell can hold, | |
| 821 | + | // or a chip that answers a click opens the row as well as answering it. | |
| 822 | + | let chip = fragment(&Node::Table { | |
| 823 | + | columns: vec![Column::new("Tag").width(layout::Width::Content)], | |
| 824 | + | rows: vec![ | |
| 825 | + | Cells::new([Cell::tag(Tag::chip("Open", Action::get("/tasks?open=1")))]) | |
| 826 | + | .activate(Action::get("/tasks/1")), | |
| 827 | + | ], | |
| 828 | + | }); | |
| 829 | + | assert!(chip.contains("data-act"), "{chip}"); | |
| 830 | + | assert!(chip.contains("closest("), "{chip}"); | |
| 831 | + | ||
| 832 | + | // A badge answers nothing, so it is not a control and the row keeps htmx's | |
| 833 | + | // bare default. | |
| 834 | + | let badge = fragment(&Node::Table { | |
| 835 | + | columns: vec![Column::new("Status").width(layout::Width::Content)], | |
| 836 | + | rows: vec![Cells::new([Cell::tag(Tag::badge("Paid"))]).activate(Action::get("/sales/1"))], | |
| 837 | + | }); | |
| 838 | + | assert!(!badge.contains("data-act"), "{badge}"); | |
| 839 | + | assert!(!badge.contains("hx-trigger"), "{badge}"); | |
| 840 | + | } | |
| 841 | + | ||
| 793 | 842 | #[test] | |
| 794 | 843 | fn a_cell_of_plain_text_is_still_a_string() { | |
| 795 | 844 | // The `From<&str>` that keeps every value-only table unchanged. Without it |