Skip to content

Commit

Permalink
feat(a11y): added route announcer
Browse files Browse the repository at this point in the history
Closes #124.
  • Loading branch information
arctic-hen7 committed Jan 25, 2022
1 parent 193f733 commit 76c0930
Show file tree
Hide file tree
Showing 2 changed files with 74 additions and 4 deletions.
76 changes: 73 additions & 3 deletions examples/basic/.perseus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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! {
Expand Down Expand Up @@ -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()) }
}
}
}))
}
Expand Down
2 changes: 1 addition & 1 deletion packages/perseus/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RouterLoadState> {
self.load_state.handle()
}
Expand Down

0 comments on commit 76c0930

Please sign in to comment.