tribble-app 0.1.1

The Perseus app that underlies Tribble.
Documentation
use super::get_build_state::WorkflowProps;
use super::parse_md::parse_md_to_html;
use crate::parser::{
    Endpoint, Input, InputSectionElem, InputType, Section, SectionElem, SelectOption,
};
use crate::svg;
#[cfg(not(debug_assertions))]
use js_sys::Function;
use std::collections::HashMap;
use sycamore::context::{use_context, ContextProvider, ContextProviderProps};
use sycamore::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::HtmlOptionElement;

/// The context of a workflow (these need to be accessed by multiple different parts of the workflow).
#[derive(Clone, Debug)]
struct WorkflowCtx {
    /// The history of the user's progression through different sections, along with the different tags they've accumulated. If the user moves back, we'll update `history_pos`, if they
    /// then act without using the forward button (even if they do the same thing as it would've), this will be truncated to their position (but their form inputs are preserved
    /// regardless).
    ///
    /// New sections are added here immediately, and their tags are added afterwards.
    history: Signal<Vec<SectionResult>>,
    /// The user's current position in their history.
    history_pos: Signal<usize>,
    /// The current location (either a section or an endpoint).
    loc: Signal<String>,
    /// The values types in different inputs, which can be later referenced for interpolation. Selects with multiple values submit their values as a comma-delimited list.
    form_values: Signal<HashMap<String, Signal<String>>>,
}
impl WorkflowCtx {
    fn new(index_loc: String) -> Self {
        Self {
            loc: Signal::new(index_loc.clone()),
            // The history should start on the first section
            history: Signal::new(vec![SectionResult {
                name: index_loc,
                tags: Vec::new(),
            }]),
            history_pos: Signal::new(0), // There's no history at this point, so this is safe
            form_values: Signal::default(),
        }
    }
}

/// The results from a section. A vector of these can be used to track history.
#[derive(Clone, Debug)]
struct SectionResult {
    tags: Vec<String>,
    name: String,
}

#[perseus::template(Workflow)]
#[component(Workflow<G>)]
pub fn workflow(props: WorkflowProps) -> View<G> {
    let index_loc = props.workflow.index.clone();
    // If we're in the browser, immediately tell it that we want to prompt the user before they leave the page
    // we'll only actually do this if we're in dev mode
    #[cfg(not(debug_assertions))]
    if G::IS_BROWSER {
        let window = web_sys::window().unwrap();
        window.set_onbeforeunload(Some(&Function::new_with_args(
            "ev",
            "ev.preventDefault();ev.returnValue = \"\"",
        )));
    }

    view! {
        // We pass tags around with context to avoid throwing `Signal`s over the place
        ContextProvider(ContextProviderProps {
            value: WorkflowCtx::new(index_loc),
            children: || view! {
                WorkflowInner(props)
            }
        })
    }
}

#[component(WorkflowInner<G>)]
pub fn workflow_inner(
    WorkflowProps {
        workflow,
        input_err_msg,
    }: WorkflowProps,
) -> View<G> {
    // This will be the name of a section, or, if it's prefixed with `endpoint:`, an endpoint
    let loc = use_context::<WorkflowCtx>().loc;
    // This will either store an endpoint or a section, fully rendered
    let page: ReadSignal<View<G>> = create_memo(cloned!(workflow, loc => move || {
        let loc = &*loc.get();
        if loc.starts_with("endpoint:") {
            let loc = loc.strip_prefix("endpoint:").unwrap();
            let endpoint_props = match workflow.endpoints.get(loc) {
                Some(props) => props,
                None => todo!("handle errors in pages (no such endpoint)")
            };
            match endpoint_props {
                Endpoint::Report { preamble, text, dest_text, dest_url } => view! {
                    RenderReportEndpoint(RenderReportEndpointProps { preamble: preamble.to_string(), text: text.to_string(), dest_text: dest_text.to_string(), dest_url: dest_url.to_string() })
                },
                Endpoint::Instructional(text) => {
                    let text = parse_md_to_html(text);
                    view! {
                        div(class = "markdown", dangerously_set_inner_html = &text) {}
                    }
                }
            }
        } else {
            let section_props = match workflow.sections.get(loc) {
                Some(props) => RenderSectionProps { section: props.clone(), input_err_msg: input_err_msg.clone(), name: loc.to_string() },
                None => todo!("handle errors in pages (no such section)")
            };
            view! {
                RenderSection(section_props)
            }
        }
    }));

    view! {
        // We set the caret color at the top-level (changes the outlines of form inputs, cursor color, etc.)
        div(class = "flex justify-center w-full min-h-full py-2 xs:py-6 sm:py-8 md:py-16") {
            // TODO Top margins
            div(class = "section-container xs:shadow-md dark:xs:shadow-lg xs:rounded-lg text-center flex-col md:w-[48rem] m-auto", id = "section-content") {
                HistoryBreadcrumbs()
                // We want to alert screenreaders that this entire section can be swapped out for new content
                div(class = "w-full flex flex-col justify-center") {
                    main(class = "section-content max-w-full self-center", aria-live = "assertive", aria-atomic = true) {
                        (*page.get())
                    }
                }
            }
        }
    }
}

