MoonBit × Astro

Astrobit Demo

Reactive UI components compiled from MoonBit to JS, integrated seamlessly with Astro.

#

Counter

Reactive state with signals — SSR + hydration

client:load
0

Todo List

Add, complete, and remove tasks — client only

client:only
///|
fn counter(props : @dom.Props) -> @a.Node {
  let initial = props.get_int("initial")
  let count = @signals.signal(initial)
  @a.div(attrs={ "class": "counter-widget" }, [
    @a.div(attrs={ "class": "counter-display" }, [
      @a.span(
        attrs={ "class": "counter-value" },
        @a.dyn_text(fn() { count.get().to_string() }),
      ),
    ]),
    @a.div(attrs={ "class": "counter-controls" }, [
      @a.button(attrs={ "class": "counter-btn btn-dec" }, "−")
      |> @a.on_click(fn(_) { count.update(fn(n) { n - 1 }) }),
      @a.button(attrs={ "class": "counter-btn btn-reset" }, "Reset")
      |> @a.on_click(fn(_) { count.set(initial) }),
      @a.button(attrs={ "class": "counter-btn btn-inc" }, "+")
      |> @a.on_click(fn(_) { count.update(fn(n) { n + 1 }) }),
    ]),
  ])
}

///|
pub fn mount(element : @dom.Element, props : @dom.Props) -> Unit {
  @a.mount_dom(element, counter(props))
}

///|
pub fn render(props : @dom.Props) -> String {
  @a.render_to_html(counter(props))
}

///|
pub fn hydrate(element : @dom.Element, props : @dom.Props) -> Unit {
  @a.hydrate_dom(element, counter(props))
}
///|
pub extern "js" fn set_element_value(el : @dom.Element, value : String) -> Unit =
  #|(el, v) => { el.value = v; }

priv struct Todo {
  text : String
  done : Bool
}

///|
fn todos(_props : @dom.Props) -> @a.Node {
  let initial_todos : Array[Todo] = [
    { text: "Learn MoonBit", done: true },
    { text: "Build with Astrobit", done: false },
    { text: "Ship it!", done: false },
  ]
  let todo_list = @signals.signal(initial_todos)
  let input_text = @signals.signal("")
  @a.div(attrs={ "class": "todo-widget" }, [
    @a.dyn(fn() {
      let list = todo_list.get()
      if list.length() == 0 {
        @a.p(attrs={ "class": "todo-empty" }, "No tasks yet. Add one below!")
      } else {
        @a.ul(
          attrs={ "class": "todo-list" },
          list.mapi(fn(i, todo) {
            @a.li(
              attrs={
                "class": if todo.done { "todo-item todo-done" } else { "todo-item" },
              },
              [
                @a.button(
                  attrs={
                    "class": if todo.done { "todo-toggle checked" } else { "todo-toggle" },
                  },
                  if todo.done { "✓" } else { "" },
                )
                |> @a.on_click(fn(_) {
                  let new_list = todo_list.get().mapi(fn(j, t) {
                    if j == i { { text: t.text, done: !t.done } } else { t }
                  })
                  todo_list.set(new_list)
                }),
                @a.span(attrs={ "class": "todo-text" }, todo.text),
                @a.button(attrs={ "class": "todo-delete" }, "×")
                |> @a.on_click(fn(_) {
                  let new_list = todo_list.get().copy()
                  let _ = new_list.remove(i)
                  todo_list.set(new_list)
                }),
              ],
            )
          }),
        )
      }
    }),
    @a.div(attrs={ "class": "todo-footer" }, [
      @a.dyn_text(fn() {
        let list = todo_list.get()
        let done_count = list.fold(
          init=0,
          fn(acc, t) { if t.done { acc + 1 } else { acc } },
        )
        done_count.to_string() + " / " + list.length().to_string() + " completed"
      }),
    ]),
    @a.form(attrs={ "class": "todo-form" }, [
      @a.input(
        attrs={
          "type": "text",
          "id": "todo-input",
          "class": "todo-input",
          "placeholder": "Add a new task...",
        },
      )
      |> @a.on_input(fn(e) { input_text.set(@dom.target_value(e)) }),
      @a.button(attrs={ "type": "submit", "class": "todo-add-btn" }, "Add"),
    ])
    |> @a.on_submit(fn(e) {
      @dom.prevent_default(e)
      let text = input_text.get()
      if text != "" {
        let list = todo_list.get().copy()
        list.push({ text: text, done: false })
        todo_list.set(list)
        input_text.set("")
        match @dom.query_selector("#todo-input") {
          Some(el) => set_element_value(el, "")
          None => ()
        }
      }
    }),
  ])
}

///|
pub fn mount(element : @dom.Element, props : @dom.Props) -> Unit {
  @a.mount_dom(element, todos(props))
}

///|
pub fn render(props : @dom.Props) -> String {
  @a.render_to_html(todos(props))
}

///|
pub fn hydrate(element : @dom.Element, props : @dom.Props) -> Unit {
  @a.hydrate_dom(element, todos(props))
}