use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Element, HtmlCanvasElement, HtmlElement};
use winit::platform::web::WindowExtWebSys;
use winit::window::Window;
use frus_widgets::{Rect, Role, SemanticsProperties, Toggled, WidgetId};
fn aria_role(role: Role) -> &'static str {
match role {
Role::None => "group",
Role::Label => "text",
Role::Heading => "heading",
Role::Button => "button",
Role::Link => "link",
Role::CheckBox => "checkbox",
Role::Switch => "switch",
Role::RadioButton => "radio",
Role::Slider => "slider",
Role::TextInput => "textbox",
Role::Image => "img",
Role::Tab => "tab",
Role::ListItem => "listitem",
Role::ProgressBar => "progressbar",
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum A11yAction {
Click(WidgetId),
Focus(WidgetId),
}
type Actions = Rc<RefCell<Vec<A11yAction>>>;
struct Live {
el: HtmlElement,
clickable: bool,
_click: Option<Closure<dyn FnMut(web_sys::Event)>>,
_keys: Option<Closure<dyn FnMut(web_sys::Event)>>,
_focus: Closure<dyn FnMut(web_sys::Event)>,
}
fn key_listener() -> Closure<dyn FnMut(web_sys::Event)> {
Closure::wrap(Box::new(move |event: web_sys::Event| {
let activates = event
.dyn_ref::<web_sys::KeyboardEvent>()
.is_some_and(|key| matches!(key.key().as_str(), "Enter" | " "));
if activates {
event.stop_propagation();
}
}) as Box<dyn FnMut(web_sys::Event)>)
}
fn click_listener(
id: WidgetId,
actions: Actions,
window: Arc<Window>,
) -> Closure<dyn FnMut(web_sys::Event)> {
Closure::wrap(Box::new(move |_event: web_sys::Event| {
actions.borrow_mut().push(A11yAction::Click(id));
window.request_redraw();
}) as Box<dyn FnMut(web_sys::Event)>)
}
fn focus_listener(
id: WidgetId,
actions: Actions,
programmatic: Rc<Cell<bool>>,
window: Arc<Window>,
) -> Closure<dyn FnMut(web_sys::Event)> {
Closure::wrap(Box::new(move |_event: web_sys::Event| {
if !programmatic.get() {
actions.borrow_mut().push(A11yAction::Focus(id));
window.request_redraw();
}
}) as Box<dyn FnMut(web_sys::Event)>)
}
pub(crate) struct A11y {
canvas: HtmlCanvasElement,
live_region: HtmlElement,
nodes: HashMap<WidgetId, Live>,
actions: Actions,
programmatic_focus: Rc<Cell<bool>>,
focus: Option<WidgetId>,
announce: String,
window: Arc<Window>,
}
impl A11y {
pub(crate) fn new(window: &Arc<Window>) -> Option<Self> {
let canvas = window.canvas()?;
canvas.set_attribute("role", "application").ok();
let live_region = new_live_region(&canvas)?;
Some(Self {
canvas,
live_region,
nodes: HashMap::new(),
actions: Rc::new(RefCell::new(Vec::new())),
programmatic_focus: Rc::new(Cell::new(false)),
focus: None,
announce: String::new(),
window: window.clone(),
})
}
pub(crate) fn update(
&mut self,
nodes: &[(WidgetId, Rect, SemanticsProperties)],
focus: Option<WidgetId>,
title: &str,
announce: &str,
) {
self.canvas.set_attribute("aria-label", title).ok();
let present: std::collections::HashSet<WidgetId> =
nodes.iter().map(|(id, _, _)| *id).collect();
let active = self
.canvas
.owner_document()
.and_then(|document| document.active_element());
let is_active = |el: &HtmlElement| {
active
.as_ref()
.is_some_and(|active| active == AsRef::<web_sys::Element>::as_ref(el))
};
let mut lost_focus = false;
self.nodes.retain(|id, live| {
let keep = present.contains(id);
if !keep {
lost_focus |= is_active(&live.el);
let _ = self.canvas.remove_child(&live.el);
}
keep
});
for (id, _rect, sem) in nodes {
match self.nodes.get(id) {
Some(live) if live.clickable == sem.clickable => {
apply(&live.el, sem);
}
_ => {
if let Some(old) = self.nodes.remove(id) {
lost_focus |= is_active(&old.el);
let _ = self.canvas.remove_child(&old.el);
}
if let Some(live) = self.spawn(*id, sem) {
apply(&live.el, sem);
self.nodes.insert(*id, live);
}
}
}
}
if focus != self.focus || lost_focus {
self.focus = focus;
if let Some(id) = focus {
if let Some(live) = self.nodes.get(&id) {
self.programmatic_focus.set(true);
let _ = live.el.focus();
self.programmatic_focus.set(false);
}
}
}
if announce != self.announce {
self.announce = announce.to_string();
self.live_region.set_text_content(Some(announce));
}
}
fn spawn(&self, id: WidgetId, sem: &SemanticsProperties) -> Option<Live> {
let document = self.canvas.owner_document()?;
let tag = if sem.clickable { "button" } else { "div" };
let el: HtmlElement = document
.create_element(tag)
.ok()?
.dyn_into::<HtmlElement>()
.ok()?;
if sem.clickable {
el.set_attribute("type", "button").ok();
} else {
el.set_attribute("tabindex", "-1").ok();
}
let click = sem.clickable.then(|| {
let closure = click_listener(id, self.actions.clone(), self.window.clone());
let _ = (el.as_ref() as &web_sys::EventTarget)
.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref());
closure
});
let keys = sem.clickable.then(|| {
let closure = key_listener();
let _ = (el.as_ref() as &web_sys::EventTarget)
.add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref());
closure
});
let focus_closure = focus_listener(
id,
self.actions.clone(),
self.programmatic_focus.clone(),
self.window.clone(),
);
let _ = (el.as_ref() as &web_sys::EventTarget)
.add_event_listener_with_callback("focus", focus_closure.as_ref().unchecked_ref());
self.canvas.append_child(&el).ok()?;
Some(Live {
el,
clickable: sem.clickable,
_click: click,
_keys: keys,
_focus: focus_closure,
})
}
pub(crate) fn take_actions(&self) -> Vec<A11yAction> {
std::mem::take(&mut self.actions.borrow_mut())
}
}
fn new_live_region(canvas: &HtmlCanvasElement) -> Option<HtmlElement> {
let document = canvas.owner_document()?;
let body = document.body()?;
let el: HtmlElement = document
.create_element("div")
.ok()?
.dyn_into::<HtmlElement>()
.ok()?;
el.set_attribute("aria-live", "polite").ok();
el.set_attribute("role", "status").ok();
el.set_attribute(
"style",
"position:absolute;width:1px;height:1px;overflow:hidden;\
clip:rect(0,0,0,0);white-space:nowrap;",
)
.ok();
body.append_child(&el).ok()?;
Some(el)
}
fn apply(el: &HtmlElement, sem: &SemanticsProperties) {
let element: &Element = el.as_ref();
element.set_attribute("role", aria_role(sem.role)).ok();
if let Some(label) = &sem.label {
element.set_attribute("aria-label", label).ok();
} else {
element.remove_attribute("aria-label").ok();
}
match sem.toggled {
Toggled::None => {
element.remove_attribute("aria-checked").ok();
}
Toggled::False => {
element.set_attribute("aria-checked", "false").ok();
}
Toggled::True => {
element.set_attribute("aria-checked", "true").ok();
}
Toggled::Mixed => {
element.set_attribute("aria-checked", "mixed").ok();
}
}
if sem.disabled {
element.set_attribute("aria-disabled", "true").ok();
if sem.clickable {
element.set_attribute("disabled", "").ok();
}
} else {
element.remove_attribute("aria-disabled").ok();
if sem.clickable {
element.remove_attribute("disabled").ok();
}
}
if let Some((min, value, max)) = sem.range {
element
.set_attribute("aria-valuemin", &min.to_string())
.ok();
element
.set_attribute("aria-valuenow", &value.to_string())
.ok();
element
.set_attribute("aria-valuemax", &max.to_string())
.ok();
} else {
element.remove_attribute("aria-valuemin").ok();
element.remove_attribute("aria-valuenow").ok();
element.remove_attribute("aria-valuemax").ok();
}
match (&sem.value, sem.role) {
(Some(value), Role::TextInput) => el.set_text_content(Some(value)),
(Some(value), Role::Slider | Role::ProgressBar) => {
element.set_attribute("aria-valuetext", value).ok();
}
(Some(value), _) => {
element.set_attribute("aria-description", value).ok();
}
(None, Role::TextInput) => el.set_text_content(None),
(None, _) => {
element.remove_attribute("aria-description").ok();
element.remove_attribute("aria-valuetext").ok();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roles_map_to_aria() {
assert_eq!(aria_role(Role::Button), "button");
assert_eq!(aria_role(Role::CheckBox), "checkbox");
assert_eq!(aria_role(Role::Slider), "slider");
assert_eq!(aria_role(Role::ProgressBar), "progressbar");
assert_eq!(aria_role(Role::TextInput), "textbox");
}
}