struct RenderSectionProps {
    section: Section,
    input_err_msg: String,
    name: String,
}

/// Renders a section. We loop through the elements without keying or the like because, every time we re-render the list of props, we'll be changing all of them.
#[component(RenderSection<G>)]
fn render_section(
    RenderSectionProps {
        section,
        input_err_msg,
        name,
    }: RenderSectionProps,
) -> View<G> {
    // We keep a local map of form values that we'll add to the global one on a progression (otherwise we're doing unecessary context reads)
    // We make this reactive at the value level, because each value will be updating (whereas the global one has values set, because they're submitted after the section is moved on from)
    // This also stores the input's properties and a reactive boolean that will be `true` if an error message needs to be shown
    #[allow(clippy::type_complexity)]
    let form_values: Signal<HashMap<String, (Signal<String>, InputSectionElem, Signal<bool>)>> =
        Signal::new(HashMap::new());

    let ctx = use_context::<WorkflowCtx>();
    let elems = View::new_fragment(
        section
            .iter()
            .map(cloned!(ctx => move |section_elem| {
                let rendered = match section_elem {
                    SectionElem::Text(text) => {
                        let text = parse_md_to_html(text);
                        view! {
                            div(class = "markdown", dangerously_set_inner_html = &text) {}
                        }
                    },
                    SectionElem::Progression { text, link, tags } => {
                        let text = text.to_string();
                        let link = link.to_string();
                        let new_tags = tags.clone();
                        let progression_handler = cloned!(ctx, form_values, name => move |_| {
                            // If the user selects this progression, we need to set the new location and update the tags
                            let history = (*ctx.history.get()).clone();
                            let mut tags = Vec::new(); // This is for just the tags accumulated in this section
                            tags.extend(new_tags.iter().cloned());
                            // All the form values for this section should be sent to the global store for later inteprolation
                            let form_values_global = (*ctx.form_values.get()).clone();
                            let mut do_change = true;
                            for (_id, (value_signal, input, show_err)) in form_values.get().iter() {
                                let value = (*value_signal.get()).clone();
                                if value.is_empty() && !input.optional {
                                    show_err.clone().set(true);
                                    do_change = false;
                                } else {
                                    show_err.clone().set(false);
                                    // If this is a select input, some of its options might want to add tags if they've been selected
                                    if let Input::Select { options, .. } = input.input.clone() {
                                        // We need to create a map of simple option text names to the tags they might add
                                        let options_simple: HashMap<String, Vec<String>> = options.iter().map(|opt| match opt {
                                            SelectOption::Simple(text) => (text.to_string(), vec![]),
                                            SelectOption::WithTags { text, tags } => (text.to_string(), tags.clone())
                                        }).collect();
                                        let values_vec = value.split(", "); // This is the reason we don't allow commas in select options!
                                        for selected_value in values_vec {
                                            let tags_to_add = match options_simple.get(selected_value) {
                                                Some(tags) => tags,
                                                // This is impossible because we're indexing based on options the user only typed in one place
                                                None => unreachable!()
                                            }.clone();
                                            tags.extend(tags_to_add);
                                        }
                                    }
                                    // Do the same for boolean inputs
                                    if let Input::Text { input_type: InputType::Boolean { tags: Some(new_tags) } } = input.input.clone() {
                                        if value == "true" {
                                            tags.extend(new_tags);
                                        }
                                    }
                                    // The value has already been registered globally, so we don't need to do any more
                                }
                            }
                            if do_change {
                                ctx.form_values.set(form_values_global);
                                let history_pos = *ctx.history_pos.get();
                                // The history position points to an element that contains the current section result (with tags waiting to be filled out), so delete everything
                                // after that (in case we've gone back in the history), a progression resets all following history
                                let mut history: Vec<SectionResult> = history
                                    .iter()
                                    .enumerate()
                                    .filter(|(i, _)| {
                                        // Only permit elements that are on or below the current history position (which is this section)
                                        i <= &history_pos
                                    })
                                    .map(|(_, v)| v)
                                    .cloned()
                                    .collect();
                                // Update the history for this section with the tags we've accumulated
                                history[history_pos] = SectionResult {
                                    name: name.clone(),
                                    tags
                                };
                                // Add a history element for the next section we're about to go to
                                history.push(SectionResult {
                                    name: link.clone(), // This could be an endpoint, in which case it won't ever accumulate any tags, so there are no problems there
                                    tags: Vec::new() // This will be filled out when we reach the next progression element
                                });
                                // Update the user's position in the history to the next section they're about to go to
                                ctx.history_pos.set(history_pos + 1);
                                ctx.history.set(history);
                                // This reactively updates the section being displayed to the user (though we can do more stuff after this if we want)
                                ctx.loc.set(link.clone());
                            }
                        });
                        view! {
                            button(
                                on:click = progression_handler,
                                class = "group inline-flex items-center p-5 text-lg shadow-md hover:shadow-lg dark:shadow-lg dark:hover:shadow-xl transition-shadow duration-200 rounded-lg"
                            ) {
                                (text)
                                div(class = "h-5 w-5 group-hover:ml-1 transition-all ease-in-out duration-200") {
                                    (svg!(r#"<svg aria-hidden=true xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>"#))
                                }
                            }
                        }
                    },
                    SectionElem::Input(input_props @ InputSectionElem { id, default, label, input, optional }) => {
                        // If we've moved back through the history, there may be records for this input (which we should autofill)
                        let mut form_values_map = (*form_values.get()).clone();
                        let show_err = Signal::new(false);
                        let mut form_values_global = (*ctx.form_values.get()).clone();
                        let input_value = if form_values_global.contains_key(id) {
                            Signal::new((*form_values_global.get(id).unwrap().get()).clone())
                        } else {
                            Signal::new(String::new())
                        };
                        // Register the value locally (so that progression elements can play with it)
                        form_values_map.insert(id.to_string(), (input_value.clone(), input_props.clone(), show_err.clone()));
                        form_values.set(form_values_map);
                        // Register the value globally (that way values are still saved even if the user moves back in history before submitting this section)
                        form_values_global.insert(id.to_string(), input_value.clone());
                        ctx.form_values.set(form_values_global);

                        // If we have pre-existing data, we'll override the default
                        let default = if !input_value.get().is_empty() {
                            (*input_value.get()).clone()
                        } else {
                            default.clone().unwrap_or_else(|| "".to_string())
                        };
                        // Without this, the default values don't actually do anything
                        // We still set them with `value` though for progressive enhancement and accessibility
                        input_value.set(default.clone());

                        let id = id.to_string();
                        let id_for_err_label = id.clone();
                        let label = label.clone();

                        let err_label = create_memo(cloned!(show_err, input_err_msg, id_for_err_label => move || {
                            if *show_err.get() {
                                let id_for_err_label = id_for_err_label.clone();
                                view! {
                                    label(for = id_for_err_label) { (input_err_msg) }
                                }
                            } else {
                                View::empty()
                            }
                        }));

                        // We render the asterisk for required values based on a class
                        let label_class = if !optional {
                            "input-required"
                        } else {
                            ""
                        };

                        let input_rendered = match input {
                            Input::Text { input_type } => {
                                // We make all the placeholders empty because that allows the CSS `:placeholder-shown` selector to work
                                match input_type {
                                    // Multiline inputs use a `textarea` rather than an `input`, so we split off here
                                    InputType::Multiline => view! {
                                        // We want to keep the integrity of the page, so it's only resizeable in the y-direction
                                        label(class = "custom-input") {
                                            textarea(bind:value = input_value, class = "resize-y", placeholder = "") { (default) }
                                            span(class = label_class) { (label) }
                                        }
                                        ((*err_label.get()).clone())
                                    },
                                    // A lot of the other input types are the same (other than their `type`), except for the ones with extra properties
                                    InputType::Number { min, max } => match (*min, *max) {
                                        (Some(min), Some(max)) => view! {
                                            label(class = "custom-input") {
                                                input(bind:value = input_value, type = "number", min = min, max = max, value = default, id = id, placeholder = "") {}
                                                span(class = label_class) { (label) }
                                            }
                                            ((*err_label.get()).clone())
                                        },
                                        (Some(min), None) => view! {
                                            label(class = "custom-input") {
                                                input(bind:value = input_value, type = "number", min = min, value = default, id = id, placeholder = "") {}
                                                span(class = label_class) { (label) }
                                            }
                                            ((*err_label.get()).clone())
                                        },
                                        (None, Some(max)) => view! {
                                            label(class = "custom-input") {
                                                input(bind:value = input_value, type = "number", max = max, value = default, id = id, placeholder = "") {}
                                                span(class = label_class) { (label) }
                                            }
                                            ((*err_label.get()).clone())
                                        },
                                        (None, None) => view! {
                                            label(class = "custom-input") {
                                                input(bind:value = input_value, type = "number", value = default, id = id, placeholder = "") {}
                                                span(class = label_class) { (label) }
                                            }
                                            ((*err_label.get()).clone())
                                        },
                                    },
                                    InputType::Range { min, max } => {
                                        let min = *min;
                                        let max = *max;
                                        view! {
                                            // For a range, the min/max are mandatory
                                            label(class = "custom-input") {
                                                input(bind:value = input_value, type = "range", min = min, max = max, value = default, id = id, placeholder = "") {}
                                                span(class = label_class) { (label) }
                                            }
                                            ((*err_label.get()).clone())
                                        }
                                    },
                                    InputType::Boolean { .. } => {
                                        let checked = Signal::new(
                                            match default.as_str() {
                                                "true" => true,
                                                // Anything other than `true` is treated as `false`
                                                // Additionally, we directly normalize it as such (in case the user leaves it as false)
                                                _ => {
                                                    input_value.set("false".to_string());
                                                    false
                                                }
                                            }
                                        );
                                        // Based on that boolean state, we update the global string state
                                        create_effect(cloned!(input_value, checked => move || {
                                            let bool_val = *checked.get();
                                            input_value.set(bool_val.to_string());
                                        }));
                                        view! {
                                            label(class = "switch") {
                                                span(class = label_class) { (label) }
                                                // We need to move the keyboard accessibility from the `input` to the `span` that will actually hold the switch
                                                input(type = "checkbox", bind:checked = checked.clone(), id = id, tabindex = "-1") {}
                                                span(role = "checkButton", tabindex = "0", on:keydown = cloned!(checked => move |ev: web_sys::Event| {
                                                    let ev: web_sys::KeyboardEvent = ev.unchecked_into();
                                                    // If this is the Enter key, we should toggle the state
                                                    if ev.key_code() == 13 {
                                                        checked.set(!*checked.get());
                                                    }
                                                })) {}
                                            }
                                            ((*err_label.get()).clone())
                                        }
                                   }
                                    _ => {
                                        let input_type = input_type.to_string();
                                        view! {
                                            label(class = "custom-input") {
                                                input(bind:value = input_value, type = input_type, value = default, id = id, placeholder = "") {}
                                                span(class = label_class) { (label) }
                                            }
                                            ((*err_label.get()).clone())
                                        }
                                    }
                                }
                            },
                            Input::Select { options, can_select_multiple } => {
                                let default_opts: Vec<&str> = default.split(", ").collect();
                                let opts_rendered = View::new_fragment(
                                    options
                                        .iter()
                                        .map(|opt| match opt {
                                            SelectOption::Simple(text) => {
                                                let text = text.to_string();
                                                let value = text.clone();
                                                let is_selected = default_opts.contains(&value.as_str());
                                                view! {
                                                    option(value = value, selected = is_selected) { (text) }
                                                }
                                            },
                                            SelectOption::WithTags { text, .. } => {
                                                let text = text.to_string();
                                                let value = text.clone();
                                                let is_selected = default_opts.contains(&value.as_str());
                                                view! {
                                                    option(value = value, selected = is_selected) { (text) }
                                                }
                                            }
                                        })
                                        .collect()
                                );

                                let multi_select_handler = cloned!(input_value => move |ev: web_sys::Event| {
                                    let el: web_sys::HtmlSelectElement = ev.target().unwrap().unchecked_into();
                                    let selected_opts = el.selected_options();
                                    let selected_opts = js_sys::Array::from(&selected_opts).to_vec(); // An `HtmlCollection` will always be iterable
                                    let values: Vec<String> = selected_opts.iter().map(|opt| opt.clone().unchecked_into::<HtmlOptionElement>().value()).collect();
                                    // We convert the vector into a comma-delimited list, which will be interpolated if necessary
                                    let values_list = values.join(", ");
                                    input_value.set(values_list);
                                });

                                // This is only used for single selects
                                // If we don't have a default set, we should show a placeholder
                                let show_placeholder = Signal::new(default.is_empty());
                                let show_placeholder_class = create_memo(cloned!(show_placeholder => move || {
                                    if *show_placeholder.get() {
                                        "".to_string()
                                    } else {
                                        "no-placeholder".to_string()
                                    }
                                }));

                                match can_select_multiple {
                                    true => view! {
                                        label(class = "custom-input") {
                                            div(class = "select-wrapper select-multiple") {
                                                select(on:input = multi_select_handler, multiple = true) {
                                                    (opts_rendered)
                                                }
                                            }
                                            span(class = label_class) { (label) }
                                        }
                                        ((*err_label.get()).clone())
                                    },
                                    false => view! {
                                        label(class = "custom-input") {
                                            div(class = format!(
                                                "select-wrapper {}",
                                                show_placeholder_class.get()
                                            )) {
                                                // When this is changed in any way, we make sure the placeholder is no longer shown (you can't go back to the empty option)
                                                select(bind:value = input_value, on:change = cloned!(show_placeholder => move |_| {
                                                    show_placeholder.set(false);
                                                })) {
                                                    // If we don't have a blank (for i18n) default option, the user would have to select another option and then reselect whatever the browser makes the default to select it (not good UX!)
                                                    (if default.is_empty() {
                                                        view! {
                                                            option(value = "", selected = true, disabled = true) { "" }
                                                        }
                                                    } else {
                                                        View::empty()
                                                    })
                                                    (opts_rendered)
                                                }
                                            }
                                            span(class = label_class) { (label) }
                                        }
                                        ((*err_label.get()).clone())
                                    }
                                }
                            },
                        };
                        view! {
                            div(class = "w-full text-left") {
                                (input_rendered)
                            }
                        }
                    }
                };
                // We wrap that because there should be space between the elements in a section
                view! {
                    div(class = "my-2") {
                        (rendered)
                    }
                }
            }))
            .collect()
    );

    view! { (elems) }
}

struct RenderReportEndpointProps {
    preamble: String,
    text: String,
    dest_text: String,
    dest_url: String,
}

/// Renders a report endpoint.
#[component(RenderReportEndpoint<G>)]
fn render_report_endpoint(
    RenderReportEndpointProps {
        preamble,
        text,
        dest_text,
        dest_url,
    }: RenderReportEndpointProps,
) -> View<G> {
    let ctx = use_context::<WorkflowCtx>();
    let preamble = parse_md_to_html(&preamble);
    // Flatten the tags into one single vector
    let mut flattened_tags: Vec<String> = Vec::new();
    let history = ctx.history.get();
    for SectionResult { tags, .. } in history.iter() {
        flattened_tags.extend(tags.clone());
    }
    // Join the tags together with commas (the user doesn't need to see these, they'll be parsed by the Tribble bot)
    let tags_str = flattened_tags.join(",");
    // We now encode that internal data with base64
    let encoded_tags = base64::encode(tags_str);

    // Interpolate form values into the text
    // Except in very specific cases, it's faster to do this by simply trying to interpolate all form values
    let mut interpolated_text = text;
    let form_values = ctx.form_values.get();
    for (id, value) in form_values.iter() {
        interpolated_text = interpolated_text.replace(&format!("${{{}}}", id), &value.get());
    }
    // Now collate everything together in one convenient block
    // We hide the tags away in internal details
    // WARNING: If anything ever changes here, we need to update `getRequestedLabels` in the bot
    let report_text = format!(
        "{}\n\n<details>\n<summary>Tribble internal data</summary>\n\n{}\n\n</details>",
        interpolated_text, encoded_tags
    );
    // Interpolate that into the destination URL if needed
    let dest_url = dest_url.replace("%s", &urlencoding::encode(&report_text));

    let copy_handler = cloned!(report_text => move |_| {
        wasm_bindgen_futures::spawn_local(cloned!(report_text => async move {
            // Write the text to the clipboard
            let window = web_sys::window().unwrap();
            let clipboard = window.navigator().clipboard().unwrap();
            // We want to copy the tags as well
            let promise = clipboard.write_text(&report_text);
            let fut = wasm_bindgen_futures::JsFuture::from(promise);
            match fut.await {
                Ok(_) => (),
                Err(_) => todo!("handle errors in pages")
            }
        }));
    });

    view! {
        div(class = "markdown mb-2", dangerously_set_inner_html = &preamble) {}
        // The report itself is preformatted
        pre(class = "group overflow-x-auto break-words whitespace-pre-wrap bg-neutral-200 dark:bg-neutral-700 rounded-lg p-4 mb-1 shadow-lg dark:shadow-xl", tabindex = "0") {
            div(class = "relative") {
                button(
                    on:click = copy_handler,
                    class = "absolute top-0 right-0 mt-1 mr-1 rounded-md invisible focus:visible group-focus:visible group-hover:visible bg-neutral-200 dark:bg-neutral-700 border border-neutral-100 hover:border-neutral-200 hover:bg-neutral-300 dark:hover:bg-neutral-600 dark:border-neutral-600 dark:hover:border-neutral-600 text-black dark:text-neutral-100 transition-all duration-200 opacity-0 group-focus:opacity-100 focus:opacity-100 group-hover:opacity-100 shadow-md hover:shadow-lg"
                ) {
                    (svg!(r#"<svg aria-hiden=true xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 p-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>"#))
                }
            }
            code(id = "tribble-report") {
                (report_text)
            }
        }
        // This lets the user go to an external URL for reporting their issue
        a(
            href = dest_url,
            // Even if it's internal to the site, this should never be handled by the router
            // Tribble is a separate system, so unless it's been plugin-augmented, this will always be outside our control
            rel = "external",
            class = "group inline-flex items-center p-5 text-lg shadow-md hover:shadow-lg dark:shadow-lg dark:hover:shadow-xl transition-shadow duration-200 rounded-lg"
        ) {
            (dest_text)
            div(class = "h-5 w-5 group-hover:ml-1 transition-all ease-in-out duration-200") {
                (svg!(r#"<svg aria-hidden=true xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>"#))
            }
        }
    }
}

/// A navigational breadcrumbs element that allows the user to step through their progress.
#[component(HistoryBreadcrumbs<G>)]
fn history_breadcrumbs() -> View<G> {
    let ctx = use_context::<WorkflowCtx>();

    // This state is derived from the history, but it could equally be derived from the history position (notably though, the history is updated after the history position)
    let sections_list = create_memo(cloned!(ctx => move || {
        let history = ctx.history.get();
        let history_len = history.len() - 1; // For borrowing issues

        // We should only display the breadcrumbs if there's more than one element (otherwise it's just a random word)
        if history_len > 0 {
            View::new_fragment(
                history
                    .iter()
                    .enumerate()
                // The index in this corresponds to a position in the `history` vector
                    .map(|(i, SectionResult { name, .. })| {
                        let display_name = if name.starts_with("endpoint:") {
                            name.strip_prefix("endpoint:").unwrap()
                        } else {
                            name
                        }.to_string();
                        let history_pos = *ctx.history_pos.get();
                        let name = name.to_string();
                        let click_handler = cloned!(ctx, name => move |_| {
                            // Update the history position (we might be going forwards, or backwards, either way we preserve the rest)
                            ctx.history_pos.set(i);
                            // Update the location to be this section (again, it can't be an endpoint)
                            ctx.loc.set(name.clone());
                        });

                        // If this is the current item, it shouldn't be a link
                        let item_contents = if i == history_pos {
                            view! {
                                div(class = "p-0.5 xs:p-1 rounded-md") { (display_name) }
                            }
                        } else {
                            view! {
                                button(
                                    on:click = click_handler,
                                    class = "p-0.5 xs:p-1 text-neutral-500 dark:text-neutral-400 hover:text-black dark:hover:text-white transition-colors duration-200 rounded-md text-left",
                                ) { (display_name) }
                            }
                        };
                        view! {
                            li(class = "mx-1 text-left") {
                                (item_contents)
                            }
                            // If this isn't the last element, add a separator
                            (if i == history_len {
                                View::empty()
                            } else {
                                view! {
                                    div(class = "font-black text-primary dark:text-light h-4 w-4 mt-[0.07rem]") {
                                        (svg!(r#"<svg aria-hidden=true xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>"#))
                                    }
                                }
                            })
                        }
                    })
                    .collect()
            )
        } else {
            View::empty()
        }
    }));

    view! {
        // BUG This can be ugly with very long section names, it's be nicer if all the text just flowed fully
        nav(aria-live = "assertive") {
            // We center vertically so that the text separators stay in line with the button padding
            ol(class = "w-max flex flex-wrap items-center text-sm text-left p-[0.65rem] rounded-lg shadow-md dark:shadow-lg") {
                (*sections_list.get())
            }
        }
    }
}