AkurAI Build
Menu

Public repository / d7f31233a0aaba65d741a85ac631a0e9004dba49

AkurAI-Framework / crates/ui/src/dom.rs

Latest change 3aae9f4e85241bcb775c31f5c03094becd3da56a - Pure-Rust UI layer: akurai-ui, akurai-crypto, Tailwind-grade akurai-css by Ólafur Búi Ólafsson

//! Views, elements and rendering.
//!
//! Components are plain functions returning something [`IntoView`] — an
//! [`El`] built with the chainable element API, text, a tuple or `Vec` of
//! views, or a closure. A closure is a *dynamic region*: it re-renders, in
//! place between two comment markers, whenever a signal it reads changes.
//!
//! ```
//! use akurai_ui::prelude::*;
//!
//! fn counter() -> impl IntoView {
//!     let count = signal(0);
//!     div().class("flex gap-2").child((
//!         button().on_click(move |_| count.update(|c| *c += 1)).child("+1"),
//!         span().child(move || count.get().to_string()),
//!     ))
//! }
//! # let _ = counter;
//! ```

use crate::callbacks;
use crate::host::{self, EventRef, Node};
use crate::reactive::{
    child_scope, current_scope, effect, memo, on_cleanup, signal, untrack, Scope, Signal,
};
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::hash::Hash;
use std::rc::Rc;

type MountFn = Box<dyn FnOnce(Node, Option<Node>)>;

/// Something that can be mounted into the DOM.
pub enum View {
    Empty,
    Node(Node),
    Many(Vec<View>),
    /// Custom mount logic: `(parent, insert_before)`.
    Mount(MountFn),
}

/// Conversion into a [`View`] — the React "renderable" idea.
pub trait IntoView {
    fn into_view(self) -> View;
}

impl IntoView for View {
    fn into_view(self) -> View {
        self
    }
}
impl IntoView for () {
    fn into_view(self) -> View {
        View::Empty
    }
}
impl IntoView for &str {
    fn into_view(self) -> View {
        View::Node(host::create_text(self))
    }
}
impl IntoView for String {
    fn into_view(self) -> View {
        self.as_str().into_view()
    }
}
impl IntoView for &String {
    fn into_view(self) -> View {
        self.as_str().into_view()
    }
}
impl<V: IntoView> IntoView for Option<V> {
    fn into_view(self) -> View {
        self.map_or(View::Empty, IntoView::into_view)
    }
}
impl<V: IntoView> IntoView for Vec<V> {
    fn into_view(self) -> View {
        View::Many(self.into_iter().map(IntoView::into_view).collect())
    }
}
impl<F, V> IntoView for F
where
    F: Fn() -> V + 'static,
    V: IntoView,
{
    fn into_view(self) -> View {
        dynamic(move || self().into_view())
    }
}

macro_rules! tuple_into_view {
    ($($name:ident),+) => {
        impl<$($name: IntoView),+> IntoView for ($($name,)+) {
            #[allow(non_snake_case)]
            fn into_view(self) -> View {
                let ($($name,)+) = self;
                View::Many(vec![$($name.into_view()),+])
            }
        }
    };
}
tuple_into_view!(A);
tuple_into_view!(A, B);
tuple_into_view!(A, B, C);
tuple_into_view!(A, B, C, D);
tuple_into_view!(A, B, C, D, E);
tuple_into_view!(A, B, C, D, E, G);
tuple_into_view!(A, B, C, D, E, G, H);
tuple_into_view!(A, B, C, D, E, G, H, I);

macro_rules! display_into_view {
    ($($t:ty),+) => {$(
        impl IntoView for $t {
            fn into_view(self) -> View {
                self.to_string().into_view()
            }
        }
    )+};
}
display_into_view!(i32, i64, u32, u64, usize, f64);

