diff --git a/examples/basic/.perseus/src/lib.rs b/examples/basic/.perseus/src/lib.rs index 1f38468e08..9ec2b730d7 100644 --- a/examples/basic/.perseus/src/lib.rs +++ b/examples/basic/.perseus/src/lib.rs @@ -11,14 +11,31 @@ use perseus::{ }, plugins::PluginAction, state::{FrozenApp, GlobalState, PageStateStore, ThawPrefs}, - templates::{RouterState, TemplateNodeType}, + templates::{RouterLoadState, RouterState, TemplateNodeType}, DomNode, }; use std::cell::RefCell; use std::rc::Rc; -use sycamore::prelude::{cloned, create_effect, view, NodeRef, ReadSignal}; +use sycamore::prelude::{cloned, create_effect, view, NodeRef, ReadSignal, Signal}; use sycamore_router::{HistoryIntegration, Router, RouterProps}; use wasm_bindgen::{prelude::wasm_bindgen, JsValue}; +use web_sys::Element; + +// We don't want to bring in a styling library, so we do this the old-fashioned way! +// We're particualrly comprehensive with these because the user could *potentially* stuff things up with global rules +// https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe +const ROUTE_ANNOUNCER_STYLES: &str = r#" + margin: 0; + padding: 0; + border: 0; + clip: rect(0 0 0 0); + height: 1px; + width: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + word-wrap: normal; +"#; /// The entrypoint into the app itself. This will be compiled to Wasm and actually executed, rendering the rest of the app. #[wasm_bindgen] @@ -87,6 +104,56 @@ pub fn run() -> Result<(), JsValue> { // Put the locales into an `Rc` so we can use them in locale detection (which is inside a future) let locales = Rc::new(locales); + // Create a derived state for the route announcement + // We do this with an effect because we only want to update in some cases (when the new page is actually loaded) + // We also need to know if it's the first page (because we don't want to announce that, screen readers will get that one right) + let route_announcement = Signal::new(String::new()); + let mut is_first_page = true; + create_effect( + cloned!(route_announcement, router_state => move || if let RouterLoadState::Loaded { path, .. } = &*router_state.get_load_state().get() { + if is_first_page { + // This is the first load event, so the next one will be for a new page (or at least something that we should announce, if this page reloads then the content will change, that would be from thawing) + is_first_page = false; + } else { + // TODO Validate approach with reloading + // A new page has just been loaded and is interactive (this event only fires after all rendering and hydration is complete) + // Set the announcer to announce the title, falling back to the first `h1`, and then falling back again to the path + let document = web_sys::window().unwrap().document().unwrap(); + // If the content of the provided element is empty, this will transform it into `None` + let make_empty_none = |val: Element| { + let val = val.inner_html(); + if val.is_empty() { + None + } else { + Some(val) + } + }; + let title = document + .query_selector("title") + .unwrap() + .map(make_empty_none) + .flatten(); + let announcement = match title { + Some(title) => title, + None => { + let first_h1 = document + .query_selector("h1") + .unwrap() + .map(make_empty_none) + .flatten(); + match first_h1 { + Some(val) => val, + // Our final fallback will be the path + None => path.to_string() + } + } + }; + + route_announcement.set(announcement); + } + }), + ); + sycamore::render_to( move || { view! { @@ -160,7 +227,10 @@ pub fn run() -> Result<(), JsValue> { // However, the server has already rendered initial load content elsewhere, so we move that into here as well in the app shell // The main reason for this is that the router only intercepts click events from its children view! { - div(id="__perseus_content_rx", class="__perseus_content", ref=container_rx) {} + div { + div(id="__perseus_content_rx", class="__perseus_content", ref=container_rx) {} + p(id = "__perseus_route_announcer", aria_live = "assertive", role = "alert", style = ROUTE_ANNOUNCER_STYLES) { (route_announcement.get()) } + } } })) } diff --git a/packages/perseus/src/router.rs b/packages/perseus/src/router.rs index c55c999abf..45ca9ea976 100644 --- a/packages/perseus/src/router.rs +++ b/packages/perseus/src/router.rs @@ -310,7 +310,7 @@ impl Default for RouterState { } } impl RouterState { - /// Gets the load state of the router. + /// Gets the load state of the router. You'll still need to call `.get()` after this (this just returns a `ReadSignal` to derive other state from in a `create_memo` or the like). pub fn get_load_state(&self) -> ReadSignal { self.load_state.handle() }