// Element DSL - Simplified API for creating reactive elements
//
// Usage:
// div(class="myapp", on=events().click(fn(_) { ... }), [text("Hello")])
//
// ## Bundle Size Optimization Notes
//
// This module uses JS FFI extensively to minimize bundle size:
//
// 1. **Double.to_string via JS FFI**: MoonBit's native Double.to_string uses the
// ryu algorithm which pulls in ~31KB of Int64 operations. We use JS's native
// toString() instead.
//
// 2. **HandlerMap as opaque JS object**: Instead of a MoonBit struct with 16 fields,
// we use a plain JS object created via FFI. This avoids generating large
// to_attrs() functions with 16 if-statements.
//
// 3. **Lowercase event names**: Event names are stored as lowercase DOM standard
// names (click, input) instead of React-style camelCase (onClick, onInput).
// This avoids runtime string conversion and prevents future bloat from
// conversion dictionaries.
//
// 4. **Method chaining via events() DSL**: Using events().click(...).input(...)
// instead of on(click=Some(...)) avoids generating Option wrappers for all
// 16 event types. MoonBit's dead code elimination removes unused handlers.
//
///|
/// Type alias for event handler
pub type Handler = (@js.Any) -> Unit
// =============================================================================
// Attr - Attribute value for attrs array
// =============================================================================
///|
/// Attribute value type for attrs array
/// Usage: Attr::AttrString("value"), Attr::AttrInt(42), etc.
pub(all) enum Attr {
AttrString(String)
AttrNumber(Double)
AttrInt(Int)
AttrBool(Bool)
}
///|
/// Convert Double to String using JS toString
/// Note: We avoid MoonBit's Double.to_string() which uses ryu algorithm (~31KB overhead)
fn double_to_string(d : Double) -> String {
let any : @js.Any = d |> @js.identity
any._call("toString", []).cast()
}
///|
impl Show for Attr with output(self, logger) {
match self {
AttrString(s) => logger.write_string(s)
AttrNumber(d) => logger.write_string(double_to_string(d))
AttrInt(i) => logger.write_string(i.to_string())
AttrBool(b) => logger.write_string(b.to_string())
}
}
///|
/// Convert Attr to AttrValue
pub fn Attr::to_attr_value(self : Attr) -> AttrValue {
Static(self.to_string())
}
// =============================================================================
// HandlerMap - Event handlers collection (JS FFI optimized)
// =============================================================================
///|
/// Event handlers collection - opaque JS object
/// Internally stores { click?: fn, input?: fn, ... } (lowercase DOM event names)
/// Note: Using opaque JS object instead of MoonBit struct to avoid code bloat
pub type HandlerMap
///|
/// Create an empty HandlerMap, then chain event handlers
/// Example: events().click(fn(_) { ... }).input(fn(e) { ... })
///
/// Each handler receives the appropriate typed event:
/// - click, dblclick, mouseenter, etc. -> MouseHandler (@js_dom.MouseEvent)
/// - keydown, keyup, keypress -> KeyboardHandler (@js_dom.KeyboardEvent)
/// - input -> InputHandler (@js_dom.InputEvent)
/// - change -> ChangeHandler (@js_dom.ChangeEvent)
/// - submit -> FormHandler (@js_dom.FormEvent)
/// - focus, blur -> FocusHandler (@js_dom.FocusEvent)
pub fn events() -> HandlerMap {
@js.new_object().cast()
}
///|
pub extern "js" fn HandlerMap::click(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.click = handler; return m; }
///|
pub extern "js" fn HandlerMap::dblclick(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.dblclick = handler; return m; }
///|
pub extern "js" fn HandlerMap::input(
self : Self,
handler : InputHandler,
) -> HandlerMap =
#|(m, handler) => { m.input = handler; return m; }
///|
pub extern "js" fn HandlerMap::change(
self : Self,
handler : ChangeHandler,
) -> HandlerMap =
#|(m, handler) => { m.change = handler; return m; }
///|
pub extern "js" fn HandlerMap::submit(
self : Self,
handler : FormHandler,
) -> HandlerMap =
#|(m, handler) => { m.submit = handler; return m; }
///|
pub extern "js" fn HandlerMap::keydown(
self : Self,
handler : KeyboardHandler,
) -> HandlerMap =
#|(m, handler) => { m.keydown = handler; return m; }
///|
pub extern "js" fn HandlerMap::keyup(
self : Self,
handler : KeyboardHandler,
) -> HandlerMap =
#|(m, handler) => { m.keyup = handler; return m; }
///|
pub extern "js" fn HandlerMap::keypress(
self : Self,
handler : KeyboardHandler,
) -> HandlerMap =
#|(m, handler) => { m.keypress = handler; return m; }
///|
pub extern "js" fn HandlerMap::focus(
self : Self,
handler : FocusHandler,
) -> HandlerMap =
#|(m, handler) => { m.focus = handler; return m; }
///|
pub extern "js" fn HandlerMap::blur(
self : Self,
handler : FocusHandler,
) -> HandlerMap =
#|(m, handler) => { m.blur = handler; return m; }
///|
pub extern "js" fn HandlerMap::mouseenter(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.mouseenter = handler; return m; }
///|
pub extern "js" fn HandlerMap::mouseleave(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.mouseleave = handler; return m; }
///|
pub extern "js" fn HandlerMap::mouseover(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.mouseover = handler; return m; }
///|
pub extern "js" fn HandlerMap::mouseout(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.mouseout = handler; return m; }
///|
pub extern "js" fn HandlerMap::mousedown(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.mousedown = handler; return m; }
///|
pub extern "js" fn HandlerMap::mouseup(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.mouseup = handler; return m; }
///|
/// Composition start event (for IME input)
pub extern "js" fn HandlerMap::compositionstart(
self : Self,
handler : Handler,
) -> HandlerMap =
#|(m, handler) => { m.compositionstart = handler; return m; }
///|
/// Composition end event (for IME input)
pub extern "js" fn HandlerMap::compositionend(
self : Self,
handler : Handler,
) -> HandlerMap =
#|(m, handler) => { m.compositionend = handler; return m; }
///|
/// Mouse move event (for drag, hover tracking)
pub extern "js" fn HandlerMap::mousemove(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.mousemove = handler; return m; }
///|
/// Wheel event (for scroll/zoom)
pub extern "js" fn HandlerMap::wheel(
self : Self,
handler : WheelHandler,
) -> HandlerMap =
#|(m, handler) => { m.wheel = handler; return m; }
///|
/// Context menu event (right-click)
pub extern "js" fn HandlerMap::contextmenu(
self : Self,
handler : MouseHandler,
) -> HandlerMap =
#|(m, handler) => { m.contextmenu = handler; return m; }
///|
/// Drag start event
pub extern "js" fn HandlerMap::dragstart(
self : Self,
handler : DragHandler,
) -> HandlerMap =
#|(m, handler) => { m.dragstart = handler; return m; }
///|
/// Drag event (during drag)
pub extern "js" fn HandlerMap::drag(
self : Self,
handler : DragHandler,
) -> HandlerMap =
#|(m, handler) => { m.drag = handler; return m; }
///|
/// Drag end event
pub extern "js" fn HandlerMap::dragend(
self : Self,
handler : DragHandler,
) -> HandlerMap =
#|(m, handler) => { m.dragend = handler; return m; }
///|
/// Drag enter event (enters drop zone)
pub extern "js" fn HandlerMap::dragenter(
self : Self,
handler : DragHandler,
) -> HandlerMap =
#|(m, handler) => { m.dragenter = handler; return m; }
///|
/// Drag leave event (leaves drop zone)
pub extern "js" fn HandlerMap::dragleave(
self : Self,
handler : DragHandler,
) -> HandlerMap =
#|(m, handler) => { m.dragleave = handler; return m; }
///|
/// Drag over event (over drop zone)
pub extern "js" fn HandlerMap::dragover(
self : Self,
handler : DragHandler,
) -> HandlerMap =
#|(m, handler) => { m.dragover = handler; return m; }
///|
/// Drop event
pub extern "js" fn HandlerMap::drop(
self : Self,
handler : DragHandler,
) -> HandlerMap =
#|(m, handler) => { m.drop = handler; return m; }
///|
/// Touch start event
pub extern "js" fn HandlerMap::touchstart(
self : Self,
handler : TouchHandler,
) -> HandlerMap =
#|(m, handler) => { m.touchstart = handler; return m; }
///|
/// Touch move event
pub extern "js" fn HandlerMap::touchmove(
self : Self,
handler : TouchHandler,
) -> HandlerMap =
#|(m, handler) => { m.touchmove = handler; return m; }
///|
/// Touch end event
pub extern "js" fn HandlerMap::touchend(
self : Self,
handler : TouchHandler,
) -> HandlerMap =
#|(m, handler) => { m.touchend = handler; return m; }
///|
/// Touch cancel event
pub extern "js" fn HandlerMap::touchcancel(
self : Self,
handler : TouchHandler,
) -> HandlerMap =
#|(m, handler) => { m.touchcancel = handler; return m; }
///|
/// Pointer down event (unified mouse/touch/pen)
pub extern "js" fn HandlerMap::pointerdown(
self : Self,
handler : PointerHandler,
) -> HandlerMap =
#|(m, handler) => { m.pointerdown = handler; return m; }
///|
/// Pointer move event
pub extern "js" fn HandlerMap::pointermove(
self : Self,
handler : PointerHandler,
) -> HandlerMap =
#|(m, handler) => { m.pointermove = handler; return m; }
///|
/// Pointer up event
pub extern "js" fn HandlerMap::pointerup(
self : Self,
handler : PointerHandler,
) -> HandlerMap =
#|(m, handler) => { m.pointerup = handler; return m; }
///|
/// Pointer cancel event
pub extern "js" fn HandlerMap::pointercancel(
self : Self,
handler : PointerHandler,
) -> HandlerMap =
#|(m, handler) => { m.pointercancel = handler; return m; }
///|
/// Pointer enter event
pub extern "js" fn HandlerMap::pointerenter(
self : Self,
handler : PointerHandler,
) -> HandlerMap =
#|(m, handler) => { m.pointerenter = handler; return m; }
///|
/// Pointer leave event
pub extern "js" fn HandlerMap::pointerleave(
self : Self,
handler : PointerHandler,
) -> HandlerMap =
#|(m, handler) => { m.pointerleave = handler; return m; }
///|
/// Scroll event
pub extern "js" fn HandlerMap::scroll(
self : Self,
handler : Handler,
) -> HandlerMap =
#|(m, handler) => { m.scroll = handler; return m; }
///|
/// Resize event (for window/element resize)
pub extern "js" fn HandlerMap::resize(
self : Self,
handler : Handler,
) -> HandlerMap =
#|(m, handler) => { m.resize = handler; return m; }
///|
/// Convert HandlerMap to Array of attribute tuples using JS Object.entries
/// Keys are already lowercase event names, tag 2 = Handler variant
extern "js" fn HandlerMap::to_attrs(
self : HandlerMap,
) -> Array[(String, AttrValue)] =
#|(m) => Object.entries(m).map(([k, v]) => ({ _0: k, _1: { $tag: 2, _0: v } }))
// =============================================================================
// Internal: Build props from DSL parameters
// =============================================================================
///|
fn build_props(
id : String?,
class : String?,
style : String?,
on : HandlerMap?,
ref_ : ElementRef?,
attrs : Array[(String, Attr)]?,
dyn_attrs? : Array[(String, AttrValue)]? = None,
) -> Array[(String, AttrValue)] {
let result : Array[(String, AttrValue)] = []
// id
if id is Some(v) {
result.push(("id", Static(v)))
}
// class
if class is Some(v) {
result.push(("className", Static(v)))
}
// style (now a simple string like "color: red; margin: 10px")
if style is Some(s) {
result.push(("style", Static(s)))
}
// event handlers
if on is Some(handlers) {
for attr in handlers.to_attrs() {
result.push(attr)
}
}
// ref callback - stored as special __ref key with Handler variant
// Will be processed during render to call the callback with the element
if ref_ is Some(cb) {
// Wrap ElementRef callback as Handler for storage in AttrValue
// ElementRef is type alias for (@js_dom.Element) -> Unit
let handler : (@js.Any) -> Unit = fn(el) { cb(el.cast()) }
result.push(("__ref", Handler(handler)))
}
// additional static attrs
if attrs is Some(extra) {
for pair in extra {
result.push((pair.0, pair.1.to_attr_value()))
}
}
// additional dynamic attrs (AttrValue - can include Dynamic, Handler)
if dyn_attrs is Some(extra) {
for pair in extra {
result.push(pair)
}
}
result
}
// =============================================================================
// use_style - CSS injection for Shadow DOM / document.head
// =============================================================================
///|
/// Inject CSS into the ShadowRoot containing the element, or document.head
///
/// - Uses getRootNode() to find the ShadowRoot that contains the element
/// - If element is inside a ShadowRoot, injects into that ShadowRoot
/// - Otherwise, injects into document.head
/// - Uses CSS hash as ID to prevent duplicate injection
///
/// Usage:
/// use_style(css, element) // element can be any element inside a ShadowRoot
pub fn use_style(css : String, root : @js_dom.Element) -> Unit {
let style_id = "style-\{hash_css(css)}"
ffi_use_style(css, style_id, root.as_any())
}
///|
/// FFI: Inject style into Shadow DOM or document.head
/// Waits for element to be connected to DOM before injection.
/// Uses exponential backoff for retries (16ms, 32ms, 64ms, 128ms, 256ms).
/// Only injects into ShadowRoot if element is inside one; otherwise uses document.head.
extern "js" fn ffi_use_style(css : String, style_id : String, root : @js.Any) =
#|(css, id, el) => {
#| const inject = () => {
#| const rootNode = el.getRootNode();
#| const isShadow = rootNode instanceof ShadowRoot;
#| const target = isShadow ? rootNode : document.head;
#| if (!target.querySelector('#' + id)) {
#| const style = document.createElement('style');
#| style.id = id;
#| style.textContent = css;
#| if (isShadow) target.prepend(style);
#| else target.appendChild(style);
#| }
#| };
#| const tryInject = (delay, maxDelay) => {
#| if (el.isConnected) {
#| inject();
#| } else if (delay <= maxDelay) {
#| // Exponential backoff: 16ms, 32ms, 64ms, 128ms, 256ms (total ~500ms)
#| setTimeout(() => tryInject(delay * 2, maxDelay), delay);
#| }
#| // If still not connected after max delay, assume SSR style exists
#| };
#| // Start with requestAnimationFrame, then exponential backoff
#| requestAnimationFrame(() => tryInject(16, 256));
#|}
///|
/// Simple hash function for CSS string (for dedup ID)
fn hash_css(css : String) -> String {
let mut hash : UInt = 5381
for c in css {
hash = (hash << 5) + hash + c.to_uint()
}
hash.to_string()
}