/// Insert `view` into `parent`, before `before` (or at the end).
pub fn mount(parent: Node, view: View, before: Option<Node>) {
    match view {
        View::Empty => {}
        View::Node(n) => host::insert_before(parent, n, before),
        View::Many(views) => {
            for v in views {
                mount(parent, v, before);
            }
        }
        View::Mount(f) => f(parent, before),
    }
}

fn markers(parent: Node, before: Option<Node>) -> (Node, Node) {
    let start = host::create_comment("");
    let end = host::create_comment("");
    host::insert_before(parent, start, before);
    host::insert_before(parent, end, before);
    (start, end)
}

/// A region that re-renders `render` whenever a signal it reads changes.
pub fn dynamic(render: impl Fn() -> View + 'static) -> View {
    View::Mount(Box::new(move |parent, before| {
        let (start, end) = markers(parent, before);
        effect(move || {
            let view = render();
            host::clear_between(start, end);
            if let Some(p) = host::parent(end) {
                mount(p, view, Some(end));
            }
        });
    }))
}

/// Render `then` while `when` is true, `otherwise` while it is false. The
/// condition is memoised, so the branch only rebuilds when it flips.
pub fn show<V: IntoView, W: IntoView>(
    when: impl Fn() -> bool + 'static,
    then: impl Fn() -> V + 'static,
    otherwise: impl Fn() -> W + 'static,
) -> View {
    let cond = memo(when);
    dynamic(move || {
        if cond.get() {
            untrack(|| then().into_view())
        } else {
            untrack(|| otherwise().into_view())
        }
    })
}

/// Render `then` only while `when` is true.
pub fn when<V: IntoView>(
    when: impl Fn() -> bool + 'static,
    then: impl Fn() -> V + 'static,
) -> View {
    show(when, then, || ())
}

struct Row<T: 'static> {
    item: Signal<T>,
    scope: Scope,
    start: Node,
    end: Node,
}

/// A keyed list. Rows are created once per key and kept (with their DOM and
/// state) across updates; a changed item is pushed into the row's signal.
pub fn each<T, K, V>(
    items: impl Fn() -> Vec<T> + 'static,
    key: impl Fn(&T) -> K + 'static,
    render: impl Fn(Signal<T>) -> V + 'static,
) -> View
where
    T: Clone + PartialEq + 'static,
    K: Eq + Hash + Clone + 'static,
    V: IntoView,
{
    View::Mount(Box::new(move |parent, before| {
        let (_start, end) = markers(parent, before);
        let owner = current_scope();
        let rows: Rc<RefCell<HashMap<K, Row<T>>>> = Rc::new(RefCell::new(HashMap::new()));
        on_cleanup({
            let rows = rows.clone();
            move || {
                rows.borrow_mut()
                    .drain()
                    .for_each(|(_, r)| r.scope.dispose())
            }
        });
        effect(move || {
            let list = items();
            untrack(|| {
                let mut old = std::mem::take(&mut *rows.borrow_mut());
                let mut order = Vec::with_capacity(list.len());
                let mut fresh = HashMap::with_capacity(list.len());
                for item in list {
                    let k = key(&item);
                    if fresh.contains_key(&k) {
                        continue;
                    }
                    let row = match old.remove(&k) {
                        Some(row) => {
                            row.item.set_if_changed(item);
                            row
                        }
                        None => new_row(owner, end, item, &render),
                    };
                    order.push(k.clone());
                    fresh.insert(k, row);
                }
                for (_, gone) in old {
                    host::clear_between(gone.start, gone.end);
                    host::remove(gone.start);
                    host::remove(gone.end);
                    gone.scope.dispose();
                }
                let mut anchor = end;
                for k in order.iter().rev() {
                    let row = &fresh[k];
                    if host::next_sibling(row.end) != Some(anchor) {
                        host::move_range(row.start, row.end, anchor);
                    }
                    anchor = row.start;
                }
                *rows.borrow_mut() = fresh;
            });
        });
    }))
}

