use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{Document, Element, HtmlInputElement, HtmlTextAreaElement, Storage, Window};
pub(crate) fn window() -> Result<Window, JsValue> {
web_sys::window().ok_or_else(|| JsValue::from_str("no window — wrong execution context"))
}
pub(crate) fn document() -> Result<Document, JsValue> {
window()?
.document()
.ok_or_else(|| JsValue::from_str("no document — wrong execution context"))
}
pub(crate) fn session_storage() -> Result<Option<Storage>, JsValue> {
window()?.session_storage()
}
pub(crate) fn by_id(id: &str) -> Option<Element> {
document().ok()?.get_element_by_id(id)
}
pub(crate) fn input_by_id(id: &str) -> Option<HtmlInputElement> {
by_id(id)?.dyn_into::<HtmlInputElement>().ok()
}
pub(crate) fn textarea_by_id(id: &str) -> Option<HtmlTextAreaElement> {
by_id(id)?.dyn_into::<HtmlTextAreaElement>().ok()
}
#[derive(Clone, Copy)]
pub(crate) enum Msg {
Error,
Muted,
Accent,
}
impl Msg {
fn css_var(self) -> &'static str {
match self {
Msg::Error => "--error",
Msg::Muted => "--muted",
Msg::Accent => "--accent",
}
}
}
pub(crate) fn msg_span(kind: Msg, text: &str) -> String {
let style = format!("color:var({})", kind.css_var());
maud::html! { span style=(style) { (text) } }.into_string()
}
pub(crate) fn swap_inner(id: &str, html: &str) {
if let Some(el) = by_id(id) {
el.set_inner_html(html);
}
}
pub(crate) fn swap_outer(id: &str, html: &str) {
if let Some(el) = by_id(id) {
el.set_outer_html(html);
}
}
thread_local! {
static FOCUS_RETURN: RefCell<Vec<Option<Element>>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn remember_focus() {
if let Ok(doc) = document() {
FOCUS_RETURN.with(|c| c.borrow_mut().push(doc.active_element()));
}
}
pub(crate) fn restore_focus() {
FOCUS_RETURN.with(|c| {
if let Some(Some(el)) = c.borrow_mut().pop() {
if let Some(h) = el.dyn_ref::<web_sys::HtmlElement>() {
let _ = h.focus();
}
}
});
}
const FOCUSABLE_SEL: &str =
"button:not([disabled]), a[href], input:not([type=hidden]):not([disabled]), \
textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex='-1'])";
pub(crate) fn focus_first_in(container_id: &str) {
let Some(c) = by_id(container_id) else { return };
let Ok(list) = c.query_selector_all(FOCUSABLE_SEL) else { return };
for i in 0..list.length() {
if let Some(h) = list.get(i).and_then(|n| n.dyn_into::<web_sys::HtmlElement>().ok()) {
if h.offset_parent().is_some() {
let _ = h.focus();
return;
}
}
}
}
pub(crate) fn open_modal_trap() -> Option<String> {
let el = document().ok()?.query_selector("[data-modal-trap]").ok()??;
let id = el.id();
if id.is_empty() { None } else { Some(id) }
}
pub(crate) fn trap_tab_in(container_id: &str, shift: bool) -> bool {
let Some(c) = by_id(container_id) else { return false };
let Ok(list) = c.query_selector_all(FOCUSABLE_SEL) else { return false };
let mut items: Vec<web_sys::HtmlElement> = Vec::new();
for i in 0..list.length() {
if let Some(h) = list.get(i).and_then(|n| n.dyn_into::<web_sys::HtmlElement>().ok()) {
if h.offset_parent().is_some() {
items.push(h);
}
}
}
let Some(first) = items.first() else { return false };
let Some(last) = items.last() else { return false };
let active = document().ok().and_then(|d| d.active_element());
let active_in_panel = active
.as_ref()
.and_then(|a| a.closest("[data-modal-trap]").ok().flatten())
.map(|m| m.id() == container_id)
.unwrap_or(false);
if !active_in_panel {
let target = if shift { last } else { first };
let _ = target.focus();
return true;
}
let first_el: &Element = first.as_ref();
let last_el: &Element = last.as_ref();
let on_first = active.as_ref().map(|a| a == first_el).unwrap_or(false);
let on_last = active.as_ref().map(|a| a == last_el).unwrap_or(false);
if shift && on_first {
let _ = last.focus();
true
} else if !shift && on_last {
let _ = first.focus();
true
} else {
false
}
}
pub(crate) fn append_html(id: &str, html: &str) {
if let Some(el) = by_id(id) {
let _ = el.insert_adjacent_html("beforeend", html);
}
}
pub(crate) fn remove(id: &str) {
if let Some(el) = by_id(id) {
el.remove();
}
}
pub(crate) fn scroll_to_bottom(id: &str) {
if let Some(el) = by_id(id) {
el.set_scroll_top(el.scroll_height());
}
}
pub(crate) fn scroll_to_bottom_soon(id: &str) {
scroll_to_bottom(id);
let Ok(win) = window() else { return };
for delay in [60, 350] {
let id = id.to_string();
let cb = Closure::once_into_js(move || scroll_to_bottom(&id));
let _ = win.set_timeout_with_callback_and_timeout_and_arguments_0(
cb.unchecked_ref(),
delay,
);
}
}
pub(crate) fn mark_ready() {
if let Ok(doc) = document() {
if let Some(el) = doc.document_element() {
let _ = el.set_attribute("data-lh-ready", "1");
}
}
}
pub(crate) fn set_status(message: &str, is_error: bool) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
};
if let Some(el) = doc.get_element_by_id("system-status") {
el.remove();
}
if message.is_empty() {
return;
}
let Some(transcript) = doc.get_element_by_id("transcript") else {
return;
};
let cls = if is_error { "system-status err" } else { "system-status" };
let _ = transcript.insert_adjacent_html(
"beforeend",
&format!(
"<div id=\"system-status\" class=\"{cls}\">{}</div>",
html_escape(message)
),
);
scroll_to_bottom("transcript");
}
pub(crate) fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod html_escape_tests {
use super::html_escape;
#[test]
fn escapes_all_five_html_significant_chars() {
assert_eq!(
html_escape(r#"<>&"'"#),
"<>&"'"
);
}
#[test]
fn ampersand_is_escaped_first_so_entities_are_not_double_escaped() {
assert_eq!(html_escape("<"), "<");
assert_eq!(html_escape("&"), "&amp;");
}
#[test]
fn neutralizes_a_script_injection_attempt() {
let evil = r#"</span><img src=x onerror="alert(document.cookie)">"#;
let out = html_escape(evil);
assert!(!out.contains("<img"), "live <img> leaked: {out}");
assert!(!out.contains("</span>"), "live tag leaked: {out}");
assert!(out.contains("<img"), "img not escaped: {out}");
assert!(out.contains("""), "attribute quote not escaped: {out}");
}
#[test]
fn leaves_plain_text_untouched() {
assert_eq!(html_escape("redeem failed: node down (502)"), "redeem failed: node down (502)");
}
}