nightshade-api 0.53.0

Procedural high level API for the nightshade game engine
Documentation
use leptos::html;
use leptos::portal::Portal;
use leptos::prelude::*;
use wasm_bindgen::{JsCast, JsValue};
use web_sys::{HtmlDivElement, HtmlElement, KeyboardEvent};

/// Renders `children` into a `<Portal>` so they escape the normal DOM flow and
/// stack above the rest of the app.
#[component]
pub fn Overlay(children: ChildrenFn) -> impl IntoView {
    let children = StoredValue::new(children);
    view! { <Portal>{move || children.with_value(|render| render())}</Portal> }
}

/// A full-screen backdrop behind overlay content. Clicking it runs the optional
/// `on_dismiss` callback.
#[component]
pub fn Scrim(
    #[prop(optional)] on_dismiss: Option<Callback<()>>,
    children: Children,
) -> impl IntoView {
    let handle = move |_event: web_sys::MouseEvent| {
        if let Some(callback) = on_dismiss {
            callback.run(());
        }
    };
    view! {
        <div class="nightshade-scrim" on:click=handle>
            {children()}
        </div>
    }
}

const FOCUSABLE_SELECTOR: &str = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";

/// A portalled dialog shown while `open` is true. It traps Tab focus within the
/// dialog, closes on Escape or backdrop click, and restores focus to the
/// previously focused element when dismissed.
#[component]
pub fn Modal(open: RwSignal<bool>, children: ChildrenFn) -> impl IntoView {
    let dialog_ref = NodeRef::<html::Div>::new();
    let previously_focused = StoredValue::new_local(None::<HtmlElement>);
    let children = StoredValue::new(children);

    Effect::new(move |_| {
        if open.get() {
            previously_focused.set_value(active_element());
            if let Some(dialog) = dialog_ref.get() {
                let _ = dialog.focus();
            }
        } else if let Some(element) = previously_focused.get_value() {
            let _ = element.focus();
            previously_focused.set_value(None);
        }
    });

    on_cleanup(move || {
        if let Some(element) = previously_focused.get_value() {
            let _ = element.focus();
        }
    });

    let on_keydown = move |event: KeyboardEvent| match event.key().as_str() {
        "Escape" => {
            event.prevent_default();
            open.set(false);
        }
        "Tab" => {
            if let Some(dialog) = dialog_ref.get() {
                trap_tab(&dialog, &event);
            }
        }
        _ => {}
    };

    view! {
        <Show when=move || open.get() fallback=|| ()>
            <Overlay>
                <div class="nightshade-scrim" on:click=move |_| open.set(false)>
                    <div
                        node_ref=dialog_ref
                        class="nightshade-modal"
                        role="dialog"
                        aria-modal="true"
                        tabindex="-1"
                        on:click=|event| event.stop_propagation()
                        on:keydown=on_keydown
                    >
                        {children.with_value(|render| render())}
                    </div>
                </div>
            </Overlay>
        </Show>
    }
}

fn active_element() -> Option<HtmlElement> {
    web_sys::window()?
        .document()?
        .active_element()
        .and_then(|element| element.dyn_into::<HtmlElement>().ok())
}

fn trap_tab(dialog: &HtmlDivElement, event: &KeyboardEvent) {
    let Ok(list) = dialog.query_selector_all(FOCUSABLE_SELECTOR) else {
        return;
    };
    let focusable = (0..list.length())
        .filter_map(|index| list.item(index))
        .filter_map(|node| node.dyn_into::<HtmlElement>().ok())
        .collect::<Vec<_>>();
    let (Some(first), Some(last)) = (focusable.first(), focusable.last()) else {
        return;
    };
    let active = active_element();
    if event.shift_key() {
        if active
            .as_ref()
            .is_some_and(|element| same_element(element, first))
        {
            event.prevent_default();
            let _ = last.focus();
        }
    } else if active
        .as_ref()
        .is_some_and(|element| same_element(element, last))
    {
        event.prevent_default();
        let _ = first.focus();
    }
}

fn same_element(left: &HtmlElement, right: &HtmlElement) -> bool {
    let left: &JsValue = left.as_ref();
    let right: &JsValue = right.as_ref();
    left == right
}