fn new_row<T: 'static, V: IntoView>(
    owner: Option<Scope>,
    end: Node,
    item: T,
    render: &impl Fn(Signal<T>) -> V,
) -> Row<T> {
    let build = || {
        child_scope(|| {
            let item = signal(item);
            let parent = host::parent(end).expect("list end marker is attached");
            let (start, row_end) = markers(parent, Some(end));
            mount(parent, render(item).into_view(), Some(row_end));
            (item, start, row_end)
        })
    };
    let ((item, start, end), scope) = match owner {
        Some(o) => o.run(build),
        None => build(),
    };
    Row {
        item,
        scope,
        start,
        end,
    }
}

/// Mount `view` at the end of `<body>` instead of in place (dialogs, toasts).
/// It is removed when the owning scope is disposed.
pub fn portal(view: impl IntoView + 'static) -> View {
    View::Mount(Box::new(move |_, _| {
        let (start, end) = markers(host::BODY, None);
        mount(host::BODY, view.into_view(), Some(end));
        on_cleanup(move || {
            host::clear_between(start, end);
            host::remove(start);
            host::remove(end);
        });
    }))
}

// ── Events ──────────────────────────────────────────────────────────────────

/// The DOM event being handled.
#[derive(Clone, Copy, Debug)]
pub struct Event(pub(crate) EventRef);

impl Event {
    /// `event.target.value` (inputs, selects, textareas).
    pub fn value(&self) -> String {
        host::event_value(self.0)
    }
    /// `event.target.checked`.
    pub fn checked(&self) -> bool {
        host::event_checked(self.0)
    }
    /// `event.key` for keyboard events.
    pub fn key(&self) -> String {
        host::event_key(self.0)
    }
    pub fn shift_key(&self) -> bool {
        host::event_shift(self.0)
    }
    pub fn target(&self) -> Option<Node> {
        host::event_target(self.0)
    }
    pub fn prevent_default(&self) {
        host::prevent_default(self.0)
    }
    pub fn stop_propagation(&self) {
        host::stop_propagation(self.0)
    }
}

/// Wrap `f` so it runs with `owner` as the current scope, giving handlers the
/// same context (`use_context`) as the component that registered them.
pub(crate) fn in_scope<A>(owner: Option<Scope>, mut f: impl FnMut(A)) -> impl FnMut(A) {
    move |a| match owner {
        Some(scope) => scope.run(|| f(a)),
        None => f(a),
    }
}

/// Listen for `event` on `node` for the lifetime of the current scope.
pub fn listen(node: Node, event: &str, handler: impl FnMut(Event) + 'static) {
    let id = callbacks::on_event(in_scope(current_scope(), handler));
    host::listen(node, event, id);
    on_cleanup(move || callbacks::remove(id));
}

// ── Attributes & properties ─────────────────────────────────────────────────

