A way down the tree from every page
Test / test (pull_request) Successful in 53s

An arrow fixed at the left edge, half-way up the screen, and the same
link in words at the foot of the page. It goes one level down: the
nearest page below the current path that the visitor may open, found by
taking the path apart a segment at a time, and the front door when none
of those is a page (/status/oppslag -> /, /review/abc -> /review). The
front door itself has none. Labelled with where it goes: 'Tilbake til
starten', or the target page's question in quotation marks.

Content stops needing a 'back' alternative on every page, and a visitor
who went up a branch whose context did not apply is never stranded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-29 07:05:39 +02:00
co-authored by Claude Opus 5.5
parent f0c43c5c0f
commit e2596bc3cf
4 changed files with 174 additions and 0 deletions
+103
View File
@@ -470,10 +470,83 @@ fn QuestionView(
/// Every other question the visitor currently qualifies for, as a
/// small nav - how a concern reaches the people it speaks to without
/// claiming front-page space (an owner also sees the gated desks here).
/// Where the arrow on the left takes a visitor: the nearest page below
/// the current one in the tree that they may open, found by taking the
/// path apart one segment at a time (`/a/b/c`, then `/a/b`, then `/a`),
/// and the front door when none of those is a page. `None` on the front
/// door itself, which has nowhere further down to go. It is always a
/// valid way on, so a visitor who went up a branch whose context turned
/// out not to apply is never left stranded.
pub fn down_the_tree(path: &str, pages: &[(String, String)]) -> Option<(String, String)> {
let path = path.split(['?', '#']).next().unwrap_or("/").trim_end_matches('/');
if path.is_empty() {
return None;
}
let mut at = path;
while let Some(i) = at.rfind('/') {
at = &at[..i];
let candidate = if at.is_empty() { "/" } else { at };
if let Some(page) = pages.iter().find(|(id, _)| id == candidate) {
return Some(page.clone());
}
if at.is_empty() {
break;
}
}
Some(("/".to_string(), String::new()))
}
/// How the way down names where it goes: "the start" for the front
/// door, and otherwise the page's own question, quoted - a page's
/// name is the question it asks, which reads as a destination only in
/// quotation marks.
fn down_the_tree_label(lang: &str, href: &str, name: &str) -> String {
let to = if href == "/" || name.is_empty() {
t(lang, "the_start").to_string()
} else if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") {
format!("«{name}»")
} else {
format!("\u{201c}{name}\u{201d}")
};
format!("{}{to}", t(lang, "down_to"))
}
#[component]
fn QuestionNav(current_id: String) -> impl IntoView {
let NavRes(nav) = expect_context();
let location = leptos_router::hooks::use_location();
view! {
// The way down the tree: fixed at the left edge, half-way up the
// screen, small enough never to cover what a visitor is reading.
// It replaces a "back" alternative on every page that had one.
<Suspense fallback=|| ()>
{move || {
let path = location.pathname.get();
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
let label = down_the_tree_label(&lang().get(), &href, &name);
view! {
<a class="down-the-tree" href=href aria-label=label.clone() title=label>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 5 8 12l7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
}
})
}}
</Suspense>
// The same way down, at the foot of the page, in words: where a
// reader who has read to the end looks for where to go next.
<Suspense fallback=|| ()>
{move || {
let path = location.pathname.get();
nav.get().and_then(|res| res.ok()).and_then(|items| down_the_tree(&path, &items)).map(|(href, name)| {
let label = down_the_tree_label(&lang().get(), &href, &name);
view! {
<nav class="down-the-tree-foot">
<a href=href>{format!("← {label}")}</a>
</nav>
}
})
}}
</Suspense>
<Suspense fallback=|| ()>
{move || {
let current_id = current_id.clone();
@@ -2578,3 +2651,33 @@ async fn upload_file(
.as_string()
.ok_or_else(|| JsValue::from_str("upload response had no key"))
}
#[cfg(test)]
mod down_the_tree_tests {
use super::down_the_tree;
fn pages(ids: &[&str]) -> Vec<(String, String)> {
ids.iter().map(|id| (id.to_string(), format!("page {id}"))).collect()
}
#[test]
fn the_way_down_is_the_nearest_page_below_and_never_a_dead_end() {
let p = pages(&["/", "/melde-fra", "/review"]);
// One level down, to a page that exists.
assert_eq!(down_the_tree("/melde-fra", &p).unwrap().0, "/");
assert_eq!(down_the_tree("/review/abc123", &p).unwrap().0, "/review");
// Segments that are no page of their own are skipped.
assert_eq!(down_the_tree("/status/oppslag", &p).unwrap().0, "/");
assert_eq!(down_the_tree("/a/b/c/d", &p).unwrap().0, "/");
// A trailing slash or a query does not count as a level.
assert_eq!(down_the_tree("/review/abc/?chain=x", &p).unwrap().0, "/review");
// Named: the start, or the page's question in quotation marks.
assert_eq!(super::down_the_tree_label("no", "/", "Hva gjør vi?"), "Tilbake til starten");
assert_eq!(super::down_the_tree_label("no", "/review", "Hva kom inn?"), "Tilbake til «Hva kom inn?»");
assert_eq!(super::down_the_tree_label("en", "/review", "What came in?"), "Back to \u{201c}What came in?\u{201d}");
// The front door has nowhere further down.
assert!(down_the_tree("/", &p).is_none());
// A visitor who may not open the front door still gets "/".
assert_eq!(down_the_tree("/melde-fra", &pages(&["/melde-fra"])).unwrap().0, "/");
}
}
+6
View File
@@ -33,6 +33,8 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
"This part of the site is for organizational owners — sign in with that account to take a look."
}
("en", "announcements") => "Announcements",
("en", "down_to") => "Back to ",
("en", "the_start") => "the start",
("no", "also_worth_asking") => "Også verdt å spørre",
("no", "asked_by") => "Stilt av ",
@@ -51,6 +53,8 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
"Denne delen av siden er for organisasjonens eiere — logg inn med den kontoen for å ta en titt."
}
("no", "announcements") => "Kunngjøringer",
("no", "down_to") => "Tilbake til ",
("no", "the_start") => "starten",
_ => return None,
})
@@ -83,6 +87,8 @@ mod tests {
"follows_answer",
"owners_only",
"announcements",
"down_to",
"the_start",
] {
assert!(lookup("en", key).is_some(), "en missing {key}");
assert!(lookup("no", key).is_some(), "no missing {key}");