max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+155 insertions,
-27 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" |
| @@ -1742,13 +1742,30 @@ | |||
| 1742 | 1742 | Select { | |
| 1743 | 1743 | /// Segmented, toggle, or tabs. | |
| 1744 | 1744 | kind: layout::Selector, | |
| 1745 | - | /// What is on offer. | |
| 1746 | - | options: Vec<Choice>, | |
| 1745 | + | /// What is on offer, and what each one calls if it calls something of | |
| 1746 | + | /// its own. | |
| 1747 | + | /// | |
| 1748 | + | /// The tuple is [`Stats`](Self::Stats)' shape and it is here for the | |
| 1749 | + | /// same reason, stated there: `makeover-layout` cannot name an action | |
| 1750 | + | /// at all, so an address rides beside the described thing rather than | |
| 1751 | + | /// inside it. [`Choice::as_layout`] hands back a value and a label and | |
| 1752 | + | /// nothing else. | |
| 1753 | + | /// | |
| 1754 | + | /// It is what a tab strip needs. The MNW server's dashboard-user shell | |
| 1755 | + | /// has fifteen tabs and fifteen routes; one strip-level action with the | |
| 1756 | + | /// value substituted in cannot address them, and building the route by | |
| 1757 | + | /// convention would put route construction in a renderer. | |
| 1758 | + | /// | |
| 1759 | + | /// An option carrying `None` falls back to | |
| 1760 | + | /// [`action`](Self::Select::action) with its value under | |
| 1761 | + | /// [`Self::SELECTED`], which is what every option did before the tuple, | |
| 1762 | + | /// so a segmented control and a toggle are unchanged in meaning. | |
| 1763 | + | options: Vec<(Choice, Option<Action>)>, | |
| 1747 | 1764 | /// Which option is currently picked, by its | |
| 1748 | 1765 | /// [`value`](Choice::value). | |
| 1749 | 1766 | chosen: Option<String>, | |
| 1750 | - | /// What picking an option calls. The picked value is sent under | |
| 1751 | - | /// [`Self::SELECTED`]. | |
| 1767 | + | /// What picking an option calls, for the options that name nothing | |
| 1768 | + | /// themselves. The picked value is sent under [`Self::SELECTED`]. | |
| 1752 | 1769 | action: Option<Action>, | |
| 1753 | 1770 | }, | |
| 1754 | 1771 | /// How much of a set is done. |
| @@ -1134,7 +1134,7 @@ | |||
| 1134 | 1134 | /// A control that picks between things. | |
| 1135 | 1135 | fn select_html( | |
| 1136 | 1136 | kind: layout::Selector, | |
| 1137 | - | options: &[quasi_router::screen::Choice], | |
| 1137 | + | options: &[(quasi_router::screen::Choice, Option<Action>)], | |
| 1138 | 1138 | chosen: Option<&str>, | |
| 1139 | 1139 | action: Option<&Action>, | |
| 1140 | 1140 | morphs: bool, | |
| @@ -1158,14 +1158,23 @@ | |||
| 1158 | 1158 | } | |
| 1159 | 1159 | out.push('>'); | |
| 1160 | 1160 | ||
| 1161 | - | for option in options { | |
| 1161 | + | for (option, own) in options { | |
| 1162 | 1162 | let picked = chosen.is_some_and(|value| value == option.value); | |
| 1163 | 1163 | let mut classes = vec!["option"]; | |
| 1164 | 1164 | if picked { | |
| 1165 | 1165 | classes.push("option-chosen"); | |
| 1166 | 1166 | } | |
| 1167 | 1167 | ||
| 1168 | - | out.push_str("<button type=\"button\""); | |
| 1168 | + | // An option naming its own route is a link when that route is a read, | |
| 1169 | + | // for the reason every other read here is: middle-click, copy-link, a | |
| 1170 | + | // crawler and the page with JS off. A tab panel is fetched with a GET, | |
| 1171 | + | // so a tab strip is fifteen links rather than fifteen buttons. | |
| 1172 | + | let (open, close) = own | |
| 1173 | + | .as_ref() | |
| 1174 | + | .map_or(("<button type=\"button\"", "</button>"), |action| { | |
| 1175 | + | control_tag(action) | |
| 1176 | + | }); | |
| 1177 | + | out.push_str(open); | |
| 1169 | 1178 | class_attr(&classes, opts, out); | |
| 1170 | 1179 | if matches!(kind, layout::Selector::Tabs) { | |
| 1171 | 1180 | out.push_str(" role=\"tab\" aria-selected=\""); | |
| @@ -1174,7 +1183,13 @@ | |||
| 1174 | 1183 | out.push_str(" aria-pressed=\"true\""); | |
| 1175 | 1184 | } | |
| 1176 | 1185 | ||
| 1177 | - | if let Some(action) = action { | |
| 1186 | + | // The option's own route wins, and there is nothing to substitute into | |
| 1187 | + | // it: it already names the panel. The strip's action is the fallback, | |
| 1188 | + | // and it is the one that needs the picked value, because it is one | |
| 1189 | + | // route standing for all the options. | |
| 1190 | + | if let Some(own) = own { | |
| 1191 | + | action_attrs(own, Fires::Click, None, morphs, out); | |
| 1192 | + | } else if let Some(action) = action { | |
| 1178 | 1193 | // The picked value travels under one name, decided once in | |
| 1179 | 1194 | // `Node::SELECTED`, rather than agreed per screen between a | |
| 1180 | 1195 | // renderer and a handler. | |
| @@ -1184,7 +1199,7 @@ | |||
| 1184 | 1199 | ||
| 1185 | 1200 | out.push('>'); | |
| 1186 | 1201 | out.push_str(&escape(&option.label)); | |
| 1187 | - | out.push_str("</button>"); | |
| 1202 | + | out.push_str(close); | |
| 1188 | 1203 | } | |
| 1189 | 1204 | ||
| 1190 | 1205 | out.push_str("</div>"); |
| @@ -497,14 +497,20 @@ | |||
| 497 | 497 | let html = fragment(&Node::Select { | |
| 498 | 498 | kind: layout::Selector::Tabs, | |
| 499 | 499 | options: vec![ | |
| 500 | - | Choice { | |
| 501 | - | value: "open".into(), | |
| 502 | - | label: "Open".into(), | |
| 503 | - | }, | |
| 504 | - | Choice { | |
| 505 | - | value: "done".into(), | |
| 506 | - | label: "Done".into(), | |
| 507 | - | }, | |
| 500 | + | ( | |
| 501 | + | Choice { | |
| 502 | + | value: "open".into(), | |
| 503 | + | label: "Open".into(), | |
| 504 | + | }, | |
| 505 | + | None, | |
| 506 | + | ), | |
| 507 | + | ( | |
| 508 | + | Choice { | |
| 509 | + | value: "done".into(), | |
| 510 | + | label: "Done".into(), | |
| 511 | + | }, | |
| 512 | + | None, | |
| 513 | + | ), | |
| 508 | 514 | ], | |
| 509 | 515 | chosen: Some("open".into()), | |
| 510 | 516 | action: Some(Action::get("/tasks")), | |
| @@ -1765,3 +1771,89 @@ | |||
| 1765 | 1771 | assert!(!html.contains("push-url"), "{html}"); | |
| 1766 | 1772 | assert!(!html.contains("replace-url"), "{html}"); | |
| 1767 | 1773 | } | |
| 1774 | + | ||
| 1775 | + | #[test] | |
| 1776 | + | fn an_option_naming_its_own_route_calls_that_and_not_the_strips() { | |
| 1777 | + | // The tab strip. dashboard-user has fifteen panels and fifteen routes, and | |
| 1778 | + | // one strip-level action with the value substituted in cannot address them. | |
| 1779 | + | let html = fragment(&Node::Select { | |
| 1780 | + | kind: layout::Selector::Tabs, | |
| 1781 | + | options: vec![ | |
| 1782 | + | ( | |
| 1783 | + | Choice { | |
| 1784 | + | value: "projects".into(), | |
| 1785 | + | label: "Projects".into(), | |
| 1786 | + | }, | |
| 1787 | + | // No target on the action: decision 7 says the response names | |
| 1788 | + | // what it replaces, and a panel route answers with a Fragment | |
| 1789 | + | // aimed at the content slot. | |
| 1790 | + | Some(Action::get("/dashboard/tabs/projects")), | |
| 1791 | + | ), | |
| 1792 | + | ( | |
| 1793 | + | Choice { | |
| 1794 | + | value: "keys".into(), | |
| 1795 | + | label: "SSH keys".into(), | |
| 1796 | + | }, | |
| 1797 | + | Some(Action::get("/dashboard/tabs/ssh-keys")), | |
| 1798 | + | ), | |
| 1799 | + | ], | |
| 1800 | + | chosen: Some("projects".into()), | |
| 1801 | + | action: None, | |
| 1802 | + | }); | |
| 1803 | + | ||
| 1804 | + | // Each panel's own route, and no value substituted into anything: the | |
| 1805 | + | // option already names where it goes. | |
| 1806 | + | assert!(html.contains("/dashboard/tabs/projects"), "{html}"); | |
| 1807 | + | assert!(html.contains("/dashboard/tabs/ssh-keys"), "{html}"); | |
| 1808 | + | assert!( | |
| 1809 | + | !html.contains(&format!("{}=projects", Node::SELECTED)), | |
| 1810 | + | "{html}" | |
| 1811 | + | ); | |
| 1812 | + | ||
| 1813 | + | // A read is a link, so the tab works with JS off and can be copied. | |
| 1814 | + | assert!(html.contains("<a"), "{html}"); | |
| 1815 | + | assert!(html.contains("role=\"tablist\""), "{html}"); | |
| 1816 | + | assert!(html.contains("aria-selected=\"true\""), "{html}"); | |
| 1817 | + | } | |
| 1818 | + | ||
| 1819 | + | #[test] | |
| 1820 | + | fn an_option_with_no_route_of_its_own_is_unchanged() { | |
| 1821 | + | // Every segmented control and toggle in the tree is this case, and the | |
| 1822 | + | // tuple must not have moved it: the strip's action carries the picked value | |
| 1823 | + | // under the one agreed name, exactly as before. | |
| 1824 | + | let with_tuple = fragment(&Node::Select { | |
| 1825 | + | kind: layout::Selector::Segmented, | |
| 1826 | + | options: vec![ | |
| 1827 | + | ( | |
| 1828 | + | Choice { | |
| 1829 | + | value: "0".into(), | |
| 1830 | + | label: "Active".into(), | |
| 1831 | + | }, | |
| 1832 | + | None, | |
| 1833 | + | ), | |
| 1834 | + | ( | |
| 1835 | + | Choice { | |
| 1836 | + | value: "1".into(), | |
| 1837 | + | label: "Archived".into(), | |
| 1838 | + | }, | |
| 1839 | + | None, | |
| 1840 | + | ), | |
| 1841 | + | ], | |
| 1842 | + | chosen: Some("0".into()), | |
| 1843 | + | action: Some(Action::get("/notes")), | |
| 1844 | + | }); | |
| 1845 | + | ||
| 1846 | + | assert!( | |
| 1847 | + | with_tuple.contains(&format!("/notes?{}=0", Node::SELECTED)), | |
| 1848 | + | "{with_tuple}" | |
| 1849 | + | ); | |
| 1850 | + | assert!( | |
| 1851 | + | with_tuple.contains(&format!("/notes?{}=1", Node::SELECTED)), | |
| 1852 | + | "{with_tuple}" | |
| 1853 | + | ); | |
| 1854 | + | // Still a button: nothing about the fallback path changed. | |
| 1855 | + | assert!( | |
| 1856 | + | with_tuple.contains("<button type=\"button\""), | |
| 1857 | + | "{with_tuple}" | |
| 1858 | + | ); | |
| 1859 | + | } |
| @@ -133,9 +133,13 @@ | |||
| 133 | 133 | ||
| 134 | 134 | let filter = Node::Select { | |
| 135 | 135 | kind: Selector::Segmented, | |
| 136 | + | // Each option pairs with the route it calls, and both of these call | |
| 137 | + | // the strip's. A tab strip is where the other half earns its keep: an | |
| 138 | + | // option naming its own route addresses one panel out of fifteen, which | |
| 139 | + | // one route with a value substituted in cannot do. | |
| 136 | 140 | options: vec![ | |
| 137 | - | Choice::new("0", format!("Active ({active_count})")), | |
| 138 | - | Choice::new("1", format!("Archived ({archived_count})")), | |
| 141 | + | (Choice::new("0", format!("Active ({active_count})")), None), | |
| 142 | + | (Choice::new("1", format!("Archived ({archived_count})")), None), | |
| 139 | 143 | ], | |
| 140 | 144 | chosen: Some(if archived { "1" } else { "0" }.to_owned()), | |
| 141 | 145 | // The picked value arrives under `Node::SELECTED`, which is the name |