/// A value for an attribute: text, presence (`bool`), optional, or reactive.
pub trait AttrValue {
    fn apply(self, node: Node, name: &'static str);
}

impl AttrValue for &str {
    fn apply(self, node: Node, name: &'static str) {
        host::set_attr(node, name, self)
    }
}
impl AttrValue for String {
    fn apply(self, node: Node, name: &'static str) {
        host::set_attr(node, name, &self)
    }
}
impl AttrValue for &String {
    fn apply(self, node: Node, name: &'static str) {
        host::set_attr(node, name, self)
    }
}
impl AttrValue for bool {
    fn apply(self, node: Node, name: &'static str) {
        if self {
            host::set_attr(node, name, "")
        } else {
            host::remove_attr(node, name)
        }
    }
}
impl<V: AttrValue> AttrValue for Option<V> {
    fn apply(self, node: Node, name: &'static str) {
        match self {
            Some(v) => v.apply(node, name),
            None => host::remove_attr(node, name),
        }
    }
}
impl<F, V> AttrValue for F
where
    F: Fn() -> V + 'static,
    V: AttrValue,
{
    fn apply(self, node: Node, name: &'static str) {
        effect(move || self().apply(node, name));
    }
}

/// A value for a DOM property (`value`, `checked`, `disabled`, …).
pub trait PropValue {
    fn apply(self, node: Node, name: &'static str);
}
impl PropValue for &str {
    fn apply(self, node: Node, name: &'static str) {
        host::set_prop_str(node, name, self)
    }
}
impl PropValue for String {
    fn apply(self, node: Node, name: &'static str) {
        host::set_prop_str(node, name, &self)
    }
}
impl PropValue for bool {
    fn apply(self, node: Node, name: &'static str) {
        host::set_prop_bool(node, name, self)
    }
}
impl<F, V> PropValue for F
where
    F: Fn() -> V + 'static,
    V: PropValue,
{
    fn apply(self, node: Node, name: &'static str) {
        effect(move || self().apply(node, name));
    }
}

/// Captures an element's node for imperative use (focus, measuring).
#[derive(Clone, Default)]
pub struct NodeRef(Rc<Cell<Option<Node>>>);

impl NodeRef {
    pub fn new() -> Self {
        Self::default()
    }
    pub fn get(&self) -> Option<Node> {
        self.0.get()
    }
    pub fn focus(&self) {
        if let Some(n) = self.get() {
            host::focus(n);
        }
    }
}

// ── Elements ────────────────────────────────────────────────────────────────

/// An element under construction. Every method consumes and returns `self`,
/// so markup reads top-down like JSX.
#[must_use = "an element does nothing until it is mounted"]
pub struct El(Node);

/// Create an HTML element.
pub fn el(tag: &str) -> El {
    El(host::create_element(tag))
}

/// Create an SVG element.
pub fn svg_el(tag: &str) -> El {
    El(host::create_element_ns(host::SVG_NS, tag))
}

impl IntoView for El {
    fn into_view(self) -> View {
        View::Node(self.0)
    }
}

impl El {
    pub fn node(&self) -> Node {
        self.0
    }
    pub fn attr(self, name: &'static str, value: impl AttrValue) -> Self {
        value.apply(self.0, name);
        self
    }
    pub fn prop(self, name: &'static str, value: impl PropValue) -> Self {
        value.apply(self.0, name);
        self
    }
    pub fn class(self, value: impl AttrValue) -> Self {
        self.attr("class", value)
    }
    pub fn id(self, value: impl AttrValue) -> Self {
        self.attr("id", value)
    }
    pub fn style(self, value: impl AttrValue) -> Self {
        self.attr("style", value)
    }
    pub fn role(self, value: impl AttrValue) -> Self {
        self.attr("role", value)
    }
    pub fn typ(self, value: impl AttrValue) -> Self {
        self.attr("type", value)
    }
    pub fn child(self, view: impl IntoView) -> Self {
        mount(self.0, view.into_view(), None);
        self
    }
    pub fn children<V: IntoView>(self, views: impl IntoIterator<Item = V>) -> Self {
        for v in views {
            mount(self.0, v.into_view(), None);
        }
        self
    }
    pub fn on(self, event: &str, handler: impl FnMut(Event) + 'static) -> Self {
        listen(self.0, event, handler);
        self
    }
    pub fn on_click(self, handler: impl FnMut(Event) + 'static) -> Self {
        self.on("click", handler)
    }
    /// `input` events, handed the field's current value.
    pub fn on_input(self, mut handler: impl FnMut(String) + 'static) -> Self {
        self.on("input", move |e| handler(e.value()))
    }
    /// `submit` with the browser's default navigation prevented.
    pub fn on_submit(self, mut handler: impl FnMut(Event) + 'static) -> Self {
        self.on("submit", move |e| {
            e.prevent_default();
            handler(e)
        })
    }
    /// Two-way bind a text field to a signal.
    pub fn bind(self, value: Signal<String>) -> Self {
        self.prop("value", move || value.get())
            .on_input(move |v| value.set(v))
    }
    /// Two-way bind a checkbox to a signal.
    pub fn bind_checked(self, checked: Signal<bool>) -> Self {
        self.prop("checked", move || checked.get())
            .on("change", move |e| checked.set(e.checked()))
    }
    pub fn node_ref(self, r: &NodeRef) -> Self {
        r.0.set(Some(self.0));
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::html::*;
    use crate::reactive::create_root;
    use crate::testing;

    #[test]
    fn static_markup_renders() {
        testing::render(|| div().class("a").child((span().child("hi"), "!")));
        assert_eq!(
            testing::body_html(),
            r#"<div class="a"><span>hi</span>!</div>"#
        );
    }

    #[test]
    fn dynamic_text_updates_in_place() {
        let count = testing::render_with(|| {
            let count = signal(1);
            (p().child(move || count.get().to_string()), count)
        });
        assert_eq!(testing::body_html(), "<p>1</p>");
        count.set(2);
        assert_eq!(testing::body_html(), "<p>2</p>");
    }

    #[test]
    fn click_handlers_update_state() {
        testing::render(|| {
            let n = signal(0);
            button()
                .on_click(move |_| n.update(|v| *v += 1))
                .child(move || format!("n={}", n.get()))
        });
        let b = testing::find("button").unwrap();
        testing::click(b);
        testing::click(b);
        assert_eq!(testing::text(b), "n=2");
    }

    #[test]
    fn show_switches_branches_and_disposes_listeners() {
        let open = testing::render_with(|| {
            let open = signal(true);
            (
                div().child(show(
                    move || open.get(),
                    || button().on_click(|_| {}).child("x"),
                    || "closed",
                )),
                open,
            )
        });
        let before = callbacks::live();
        open.set(false);
        assert_eq!(testing::body_html(), "<div>closed</div>");
        assert_eq!(callbacks::live(), before - 1, "button listener released");
    }

    #[test]
    fn keyed_each_preserves_rows_and_reorders() {
        let items = testing::render_with(|| {
            let items = signal(vec![(1, "a"), (2, "b"), (3, "c")]);
            (
                ul().child(each(
                    move || items.get(),
                    |(id, _)| *id,
                    |item| li().child(move || item.get().1),
                )),
                items,
            )
        });
        assert_eq!(
            testing::body_html(),
            "<ul><li>a</li><li>b</li><li>c</li></ul>"
        );
        let first_li = testing::find("li").unwrap();
        items.set(vec![(3, "c"), (1, "A")]);
        assert_eq!(testing::body_html(), "<ul><li>c</li><li>A</li></ul>");
        assert_eq!(testing::find_all("li")[1], first_li, "row 1 kept its node");
    }

    #[test]
    fn bind_is_two_way() {
        let name = testing::render_with(|| {
            let name = signal(String::from("ada"));
            (input().bind(name), name)
        });
        let field = testing::find("input").unwrap();
        assert_eq!(testing::prop(field, "value"), "ada");
        testing::input(field, "grace");
        assert_eq!(name.get_untracked(), "grace");
    }

    #[test]
    fn reactive_attributes_toggle() {
        let on = testing::render_with(|| {
            let on = signal(false);
            (div().attr("data-on", move || on.get()), on)
        });
        assert_eq!(testing::body_html(), "<div></div>");
        on.set(true);
        assert_eq!(testing::body_html(), r#"<div data-on=""></div>"#);
    }

    #[test]
    fn portal_mounts_into_body_and_cleans_up() {
        let (_, scope) = create_root(|| {
            testing::reset();
            mount(
                host::BODY,
                div().child(portal(p().child("over"))).into_view(),
                None,
            );
        });
        assert_eq!(
            testing::body_html(),
            "<p>over</p><div></div>",
            "portals mount when built"
        );
        scope.dispose();
        assert_eq!(testing::body_html(), "<div></div>");
    }
}