Skip to main content

max / quasi

Take makeover 0.12: stand-ins, confirm and undo, row menus, paging, sort, keys
Author: Max Johnson <me@maxj.phd> · 2026-08-09 16:25 UTC
Signed with PGP, not checked
Commit: 022269edf2f1a4512f7af9fc1c59bec3e5b99be4
Parent: 896d573
8 files changed, +567 insertions, -37 deletions
M Cargo.lock +15 -15
@@ -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"));