max / quasi
8 files changed,
+567 insertions,
-37 deletions
| @@ -1783,9 +1783,9 @@ | |||
| 1783 | 1783 | ||
| 1784 | 1784 | [[package]] | |
| 1785 | 1785 | name = "makeover-layout" | |
| 1786 | - | version = "0.11.0" | |
| 1786 | + | version = "0.12.0" | |
| 1787 | 1787 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1788 | - | checksum = "67da5deacb4c162878a5ed87839c1a08a7b1f473901f3a8ba1ae03b6ea3952a4" | |
| 1788 | + | checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff" | |
| 1789 | 1789 | ||
| 1790 | 1790 | [[package]] | |
| 1791 | 1791 | name = "makeover-touch" | |
| @@ -1799,12 +1799,12 @@ | |||
| 1799 | 1799 | ||
| 1800 | 1800 | [[package]] | |
| 1801 | 1801 | name = "makeover-webview" | |
| 1802 | - | version = "0.20.0" | |
| 1802 | + | version = "0.21.0" | |
| 1803 | 1803 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1804 | - | checksum = "7a7917e386d40de17c282f4a4354ca73172ae0a00953f0253d136270c6fb7763" | |
| 1804 | + | checksum = "e8d99bc16a534d241a1993e5c5093a2aadafd8ff89f3f4d4a0ebad2746a49651" | |
| 1805 | 1805 | dependencies = [ | |
| 1806 | 1806 | "makeover-geometry", | |
| 1807 | - | "makeover-layout 0.11.0", | |
| 1807 | + | "makeover-layout 0.12.0", | |
| 1808 | 1808 | "makeover-touch", | |
| 1809 | 1809 | ] | |
| 1810 | 1810 | ||
| @@ -2461,7 +2461,7 @@ | |||
| 2461 | 2461 | name = "quasi-router" | |
| 2462 | 2462 | version = "0.1.0" | |
| 2463 | 2463 | dependencies = [ | |
| 2464 | - | "makeover-layout 0.11.0", | |
| 2464 | + | "makeover-layout 0.12.0", | |
| 2465 | 2465 | ] | |
| 2466 | 2466 | ||
| 2467 | 2467 | [[package]] | |
| @@ -2480,7 +2480,7 @@ | |||
| 2480 | 2480 | version = "0.1.0" | |
| 2481 | 2481 | dependencies = [ | |
| 2482 | 2482 | "docengine", | |
| 2483 | - | "makeover-layout 0.11.0", | |
| 2483 | + | "makeover-layout 0.12.0", | |
| 2484 | 2484 | "makeover-webview", | |
| 2485 | 2485 | "quasi-http", | |
| 2486 | 2486 | "quasi-router", | |
| @@ -4636,6 +4636,14 @@ | |||
| 4636 | 4636 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4637 | 4637 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 4638 | 4638 | ||
| 4639 | + | [[patch.unused]] | |
| 4640 | + | name = "synckit-client" | |
| 4641 | + | version = "0.8.0" | |
| 4642 | + | ||
| 4643 | + | [[patch.unused]] | |
| 4644 | + | name = "synckit-config" | |
| 4645 | + | version = "0.2.0" | |
| 4646 | + | ||
| 4639 | 4647 | [[patch.unused]] | |
| 4640 | 4648 | name = "kberg" | |
| 4641 | 4649 | version = "0.1.0" | |
| @@ -4647,11 +4655,3 @@ | |||
| 4647 | 4655 | [[patch.unused]] | |
| 4648 | 4656 | name = "tagtree" | |
| 4649 | 4657 | version = "0.4.0" | |
| 4650 | - | ||
| 4651 | - | [[patch.unused]] | |
| 4652 | - | name = "synckit-client" | |
| 4653 | - | version = "0.8.0" | |
| 4654 | - | ||
| 4655 | - | [[patch.unused]] | |
| 4656 | - | name = "synckit-config" | |
| 4657 | - | version = "0.2.0" |
| @@ -13,4 +13,4 @@ | |||
| 13 | 13 | workspace = true | |
| 14 | 14 | ||
| 15 | 15 | [dependencies] | |
| 16 | - | makeover-layout = "0.11.0" | |
| 16 | + | makeover-layout = "0.12.0" |
| @@ -15,8 +15,8 @@ | |||
| 15 | 15 | [dependencies] | |
| 16 | 16 | quasi-router = { path = "../quasi-router", version = "0.1.0" } | |
| 17 | 17 | quasi-http = { path = "../quasi-http", version = "0.1.0" } | |
| 18 | - | makeover-layout = "0.11.0" | |
| 19 | - | makeover-webview = "0.20.0" | |
| 18 | + | makeover-layout = "0.12.0" | |
| 19 | + | makeover-webview = "0.21.0" | |
| 20 | 20 | # `Node::Rich` carries markdown source and this is what turns it into markup. | |
| 21 | 21 | # Optional and default-on: a renderer that cannot render the member makes the | |
| 22 | 22 | # member do nothing, and a consumer with no rich text should not pay for a |
| @@ -109,8 +109,8 @@ | |||
| 109 | 109 | pub use crate::response::{Message, Outcome, Response}; | |
| 110 | 110 | pub use crate::router::{Handler, Router}; | |
| 111 | 111 | pub use crate::screen::{ | |
| 112 | - | Act, Action, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, RegionKind, Row, | |
| 113 | - | Screen, Slot, Tag, | |
| 112 | + | Act, Action, Cells, Choice, Column, Destination, Field, Figure, Meter, Node, RegionKind, Rest, | |
| 113 | + | Row, Screen, Slot, Tag, | |
| 114 | 114 | }; | |
| 115 | 115 | ||
| 116 | 116 | #[cfg(test)] |
| @@ -108,6 +108,22 @@ | |||
| 108 | 108 | pub tone: layout::Tone, | |
| 109 | 109 | /// The message. | |
| 110 | 110 | pub text: String, | |
| 111 | + | /// What taking it back calls, when it can be taken back. | |
| 112 | + | /// | |
| 113 | + | /// `524a63fe`, the half that is not on [`Act`](crate::Act). Confirming is a | |
| 114 | + | /// question asked *before*, and it is a property of the control, so it lives | |
| 115 | + | /// there. Undoing is offered *after*, alongside the sentence saying what | |
| 116 | + | /// happened, and it needs a second route — which is what made it this | |
| 117 | + | /// crate's rather than the vocabulary's, the same split the file-dialog | |
| 118 | + | /// finding took. | |
| 119 | + | /// | |
| 120 | + | /// goingson raises 16 of these and Balanced Breakfast 3, each through a | |
| 121 | + | /// helper that builds the toast, the button and a countdown by hand. | |
| 122 | + | /// | |
| 123 | + | /// No timeout here. How long an undo stays offered is renderer policy, the | |
| 124 | + | /// same class of decision as whether a pending region draws a skeleton or a | |
| 125 | + | /// spinner, and a description that carried seconds would be naming a value. | |
| 126 | + | pub undo: Option<Action>, | |
| 111 | 127 | } | |
| 112 | 128 | ||
| 113 | 129 | impl Response { | |
| @@ -158,10 +174,26 @@ | |||
| 158 | 174 | kind, | |
| 159 | 175 | tone, | |
| 160 | 176 | text: text.into(), | |
| 177 | + | undo: None, | |
| 161 | 178 | }); | |
| 162 | 179 | self | |
| 163 | 180 | } | |
| 164 | 181 | ||
| 182 | + | /// Offer to take back whatever the notice just said happened. | |
| 183 | + | /// | |
| 184 | + | /// Applies to the notice already on the response, so it follows a | |
| 185 | + | /// [`toast`](Self::toast) or a [`banner`](Self::banner) rather than | |
| 186 | + | /// replacing one. A response with nothing to say has nothing to undo: the | |
| 187 | + | /// sentence is what the offer hangs off, and an undo button with no | |
| 188 | + | /// explanation is a control the user cannot judge. | |
| 189 | + | #[must_use] | |
| 190 | + | pub fn undoable(mut self, action: Action) -> Self { | |
| 191 | + | if let Some(notice) = &mut self.notice { | |
| 192 | + | notice.undo = Some(action); | |
| 193 | + | } | |
| 194 | + | self | |
| 195 | + | } | |
| 196 | + | ||
| 165 | 197 | /// The region being replaced, or `None` for a whole screen or a redirect. | |
| 166 | 198 | /// | |
| 167 | 199 | /// A webview reads this to set `hx-retarget`. Renderers that repaint |
| @@ -338,6 +338,44 @@ | |||
| 338 | 338 | } | |
| 339 | 339 | } | |
| 340 | 340 | ||
| 341 | + | /// What a list has that it is not showing. | |
| 342 | + | /// | |
| 343 | + | /// Deliberately not virtual scrolling, which is the neighbouring thing and is | |
| 344 | + | /// not a description concern: goingson's `virtual-scroller.js` windows rows the | |
| 345 | + | /// app already holds, which is a renderer performance technique. This is a fact | |
| 346 | + | /// about the data — there are rows that were never fetched — and only the thing | |
| 347 | + | /// that fetched them knows it. | |
| 348 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 349 | + | pub struct Rest { | |
| 350 | + | /// How many more there are, when that is known. | |
| 351 | + | /// | |
| 352 | + | /// `None` is honest and common: a query that asked for 51 to find out | |
| 353 | + | /// whether there were more than 50 knows that there are, and not how many. | |
| 354 | + | /// A renderer with a count can say "50 of 400" and one without can still | |
| 355 | + | /// offer the way forward. | |
| 356 | + | pub remaining: Option<u32>, | |
| 357 | + | /// What asking for more calls. | |
| 358 | + | pub action: Action, | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | impl Rest { | |
| 362 | + | /// There is more, reached this way, and the count is not known. | |
| 363 | + | #[must_use] | |
| 364 | + | pub const fn more(action: Action) -> Self { | |
| 365 | + | Self { | |
| 366 | + | remaining: None, | |
| 367 | + | action, | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /// How many more there are. | |
| 372 | + | #[must_use] | |
| 373 | + | pub const fn remaining(mut self, remaining: u32) -> Self { | |
| 374 | + | self.remaining = Some(remaining); | |
| 375 | + | self | |
| 376 | + | } | |
| 377 | + | } | |
| 378 | + | ||
| 341 | 379 | /// How much of a set is done, owned. | |
| 342 | 380 | /// | |
| 343 | 381 | /// The borrowed original is [`layout::Meter`], which arrived at 0.10.0 for this. | |
| @@ -628,7 +666,9 @@ | |||
| 628 | 666 | /// The borrowed original is [`layout::Column`]. The `name` is both the heading | |
| 629 | 667 | /// and the address a cell is found by, which is what replaces addressing | |
| 630 | 668 | /// columns by position. | |
| 631 | - | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 669 | + | /// No `Hash`, for the reason [`Tag`] and [`Field`] have none: it can hold an | |
| 670 | + | /// [`Action`], which holds [`Params`], which is a `Vec`. | |
| 671 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 632 | 672 | pub struct Column { | |
| 633 | 673 | /// The heading, and the name the cell is addressed by. | |
| 634 | 674 | pub name: String, | |
| @@ -636,6 +676,18 @@ | |||
| 636 | 676 | pub width: layout::Width, | |
| 637 | 677 | /// What it is worth when room runs out. | |
| 638 | 678 | pub priority: layout::Priority, | |
| 679 | + | /// Which way the table is ordered by this column, if it is. | |
| 680 | + | pub sorted: Option<layout::Sort>, | |
| 681 | + | /// What pressing this heading calls. | |
| 682 | + | /// | |
| 683 | + | /// `ce620871`. makeover-layout carries `Column::sortable`, a bare bool, | |
| 684 | + | /// because it cannot name an address; here the address *is* the | |
| 685 | + | /// sortability, so the two collapse into one field and cannot disagree. | |
| 686 | + | /// [`as_layout`](Self::as_layout) sets the bool from whether this is here. | |
| 687 | + | /// | |
| 688 | + | /// Reordering a table is a control that writes with no surrounding submit, | |
| 689 | + | /// which is `14612ed8`'s shape, and the renderer treats it the same way. | |
| 690 | + | pub reorder: Option<Action>, | |
| 639 | 691 | } | |
| 640 | 692 | ||
| 641 | 693 | impl Column { | |
| @@ -645,9 +697,25 @@ | |||
| 645 | 697 | name: name.into(), | |
| 646 | 698 | width: layout::Width::Fill, | |
| 647 | 699 | priority: layout::Priority::Secondary, | |
| 700 | + | sorted: None, | |
| 701 | + | reorder: None, | |
| 648 | 702 | } | |
| 649 | 703 | } | |
| 650 | 704 | ||
| 705 | + | /// Pressing this heading reorders the table. | |
| 706 | + | #[must_use] | |
| 707 | + | pub fn reorder(mut self, action: Action) -> Self { | |
| 708 | + | self.reorder = Some(action); | |
| 709 | + | self | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | /// The table is currently ordered by this column, this way. | |
| 713 | + | #[must_use] | |
| 714 | + | pub const fn sorted(mut self, sort: layout::Sort) -> Self { | |
| 715 | + | self.sorted = Some(sort); | |
| 716 | + | self | |
| 717 | + | } | |
| 718 | + | ||
| 651 | 719 | /// Set how much room it asks for. | |
| 652 | 720 | #[must_use] | |
| 653 | 721 | pub fn width(mut self, width: layout::Width) -> Self { | |
| @@ -669,6 +737,8 @@ | |||
| 669 | 737 | name: &self.name, | |
| 670 | 738 | width: self.width, | |
| 671 | 739 | priority: self.priority, | |
| 740 | + | sortable: self.reorder.is_some(), | |
| 741 | + | sorted: self.sorted, | |
| 672 | 742 | } | |
| 673 | 743 | } | |
| 674 | 744 | } | |
| @@ -747,7 +817,13 @@ | |||
| 747 | 817 | pub id: String, | |
| 748 | 818 | /// Which region it is. | |
| 749 | 819 | pub kind: RegionKind, | |
| 750 | - | /// Whether the content is here or on its way. | |
| 820 | + | /// Whether the region's own content is here or on its way. | |
| 821 | + | /// | |
| 822 | + | /// The loading axis, and only that. Emptiness is *not* said here, which | |
| 823 | + | /// looks like the obvious place for it and is not: a column with a heading | |
| 824 | + | /// and no rows is a region that has content — the heading — and a list that | |
| 825 | + | /// has none. Marking the region empty would hide the heading with it. See | |
| 826 | + | /// [`Node::StandIn`]. | |
| 751 | 827 | pub readiness: layout::Readiness, | |
| 752 | 828 | /// What is in it. | |
| 753 | 829 | pub body: Vec<Node>, | |
| @@ -819,6 +895,43 @@ | |||
| 819 | 895 | pub tone: layout::Tone, | |
| 820 | 896 | /// Focused, disabled, or neither. | |
| 821 | 897 | pub state: Option<layout::State>, | |
| 898 | + | /// What to ask before doing it, if it should be asked. | |
| 899 | + | /// | |
| 900 | + | /// `524a63fe`. Destructiveness is a property of the action, known where the | |
| 901 | + | /// action is described, and until this existed every app expressed it by | |
| 902 | + | /// calling a JS helper at the call site: goingson has 33 such calls across | |
| 903 | + | /// four helpers and Balanced Breakfast 5. | |
| 904 | + | /// | |
| 905 | + | /// The prompt only. The word on the agreeing button is | |
| 906 | + | /// [`label`](Self::label), because it already is — goingson's `confirmDelete` | |
| 907 | + | /// passes `confirmText: 'Delete'` for an act labelled "Delete" — and a | |
| 908 | + | /// second string would be the same word twice with a chance to disagree. | |
| 909 | + | /// [`tone`](Self::tone) already says whether the dialog is a dangerous one. | |
| 910 | + | /// | |
| 911 | + | /// `Region::Modal` names the box a confirmation appears in and does not name | |
| 912 | + | /// the pattern. This is the pattern: a webview raises a dialog, a touch host | |
| 913 | + | /// an action sheet, a terminal a y/n line, and none of them is a route to a | |
| 914 | + | /// modal screen and back, which is a different interaction. | |
| 915 | + | pub confirm: Option<String>, | |
| 916 | + | /// The key that reaches it, written the way a user would say it. | |
| 917 | + | /// | |
| 918 | + | /// `2daea915`. An `Act` had a label and a destination and nothing said which | |
| 919 | + | /// key gets there, so goingson's 279-line `keyboard.js` holds the table | |
| 920 | + | /// beside the description, and the help overlay that lists the shortcuts is | |
| 921 | + | /// a second hand-written copy that can drift from it. | |
| 922 | + | /// | |
| 923 | + | /// A terminal makes the case sharper than a webview does: there the key *is* | |
| 924 | + | /// the affordance, so a description that cannot name one cannot describe the | |
| 925 | + | /// screen's primary interaction at all. | |
| 926 | + | /// | |
| 927 | + | /// Text rather than a modelled chord — "n", "ctrl+k", "?" — because the | |
| 928 | + | /// vocabulary of keys is the host's and a description that modelled it would | |
| 929 | + | /// be naming one host's keyboard. A renderer that does not know a name | |
| 930 | + | /// ignores it, which is what a webview does with a key a terminal wants. | |
| 931 | + | /// | |
| 932 | + | /// Screen-scoped, because a screen is what this describes. An app-wide | |
| 933 | + | /// shortcut belongs to the app and is not a fact about any one screen. | |
| 934 | + | pub key: Option<String>, | |
| 822 | 935 | } | |
| 823 | 936 | ||
| 824 | 937 | impl Act { | |
| @@ -829,9 +942,25 @@ | |||
| 829 | 942 | action, | |
| 830 | 943 | tone: layout::Tone::Neutral, | |
| 831 | 944 | state: None, | |
| 945 | + | confirm: None, | |
| 946 | + | key: None, | |
| 832 | 947 | } | |
| 833 | 948 | } | |
| 834 | 949 | ||
| 950 | + | /// Ask this before doing it. | |
| 951 | + | #[must_use] | |
| 952 | + | pub fn confirm(mut self, prompt: impl Into<String>) -> Self { | |
| 953 | + | self.confirm = Some(prompt.into()); | |
| 954 | + | self | |
| 955 | + | } | |
| 956 | + | ||
| 957 | + | /// The key that reaches it. | |
| 958 | + | #[must_use] | |
| 959 | + | pub fn key(mut self, key: impl Into<String>) -> Self { | |
| 960 | + | self.key = Some(key.into()); | |
| 961 | + | self | |
| 962 | + | } | |
| 963 | + | ||
| 835 | 964 | /// Set what it is saying. | |
| 836 | 965 | #[must_use] | |
| 837 | 966 | pub fn tone(mut self, tone: layout::Tone) -> Self { | |
| @@ -919,6 +1048,23 @@ | |||
| 919 | 1048 | /// a row flattens the proportion into [`meta`](Self::meta) as "3/7 | |
| 920 | 1049 | /// subtasks", keeping both numbers and losing the reading. | |
| 921 | 1050 | pub meter: Option<Meter>, | |
| 1051 | + | /// Everything else that can be done to this row. | |
| 1052 | + | /// | |
| 1053 | + | /// `5e02fbce`. [`actions`](Self::actions) is what the row shows; this is | |
| 1054 | + | /// what it *offers*, reached by right-click on a pointer host, long-press on | |
| 1055 | + | /// a touch one, and a key in a terminal. That split is the whole reason it | |
| 1056 | + | /// belongs in the description rather than in a renderer: one description has | |
| 1057 | + | /// to become a context menu, an action sheet and a key-driven menu, and no | |
| 1058 | + | /// single renderer can be the place where it is said. | |
| 1059 | + | /// | |
| 1060 | + | /// goingson opens one at 14 sites and Balanced Breakfast at 9, on top of | |
| 1061 | + | /// 680 lines of generic menu machinery between `components.js` and | |
| 1062 | + | /// `context-menus.js`. | |
| 1063 | + | /// | |
| 1064 | + | /// A `Vec<Act>` rather than a new part, because that is what a menu is: the | |
| 1065 | + | /// acts already carry their own tone, state, confirmation and key. The | |
| 1066 | + | /// 2026-08-08 row ruling is untouched — these are acts, not nodes. | |
| 1067 | + | pub menu: Vec<Act>, | |
| 922 | 1068 | /// What ticking this row calls, if ticking it is the write. | |
| 923 | 1069 | /// | |
| 924 | 1070 | /// `14612ed8`, part of it. [`selected`](Self::selected) says whether the row | |
| @@ -947,6 +1093,13 @@ | |||
| 947 | 1093 | } | |
| 948 | 1094 | } | |
| 949 | 1095 | ||
| 1096 | + | /// Something else that can be done to this row, not shown inline. | |
| 1097 | + | #[must_use] | |
| 1098 | + | pub fn offers(mut self, act: Act) -> Self { | |
| 1099 | + | self.menu.push(act); | |
| 1100 | + | self | |
| 1101 | + | } | |
| 1102 | + | ||
| 950 | 1103 | /// How much of this row's set is done. | |
| 951 | 1104 | #[must_use] | |
| 952 | 1105 | pub fn meter(mut self, meter: Meter) -> Self { | |
| @@ -1116,6 +1269,43 @@ | |||
| 1116 | 1269 | /// The message. | |
| 1117 | 1270 | text: String, | |
| 1118 | 1271 | }, | |
| 1272 | + | /// What stands where content would be, when there is none. | |
| 1273 | + | /// | |
| 1274 | + | /// `703f4cd2`. goingson draws one at 27 sites across 12 files and Balanced | |
| 1275 | + | /// Breakfast at 9, and the class families had already drifted into | |
| 1276 | + | /// `empty-state--error` against `error-state` for the same fact. Every one | |
| 1277 | + | /// of those sites substitutes markup where a list would go, which is what | |
| 1278 | + | /// makes this a node. | |
| 1279 | + | /// | |
| 1280 | + | /// # Why not on the region | |
| 1281 | + | /// | |
| 1282 | + | /// It was on [`Slot`] first, and a real screen killed it: the project | |
| 1283 | + | /// dashboard's columns are a heading and a list, and a column with no rows | |
| 1284 | + | /// is a region that has content and a list that has none. Marking the | |
| 1285 | + | /// region empty took the heading down with the rows. The emptiness belongs | |
| 1286 | + | /// to the thing that is empty. | |
| 1287 | + | /// | |
| 1288 | + | /// [`Slot::readiness`] keeps the loading axis and only that, which is what | |
| 1289 | + | /// `aria-busy` is about. | |
| 1290 | + | /// | |
| 1291 | + | /// # The state is the vocabulary's and the sentence is not | |
| 1292 | + | /// | |
| 1293 | + | /// `makeover-layout` names the four states because "nothing here yet" and | |
| 1294 | + | /// "this broke" mean the same thing in every app that will have them. "No | |
| 1295 | + | /// projects yet" is content, and so is the button under it, so both are | |
| 1296 | + | /// here. A [`layout::Readiness::Ready`] renders nothing at all: the state | |
| 1297 | + | /// that shows content has no stand-in to draw. | |
| 1298 | + | StandIn { | |
| 1299 | + | /// Which of the states this is standing in for. | |
| 1300 | + | state: layout::Readiness, | |
| 1301 | + | /// The sentence. "No projects yet", "Failed to load events". | |
| 1302 | + | message: String, | |
| 1303 | + | /// The way out, if there is one. "Add your first project", "Try again". | |
| 1304 | + | /// | |
| 1305 | + | /// 2 of goingson's 27 have one and 25 say a sentence and stop, which is | |
| 1306 | + | /// why it is optional rather than a second required string. | |
| 1307 | + | act: Option<Act>, | |
| 1308 | + | }, | |
| 1119 | 1309 | /// One control, standing on its own. | |
| 1120 | 1310 | /// | |
| 1121 | 1311 | /// `14612ed8`. A [`Form`](Self::Form) is a set of questions asked together | |
| @@ -1145,6 +1335,17 @@ | |||
| 1145 | 1335 | List { | |
| 1146 | 1336 | /// The rows, in order. | |
| 1147 | 1337 | rows: Vec<Row>, | |
| 1338 | + | /// What is not shown, if anything is. | |
| 1339 | + | /// | |
| 1340 | + | /// `346567f9`. A described list of the first 50 of 400 tasks was | |
| 1341 | + | /// indistinguishable from a described list of 50 tasks, so each app | |
| 1342 | + | /// grew its own answer: goingson a 159-line pagination manager with two | |
| 1343 | + | /// consumers that had each written it separately first, Balanced | |
| 1344 | + | /// Breakfast four `loadMore` sites. Two idioms for one fact, and the | |
| 1345 | + | /// fact is what belongs here — how much more there is and how to ask | |
| 1346 | + | /// for it. Whether that becomes numbered pages, a load-more button or | |
| 1347 | + | /// an infinite scroll is the renderer's. | |
| 1348 | + | more: Option<Rest>, | |
| 1148 | 1349 | }, | |
| 1149 | 1350 | /// Rows with named columns. | |
| 1150 | 1351 | Table { | |
| @@ -1267,15 +1468,59 @@ | |||
| 1267 | 1468 | pub fn list(rows: impl IntoIterator<Item = Row>) -> Self { | |
| 1268 | 1469 | Self::List { | |
| 1269 | 1470 | rows: rows.into_iter().collect(), | |
| 1471 | + | more: None, | |
| 1270 | 1472 | } | |
| 1271 | 1473 | } | |
| 1272 | 1474 | ||
| 1475 | + | /// The same list, saying there is more of it. | |
| 1476 | + | /// | |
| 1477 | + | /// A no-op on anything that is not a [`Self::List`], which is the one place | |
| 1478 | + | /// this file allows that: the alternative is a constructor taking rows and a | |
| 1479 | + | /// `Rest` together, and every call site that has no more rows then passes a | |
| 1480 | + | /// `None` to say so. | |
| 1481 | + | #[must_use] | |
| 1482 | + | pub fn and_more(mut self, rest: Rest) -> Self { | |
| 1483 | + | if let Self::List { more, .. } = &mut self { | |
| 1484 | + | *more = Some(rest); | |
| 1485 | + | } | |
| 1486 | + | self | |
| 1487 | + | } | |
| 1488 | + | ||
| 1273 | 1489 | /// A proportion of a set, untoned and unlabelled. | |
| 1274 | 1490 | #[must_use] | |
| 1275 | 1491 | pub const fn meter(done: u32, total: u32) -> Self { | |
| 1276 | 1492 | Self::Meter(Meter::new(done, total)) | |
| 1277 | 1493 | } | |
| 1278 | 1494 | ||
| 1495 | + | /// Nothing here yet. | |
| 1496 | + | pub fn empty(message: impl Into<String>) -> Self { | |
| 1497 | + | Self::StandIn { | |
| 1498 | + | state: layout::Readiness::Empty, | |
| 1499 | + | message: message.into(), | |
| 1500 | + | act: None, | |
| 1501 | + | } | |
| 1502 | + | } | |
| 1503 | + | ||
| 1504 | + | /// This did not load. | |
| 1505 | + | pub fn failed(message: impl Into<String>) -> Self { | |
| 1506 | + | Self::StandIn { | |
| 1507 | + | state: layout::Readiness::Failed, | |
| 1508 | + | message: message.into(), | |
| 1509 | + | act: None, | |
| 1510 | + | } | |
| 1511 | + | } | |
| 1512 | + | ||
| 1513 | + | /// The same stand-in, with a way out of it. | |
| 1514 | + | /// | |
| 1515 | + | /// A no-op on anything else, for the reason [`Self::and_more`] is one. | |
| 1516 | + | #[must_use] | |
| 1517 | + | pub fn offering(mut self, way_out: Act) -> Self { | |
| 1518 | + | if let Self::StandIn { act, .. } = &mut self { | |
| 1519 | + | *act = Some(way_out); | |
| 1520 | + | } | |
| 1521 | + | self | |
| 1522 | + | } | |
| 1523 | + | ||
| 1279 | 1524 | /// One control on its own, outside any form. | |
| 1280 | 1525 | pub fn field(field: Field) -> Self { | |
| 1281 | 1526 | Self::Field(Box::new(field)) |
| @@ -27,6 +27,7 @@ | |||
| 27 | 27 | use makeover_webview::form::{Filling, Markup, Value, escape, field_html}; | |
| 28 | 28 | use makeover_webview::list::{Cell, cells_html}; | |
| 29 | 29 | use makeover_webview::meter::meter_html; | |
| 30 | + | use makeover_webview::placeholder::placeholder_html; | |
| 30 | 31 | use quasi_router::screen::{Act, Cells, Destination, Field, Node, Row, Slot, Tag}; | |
| 31 | 32 | use quasi_router::{Action, Method, Params}; | |
| 32 | 33 | ||
| @@ -103,7 +104,7 @@ | |||
| 103 | 104 | if let Some(action) = writes { | |
| 104 | 105 | out.push_str("<div"); | |
| 105 | 106 | class_attr(&["field-writes"], opts, out); | |
| 106 | - | action_attrs(action, Fires::ChangeInside, morphs, out); | |
| 107 | + | action_attrs(action, Fires::ChangeInside, None, morphs, out); | |
| 107 | 108 | out.push('>'); | |
| 108 | 109 | } | |
| 109 | 110 | ||
| @@ -207,7 +208,13 @@ | |||
| 207 | 208 | /// one code path covers both, and no part of this stack concatenates a `?`. | |
| 208 | 209 | /// That is where escaping bugs live, and the description layer's own reason for | |
| 209 | 210 | /// carrying [`Action::params`] as values rather than as text. | |
| 210 | - | pub(crate) fn action_attrs(action: &Action, fires: Fires, morphs: bool, out: &mut String) { | |
| 211 | + | pub(crate) fn action_attrs( | |
| 212 | + | action: &Action, | |
| 213 | + | fires: Fires, | |
| 214 | + | confirm: Option<&str>, | |
| 215 | + | morphs: bool, | |
| 216 | + | out: &mut String, | |
| 217 | + | ) { | |
| 211 | 218 | // An external destination is not htmx's business: nothing swaps, no route | |
| 212 | 219 | // is called, and the browser follows a normal link. `rel` rather than | |
| 213 | 220 | // trust: a new tab with `window.opener` left intact hands the other page a | |
| @@ -244,6 +251,16 @@ | |||
| 244 | 251 | } | |
| 245 | 252 | } | |
| 246 | 253 | ||
| 254 | + | // Asking before acting is transport here, same as the verb: htmx gates the | |
| 255 | + | // request on it. That is also why it lands in this function rather than | |
| 256 | + | // beside the label — every `hx-` attribute this crate emits comes from one | |
| 257 | + | // place, or swapping htmx for fixi stops being a one-function change. | |
| 258 | + | if let Some(prompt) = confirm { | |
| 259 | + | out.push_str(" hx-confirm=\""); | |
| 260 | + | out.push_str(&escape(prompt)); | |
| 261 | + | out.push('"'); | |
| 262 | + | } | |
| 263 | + | ||
| 247 | 264 | if morphs { | |
| 248 | 265 | // Decision 7's slack: a morph preserves focus, scroll and input state | |
| 249 | 266 | // through a swap, so a whole-Screen answer stops being destructive. | |
| @@ -299,13 +316,35 @@ | |||
| 299 | 316 | } | |
| 300 | 317 | Some(layout::State::Focus) => { | |
| 301 | 318 | out.push_str(" autofocus"); | |
| 302 | - | action_attrs(&act.action, Fires::Click, morphs, out); | |
| 319 | + | action_attrs( | |
| 320 | + | &act.action, | |
| 321 | + | Fires::Click, | |
| 322 | + | act.confirm.as_deref(), | |
| 323 | + | morphs, | |
| 324 | + | out, | |
| 325 | + | ); | |
| 303 | 326 | } | |
| 304 | 327 | // `State` is `#[non_exhaustive]`, so a member added upstream lands | |
| 305 | 328 | // here. Emitting the transport is the right default for anything that | |
| 306 | 329 | // is not a suppression: a state this renderer has not learned yet | |
| 307 | 330 | // should leave the control working, not silently inert. | |
| 308 | - | _ => action_attrs(&act.action, Fires::Click, morphs, out), | |
| 331 | + | _ => action_attrs( | |
| 332 | + | &act.action, | |
| 333 | + | Fires::Click, | |
| 334 | + | act.confirm.as_deref(), | |
| 335 | + | morphs, | |
| 336 | + | out, | |
| 337 | + | ), | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // The confirmation rides with the transport, in `action_attrs`. The key does | |
| 341 | + | // not: `accesskey` is plain HTML and no part of htmx. It is emitted even | |
| 342 | + | // though a browser makes little of it, because a key is the affordance in a | |
| 343 | + | // terminal and this is the nearest honest thing a page has. | |
| 344 | + | if let Some(key) = &act.key { | |
| 345 | + | out.push_str(" accesskey=\""); | |
| 346 | + | out.push_str(&escape(key)); | |
| 347 | + | out.push('"'); | |
| 309 | 348 | } | |
| 310 | 349 | ||
| 311 | 350 | out.push('>'); | |
| @@ -349,7 +388,7 @@ | |||
| 349 | 388 | // for an input is `change` — named anyway, so the markup says what it | |
| 350 | 389 | // does rather than relying on a default holding. | |
| 351 | 390 | if let Some(action) = &row.toggle { | |
| 352 | - | action_attrs(action, Fires::Change, morphs, out); | |
| 391 | + | action_attrs(action, Fires::Change, None, morphs, out); | |
| 353 | 392 | } | |
| 354 | 393 | out.push_str(" aria-label=\"Select\">"); | |
| 355 | 394 | } | |
| @@ -362,7 +401,7 @@ | |||
| 362 | 401 | let (open, close) = control_tag(action); | |
| 363 | 402 | out.push_str(open); | |
| 364 | 403 | class_attr(&["row-activate"], opts, out); | |
| 365 | - | action_attrs(action, Fires::Click, morphs, out); | |
| 404 | + | action_attrs(action, Fires::Click, None, morphs, out); | |
| 366 | 405 | out.push('>'); | |
| 367 | 406 | out.push_str(&escape(&row.primary)); | |
| 368 | 407 | out.push_str(close); | |
| @@ -392,6 +431,23 @@ | |||
| 392 | 431 | out.push_str("</span>"); | |
| 393 | 432 | } | |
| 394 | 433 | ||
| 434 | + | // The menu before the bar, because it is not a fact about the row at all: | |
| 435 | + | // it is the set of things that can be done to it, and it renders as a | |
| 436 | + | // container the host opens its own way. A webview hangs a context menu off | |
| 437 | + | // it, a touch host an action sheet, a terminal a key-driven list; all three | |
| 438 | + | // read the same acts. | |
| 439 | + | if !row.menu.is_empty() { | |
| 440 | + | out.push_str("<div"); | |
| 441 | + | class_attr(&["row-menu"], opts, out); | |
| 442 | + | // Named rather than hidden by class, so the host's own menu code has | |
| 443 | + | // something to bind to that does not depend on how it is styled. | |
| 444 | + | out.push_str(" data-menu=\"row\" hidden>"); | |
| 445 | + | for act in &row.menu { | |
| 446 | + | act_html(act, morphs, opts, out); | |
| 447 | + | } | |
| 448 | + | out.push_str("</div>"); | |
| 449 | + | } | |
| 450 | + | ||
| 395 | 451 | // The bar sits with the plain trailing facts and before the tokens, which | |
| 396 | 452 | // is where a proportion belongs in the emphasis order: it is a fact about | |
| 397 | 453 | // the row rather than a thing with a standing of its own. The trough and | |
| @@ -453,7 +509,7 @@ | |||
| 453 | 509 | out.push_str(" aria-current=\"true\""); | |
| 454 | 510 | } | |
| 455 | 511 | if let Some(action) = &cells.activate { | |
| 456 | - | action_attrs(action, Fires::Click, morphs, out); | |
| 512 | + | action_attrs(action, Fires::Click, None, morphs, out); | |
| 457 | 513 | } | |
| 458 | 514 | out.push('>'); | |
| 459 | 515 | ||
| @@ -504,6 +560,27 @@ | |||
| 504 | 560 | out.push_str("</p>"); | |
| 505 | 561 | } | |
| 506 | 562 | ||
| 563 | + | Node::StandIn { | |
| 564 | + | state, | |
| 565 | + | message, | |
| 566 | + | act, | |
| 567 | + | } => { | |
| 568 | + | // The markup is makeover-webview's, unchanged, for the reason every | |
| 569 | + | // other emitter here defers to it: the CSS that has to match it is | |
| 570 | + | // emitted there too. The way out arrives as `Markup` because a | |
| 571 | + | // button is an address and no crate down there names one. | |
| 572 | + | let mut way_out = String::new(); | |
| 573 | + | if let Some(act) = act { | |
| 574 | + | act_html(act, morphs, opts, &mut way_out); | |
| 575 | + | } | |
| 576 | + | out.push_str(&placeholder_html( | |
| 577 | + | *state, | |
| 578 | + | message, | |
| 579 | + | (!way_out.is_empty()).then_some(Markup(way_out.as_str())), | |
| 580 | + | opts, | |
| 581 | + | )); | |
| 582 | + | } | |
| 583 | + | ||
| 507 | 584 | Node::Rich { source } => { | |
| 508 | 585 | out.push_str("<div"); | |
| 509 | 586 | class_attr(&["rich"], opts, out); | |
| @@ -549,7 +626,7 @@ | |||
| 549 | 626 | } => { | |
| 550 | 627 | out.push_str("<form"); | |
| 551 | 628 | class_attr(&["form"], opts, out); | |
| 552 | - | action_attrs(action, Fires::Click, morphs, out); | |
| 629 | + | action_attrs(action, Fires::Click, None, morphs, out); | |
| 553 | 630 | out.push('>'); | |
| 554 | 631 | for field in fields { | |
| 555 | 632 | field_group_html(field, morphs, opts, out); | |
| @@ -561,7 +638,7 @@ | |||
| 561 | 638 | out.push_str("</button></form>"); | |
| 562 | 639 | } | |
| 563 | 640 | ||
| 564 | - | Node::List { rows } => { | |
| 641 | + | Node::List { rows, more } => { | |
| 565 | 642 | out.push_str("<ul"); | |
| 566 | 643 | class_attr(&["list"], opts, out); | |
| 567 | 644 | out.push('>'); | |
| @@ -569,6 +646,32 @@ | |||
| 569 | 646 | row_html(row, morphs, opts, out); | |
| 570 | 647 | } | |
| 571 | 648 | out.push_str("</ul>"); | |
| 649 | + | ||
| 650 | + | // Outside the list, because it is not one of the things in it. A | |
| 651 | + | // renderer that wanted numbered pages instead would put them here | |
| 652 | + | // too; what the description said is that there is more and how to | |
| 653 | + | // ask, and this is one host's answer to that. | |
| 654 | + | if let Some(rest) = more { | |
| 655 | + | out.push_str("<div"); | |
| 656 | + | class_attr(&["rest"], opts, out); | |
| 657 | + | out.push('>'); | |
| 658 | + | ||
| 659 | + | let (open, close) = control_tag(&rest.action); | |
| 660 | + | out.push_str(open); | |
| 661 | + | class_attr(&["act", "rest-more"], opts, out); | |
| 662 | + | action_attrs(&rest.action, Fires::Click, None, morphs, out); | |
| 663 | + | out.push('>'); | |
| 664 | + | match rest.remaining { | |
| 665 | + | Some(n) => { | |
| 666 | + | out.push_str("Show more ("); | |
| 667 | + | out.push_str(&n.to_string()); | |
| 668 | + | out.push_str(" remaining)"); | |
| 669 | + | } | |
| 670 | + | None => out.push_str("Show more"), | |
| 671 | + | } | |
| 672 | + | out.push_str(close); | |
| 673 | + | out.push_str("</div>"); | |
| 674 | + | } | |
| 572 | 675 | } | |
| 573 | 676 | ||
| 574 | 677 | Node::Table { columns, rows } => { | |
| @@ -590,11 +693,37 @@ | |||
| 590 | 693 | out.push_str("<div role=\"row\""); | |
| 591 | 694 | class_attr(&["table-head"], opts, out); | |
| 592 | 695 | out.push('>'); | |
| 593 | - | for column in &borrowed { | |
| 696 | + | for (column, described) in borrowed.iter().zip(columns) { | |
| 594 | 697 | out.push_str("<span role=\"columnheader\""); | |
| 595 | 698 | class_attr(&["table-heading"], opts, out); | |
| 699 | + | // `aria-sort` is what a table actually says about its order; | |
| 700 | + | // the caret in `state_rules` is this renderer's expression of | |
| 701 | + | // the same fact for everyone not using a screen reader. | |
| 702 | + | if let Some(sort) = column.sorted { | |
| 703 | + | out.push_str(" aria-sort=\""); | |
| 704 | + | out.push_str(sort.as_str()); | |
| 705 | + | out.push('"'); | |
| 706 | + | } | |
| 707 | + | if column.sortable { | |
| 708 | + | out.push_str(" data-sortable"); | |
| 709 | + | } | |
| 596 | 710 | out.push('>'); | |
| 597 | - | out.push_str(&escape(column.name)); | |
| 711 | + | match &described.reorder { | |
| 712 | + | // A button inside the header cell rather than attributes on | |
| 713 | + | // the cell itself: `role="columnheader"` is not a control, | |
| 714 | + | // and a screen reader offered a press on something that | |
| 715 | + | // announces itself as a heading has been lied to. | |
| 716 | + | Some(action) => { | |
| 717 | + | let (open, close) = control_tag(action); | |
| 718 | + | out.push_str(open); | |
| 719 | + | class_attr(&["table-sort"], opts, out); | |
| 720 | + | action_attrs(action, Fires::Click, None, morphs, out); | |
| 721 | + | out.push('>'); | |
| 722 | + | out.push_str(&escape(column.name)); | |
| 723 | + | out.push_str(close); | |
| 724 | + | } | |
| 725 | + | None => out.push_str(&escape(column.name)), | |
| 726 | + | } | |
| 598 | 727 | out.push_str("</span>"); | |
| 599 | 728 | } | |
| 600 | 729 | out.push_str("</div>"); | |
| @@ -640,7 +769,7 @@ | |||
| 640 | 769 | let (open, close) = control_tag(action); | |
| 641 | 770 | out.push_str(open); | |
| 642 | 771 | class_attr(&["figure-act"], opts, out); | |
| 643 | - | action_attrs(action, Fires::Click, morphs, out); | |
| 772 | + | action_attrs(action, Fires::Click, None, morphs, out); | |
| 644 | 773 | out.push('>'); | |
| 645 | 774 | out.push_str(&figure_html(&figure.as_layout(), opts)); | |
| 646 | 775 | out.push_str(close); | |
| @@ -707,7 +836,7 @@ | |||
| 707 | 836 | out.push_str(" aria-pressed=\"true\""); | |
| 708 | 837 | } | |
| 709 | 838 | if let Some(action) = action { | |
| 710 | - | action_attrs(action, Fires::Click, morphs, out); | |
| 839 | + | action_attrs(action, Fires::Click, None, morphs, out); | |
| 711 | 840 | } | |
| 712 | 841 | } | |
| 713 | 842 | ||
| @@ -769,7 +898,7 @@ | |||
| 769 | 898 | // `Node::SELECTED`, rather than agreed per screen between a | |
| 770 | 899 | // renderer and a handler. | |
| 771 | 900 | let carrying = action.clone().with(Node::SELECTED, option.value.clone()); | |
| 772 | - | action_attrs(&carrying, Fires::Click, morphs, out); | |
| 901 | + | action_attrs(&carrying, Fires::Click, None, morphs, out); | |
| 773 | 902 | } | |
| 774 | 903 | ||
| 775 | 904 | out.push('>'); |
| @@ -9,7 +9,7 @@ | |||
| 9 | 9 | ||
| 10 | 10 | use makeover_layout as layout; | |
| 11 | 11 | use quasi_http::Render; | |
| 12 | - | use quasi_router::screen::{Act, Cells, Choice, Column, Field, Figure, Meter, Row, Tag}; | |
| 12 | + | use quasi_router::screen::{Act, Cells, Choice, Column, Field, Figure, Meter, Rest, Row, Tag}; | |
| 13 | 13 | use quasi_router::{Action, Node, RegionKind, Screen, Slot}; | |
| 14 | 14 | ||
| 15 | 15 | use crate::{Emit, Shell, Webview}; | |
| @@ -624,6 +624,130 @@ | |||
| 624 | 624 | assert!(!html.contains("hunter2")); | |
| 625 | 625 | } | |
| 626 | 626 | ||
| 627 | + | #[test] | |
| 628 | + | fn an_empty_region_says_so_instead_of_rendering_an_empty_box() { | |
| 629 | + | // `703f4cd2`. A region shows its content or a stand-in, never both, which | |
| 630 | + | // is `Readiness` being one axis with four values rather than two. | |
| 631 | + | let empty = render( | |
| 632 | + | &Screen::list_detail("Projects", false).with( | |
| 633 | + | Slot::new("list", RegionKind::Pane) | |
| 634 | + | .with(Node::section("Projects")) | |
| 635 | + | .with(Node::empty("No projects yet")), | |
| 636 | + | ), | |
| 637 | + | ); | |
| 638 | + | ||
| 639 | + | assert!(empty.contains("No projects yet")); | |
| 640 | + | assert!(empty.contains(r#"data-state="empty""#)); | |
| 641 | + | // The heading survives, which is why this is a node and not a state on the | |
| 642 | + | // region: a column with a heading and no rows still has a heading. | |
| 643 | + | assert!(empty.contains(">Projects<")); | |
| 644 | + | // Not announced as a fault: an empty list is the normal state of a new | |
| 645 | + | // install. | |
| 646 | + | assert!(!empty.contains(r#"role="alert""#)); | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | #[test] | |
| 650 | + | fn a_failed_region_is_a_different_state_from_an_empty_one_and_offers_a_way_out() { | |
| 651 | + | let failed = render( | |
| 652 | + | &Screen::list_detail("Events", false).with( | |
| 653 | + | Slot::new("list", RegionKind::Pane).with( | |
| 654 | + | Node::failed("Failed to load events") | |
| 655 | + | .offering(Act::new("Try again", Action::get("/events"))), | |
| 656 | + | ), | |
| 657 | + | ), | |
| 658 | + | ); | |
| 659 | + | ||
| 660 | + | assert!(failed.contains(r#"data-state="failed""#)); | |
| 661 | + | assert!(failed.contains(r#"data-tone="danger""#)); | |
| 662 | + | assert!(failed.contains(r#"role="alert""#)); | |
| 663 | + | // The way out is a real control, so it reaches a handler. | |
| 664 | + | assert!(failed.contains("hx-get=\"/events\"")); | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | #[test] | |
| 668 | + | fn a_destructive_act_asks_first_and_a_shortcut_reaches_it() { | |
| 669 | + | // `524a63fe` and `2daea915`. Both are facts about the control that no | |
| 670 | + | // renderer can derive, and goingson expressed the first by calling a JS | |
| 671 | + | // helper at 33 call sites. | |
| 672 | + | let html = fragment(&Node::Act( | |
| 673 | + | Act::new("Delete", Action::post("/tasks/1/delete")) | |
| 674 | + | .tone(layout::Tone::Danger) | |
| 675 | + | .confirm("Delete this task? This cannot be undone.") | |
| 676 | + | .key("d"), | |
| 677 | + | )); | |
| 678 | + | ||
| 679 | + | assert!(html.contains(r#"hx-confirm="Delete this task? This cannot be undone.""#)); | |
| 680 | + | assert!(html.contains(r#"accesskey="d""#)); | |
| 681 | + | assert!(html.contains("hx-post=\"/tasks/1/delete\"")); | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | #[test] | |
| 685 | + | fn a_row_offers_what_it_does_not_show() { | |
| 686 | + | // `5e02fbce`. `actions` is what the row shows; `menu` is what it offers, | |
| 687 | + | // opened by right-click, long-press or a key depending on the host. | |
| 688 | + | let html = fragment(&Node::list([Row::new("Buy milk") | |
| 689 | + | .act(Act::new("Done", Action::post("/tasks/1/complete"))) | |
| 690 | + | .offers(Act::new("Duplicate", Action::post("/tasks/1/copy"))) | |
| 691 | + | .offers( | |
| 692 | + | Act::new("Delete", Action::post("/tasks/1/delete")).confirm("Delete this task?"), | |
| 693 | + | )])); | |
| 694 | + | ||
| 695 | + | assert!(html.contains(r#"data-menu="row""#)); | |
| 696 | + | // Hidden rather than absent: the host opens it, and a menu that is not in | |
| 697 | + | // the document cannot be opened. | |
| 698 | + | assert!(html.contains(" hidden>")); | |
| 699 | + | assert!(html.contains("Duplicate")); | |
| 700 | + | assert!(html.contains(r#"hx-confirm="Delete this task?""#)); | |
| 701 | + | // The shown action is still shown. | |
| 702 | + | assert!(html.contains("Done")); | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | #[test] | |
| 706 | + | fn a_list_says_how_much_more_there_is_and_how_to_ask() { | |
| 707 | + | // `346567f9`. A described list of the first 50 of 400 was indistinguishable | |
| 708 | + | // from a described list of 50. | |
| 709 | + | let counted = fragment( | |
| 710 | + | &Node::list([Row::new("One")]) | |
| 711 | + | .and_more(Rest::more(Action::get("/tasks?page=2")).remaining(350)), | |
| 712 | + | ); | |
| 713 | + | assert!(counted.contains("350 remaining")); | |
| 714 | + | assert!(counted.contains("hx-get=\"/tasks?page=2\"")); | |
| 715 | + | ||
| 716 | + | // A count is often unknown: asking for 51 to find out whether there are | |
| 717 | + | // more than 50 answers the question without answering how many. | |
| 718 | + | let uncounted = | |
| 719 | + | fragment(&Node::list([Row::new("One")]).and_more(Rest::more(Action::get("/more")))); | |
| 720 | + | assert!(uncounted.contains("Show more")); | |
| 721 | + | assert!(!uncounted.contains("remaining")); | |
| 722 | + | ||
| 723 | + | let plain = fragment(&Node::list([Row::new("One")])); | |
| 724 | + | assert!(!plain.contains("rest")); | |
| 725 | + | } | |
| 726 | + | ||
| 727 | + | #[test] | |
| 728 | + | fn a_sortable_column_says_which_way_and_offers_the_press() { | |
| 729 | + | // `ce620871`. The one finding that completed a member rather than adding | |
| 730 | + | // one: `Column` shipped with a width and a priority and could say nothing | |
| 731 | + | // about order. | |
| 732 | + | let html = fragment(&Node::Table { | |
| 733 | + | columns: vec![ | |
| 734 | + | Column::new("Title") | |
| 735 | + | .reorder(Action::get("/tasks?sort=title")) | |
| 736 | + | .sorted(layout::Sort::Ascending), | |
| 737 | + | Column::new("Due").reorder(Action::get("/tasks?sort=due")), | |
| 738 | + | Column::new("Notes"), | |
| 739 | + | ], | |
| 740 | + | rows: vec![Cells::new(["Ship it", "Tomorrow", "None"])], | |
| 741 | + | }); | |
| 742 | + | ||
| 743 | + | assert!(html.contains(r#"aria-sort="ascending""#)); | |
| 744 | + | assert_eq!(html.matches("data-sortable").count(), 2); | |
| 745 | + | // The press is a button inside the header cell, never the cell itself: a | |
| 746 | + | // `columnheader` is not a control and must not announce itself as one. | |
| 747 | + | assert!(html.contains(r#"<button type="button" class="table-sort""#)); | |
| 748 | + | assert!(html.contains("hx-get=\"/tasks?sort=due\"")); | |
| 749 | + | } | |
| 750 | + | ||
| 627 | 751 | #[test] | |
| 628 | 752 | fn a_notice_interrupts_only_when_its_tone_says_to() { | |
| 629 | 753 | let danger = fragment(&Node::banner(layout::Tone::Danger, "Disk full")); |