pocopine-core 0.1.0

Client-side reactive runtime for pocopine — a Rust/WASM port of Alpine.js.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! `pp-on:event[.mod]*="handler"` — event listeners that dispatch to a
//! macro-generated handler on the scope's `ComponentState`.
//!
//! Supported modifiers:
//!
//! | modifier           | effect                                                |
//! |--------------------|-------------------------------------------------------|
//! | `prevent`          | calls `preventDefault()` before dispatch              |
//! | `stop`             | calls `stopPropagation()`                             |
//! | `self`             | only fires when `event.target === el`                  |
//! | `once`             | browser removes the listener after one fire           |
//! | `window`           | attach to `window` instead of `el`                    |
//! | `document`         | attach to `document` instead of `el`                  |
//! | `capture`          | install in capture phase (`addEventListener(..., {capture: true})`) |
//! | `outside`          | only fires when target is outside `el`; implies capture |
//! | `debounce[.<ms>]`  | wait `ms` (default 300) of quiet after the last event |

use std::cell::{Cell, RefCell};
use std::rc::Rc;

use js_sys::Function;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::prelude::*;
use web_sys::{AddEventListenerOptions, Element, Event, EventTarget, KeyboardEvent, Node};

use crate::expr::{self, Expr, Spanned};
use crate::magics::with_current_event;
use crate::reactive::ScopeId;
use crate::scope::with_current_el;

/// Install a `pp-on:<event>[.<mod>...]` listener on `el` (or on
/// `window` / `document` per the modifier set), routing fired
/// events through `expr` with `$event` available in scope.
///
/// `modifiers` carries the post-`.` tokens — `prevent`, `stop`,
/// `self`, `once`, `window`, `document`, `outside`, key
/// modifiers, and the `debounce` + numeric-ms pair. The set
/// must match what RFC-057 §6.1's table expects; the macro-side
/// classifier validates ahead of time, so reaching this with
/// an unknown token is a framework bug.
///
/// Cleanup-safe install entry point — the listener's lifetime
/// ties to `el` via [`crate::mount::track_listener_on_with_opts`].
pub fn install(
    el: &Element,
    scope_id: ScopeId,
    proxy: &JsValue,
    event: &str,
    modifiers: &'static [&'static str],
    ast: Rc<Spanned<Expr>>,
) {
    let event = event.to_string();
    let el = el.clone();
    let proxy = proxy.clone();

    let prevent = modifiers.contains(&"prevent");
    let stop = modifiers.contains(&"stop");
    let self_only = modifiers.contains(&"self");
    let once = modifiers.contains(&"once");
    let on_window = modifiers.contains(&"window");
    let on_document = modifiers.contains(&"document");
    let outside = modifiers.contains(&"outside");
    let capture = modifiers.contains(&"capture");
    let debounce_ms: Option<u32> = parse_debounce(modifiers);

    // Persistent closure used by `setTimeout` in the debounce branch.
    // Built once per listener so rapid events don't allocate a fresh
    // JS closure each time. Pulls the most recent event out of the
    // shared slot so the expression still sees `$event` after a
    // debounce delay.
    //
    // The backing `Closure<dyn FnMut()>` is `Some` only for debounced
    // listeners. Kept alive by being moved into the outer listener
    // closure's capture (below) so its JS callable stays valid for
    // the lifetime of the listener — and drops when the listener
    // drops via the element-scoped listener table.
    type DebouncedInvoke = (Option<Function>, Option<Closure<dyn FnMut()>>);
    let last_event: Rc<RefCell<Option<Event>>> = Rc::new(RefCell::new(None));
    let (invoke_fn, debounce_closure): DebouncedInvoke = if debounce_ms.is_some() {
        let ast = ast.clone();
        let last_event = last_event.clone();
        let el_for_debounce = el.clone();
        let proxy_for_debounce = proxy.clone();
        let c = Closure::wrap(Box::new(move || {
            let ev = last_event.borrow().clone();
            let ev_js: JsValue = match &ev {
                Some(e) => {
                    let r: &JsValue = e.as_ref();
                    r.clone()
                }
                None => JsValue::UNDEFINED,
            };
            with_current_el(&el_for_debounce, || {
                crate::scope::with_current_scope_id(scope_id, || {
                    with_current_event(&ev_js, || {
                        expr::evaluate(&ast, &proxy_for_debounce);
                    });
                });
            });
        }) as Box<dyn FnMut()>);
        let f: Function = c.as_ref().unchecked_ref::<Function>().clone();
        (Some(f), Some(c))
    } else {
        (None, None)
    };

    let window = web_sys::window().expect("window");
    let timer: Rc<Cell<Option<i32>>> = Rc::new(Cell::new(None));

    // Key modifiers (RFC-013). Cloned into the closure below.
    let key_modifiers = collect_key_modifiers(modifiers);

    let el_for_closure = el.clone();
    // Keep the debounce closure alive by moving it into the outer
    // listener's capture. When the listener drops (via the element
    // listener table in `release_subtree`), the debounce closure
    // drops with it — no `.forget()` required.
    let _debounce_closure_owned = debounce_closure;
    let closure = Closure::wrap(Box::new({
        let ast = ast.clone();
        let proxy = proxy.clone();
        let invoke_fn = invoke_fn.clone();
        let window = window.clone();
        let timer = timer.clone();
        let last_event = last_event.clone();
        let _debounce_closure_owned = _debounce_closure_owned;
        move |ev: Event| {
            // `outside` runs first: when set, we only continue past
            // this block if the event originated outside the host.
            //
            // Compound components (Popover, DropdownMenu, Select)
            // whose *trigger* is a sibling of the Content hit a
            // classic race: clicking the trigger while the panel is
            // open fires the capture-phase outside-listener first
            // (target is the trigger — "outside" the Content) →
            // close runs → then the click bubbles to the trigger →
            // toggle flips open back on. Net: stays open.
            //
            // Opt-out via `data-pp-outside-exempt="<selector>"` on
            // the host: when the click's target matches that
            // selector (or is descended from something that does),
            // the outside-listener treats it as "inside" and
            // doesn't fire close. The compound's Content stamps the
            // attribute with its trigger's selector at mount.
            if outside {
                let host_node: &Node = el_for_closure.as_ref();
                if !host_node.is_connected() {
                    return;
                }
                match ev.target() {
                    Some(t) => match t.dyn_into::<Node>() {
                        Ok(node) => {
                            if el_for_closure.contains(Some(&node)) {
                                return;
                            }
                            if let Some(sel) =
                                el_for_closure.get_attribute("data-pp-outside-exempt")
                            {
                                if let Ok(target_el) = node.clone().dyn_into::<Element>() {
                                    if target_el.closest(&sel).ok().flatten().is_some() {
                                        return;
                                    }
                                }
                            }
                        }
                        Err(_) => return,
                    },
                    None => return,
                }
            }
            // Key filter runs BEFORE `prevent` / `stop` so a modifier
            // chain like `@keydown.enter.prevent` only preventDefaults
            // when the target key actually matches. Otherwise every
            // keystroke on an editable input (e.g. PineCombobox /
            // PineCommand search inputs) would be swallowed by the
            // `.prevent` applied to a sibling `.escape` / `.enter`
            // handler.
            if !key_modifiers.is_empty() && !key_filter_matches(&ev, &key_modifiers) {
                return;
            }
            if !outside && prevent {
                ev.prevent_default();
            }
            if stop {
                ev.stop_propagation();
            }
            if self_only {
                if outside {
                    // `.self` + `.outside` is contradictory by
                    // definition — never fire.
                    return;
                }
                if let Some(target) = ev.target() {
                    if target != *el_for_closure.as_ref() {
                        return;
                    }
                }
            }
            if let (Some(ms), Some(invoke_fn)) = (debounce_ms, invoke_fn.as_ref()) {
                // Remember the most recent event so the delayed
                // callback can still pass it to the handler.
                *last_event.borrow_mut() = Some(ev);
                if let Some(prev) = timer.take() {
                    window.clear_timeout_with_handle(prev);
                }
                let handle = window
                    .set_timeout_with_callback_and_timeout_and_arguments_0(invoke_fn, ms as i32)
                    .unwrap_or(0);
                timer.set(Some(handle));
            } else {
                let ev_js: JsValue = {
                    let r: &JsValue = ev.as_ref();
                    r.clone()
                };
                with_current_el(&el_for_closure, || {
                    crate::scope::with_current_scope_id(scope_id, || {
                        with_current_event(&ev_js, || {
                            expr::evaluate(&ast, &proxy);
                        });
                    });
                });
            }
        }
    }) as Box<dyn FnMut(Event)>);

    let target: EventTarget = if outside || on_document {
        web_sys::window()
            .and_then(|w| w.document())
            .expect("document")
            .into()
    } else if on_window {
        web_sys::window().expect("window").into()
    } else {
        el.clone().into()
    };

    let opts = AddEventListenerOptions::new();
    opts.set_once(once);
    if outside || capture {
        // `outside` always uses capture (so this runs before
        // descendants can stop the event — otherwise a child's
        // `@click.stop` hides the outside click from us).
        // `.capture` is the user-facing modifier for the same
        // semantic without the outside-only filter, used for
        // listeners that need to see the event before bubble-
        // phase descendants.
        opts.set_capture(true);
    }
    // Tie the listener's lifetime to `el` so `release_subtree`
    // removes it (and drops the underlying `Box<dyn FnMut>`) on
    // unmount. Replaces the old `closure.forget()` which leaked the
    // listener AND — for `.window` / `.document` / `.outside`
    // variants — kept it firing past unmount.
    crate::mount::track_listener_on_with_opts(&el, target, &event, &opts, closure);
}

/// Backward-compat: a directive value that's a single identifier
/// (`@click="on_click"`, `@submit="save"`) is rewritten to a
/// one-arg call of `on_click($event)`. Keeps every existing handler
/// that declared `(&mut self, ev: Event)` working unchanged without
/// the author having to write `@click="on_click($event)"`.
/// Backfill the RFC-058 § 5.5 install path: the `pp-on` parser
/// allows authors to write `@click="on_click"` (a bare path) and
/// expects the runtime to invoke it as `on_click($event)`. The
/// rewrite happens at parse time inside `run` today; the
/// public install helper accepts an already-parsed AST, so the
/// Phase 2.4 plan applier needs the same rewrite available.
/// Public so the compiled plan installer can
/// call it before handing the AST to [`install`].
#[doc(hidden)]
pub fn backfill_legacy_call(ast: Spanned<Expr>) -> Spanned<Expr> {
    match ast.value {
        Expr::Path(ref segs) if segs.len() == 1 => {
            let name = segs[0].clone();
            let span = ast.span.clone();
            let event_arg = Spanned {
                value: Expr::Path(vec!["$event".to_string()]),
                span: span.clone(),
            };
            Spanned {
                value: Expr::Call(name, vec![event_arg]),
                span,
            }
        }
        _ => ast,
    }
}

/// Scan the modifier list for `debounce` and the optional numeric
/// modifier that follows it (`pp-on:input.debounce.500="foo"` →
/// 500ms; bare `debounce` → 300ms default).
fn parse_debounce(modifiers: &[&str]) -> Option<u32> {
    for (i, m) in modifiers.iter().enumerate() {
        if *m == "debounce" {
            let ms = modifiers
                .get(i + 1)
                .and_then(|s| s.parse::<u32>().ok())
                .unwrap_or(300);
            return Some(ms);
        }
    }
    None
}

fn collect_key_modifiers(modifiers: &[&'static str]) -> Vec<&'static str> {
    modifiers
        .iter()
        .enumerate()
        .filter_map(|(i, m)| {
            if i > 0 && modifiers[i - 1] == "debounce" && m.parse::<u32>().is_ok() {
                return None;
            }
            is_key_modifier(m).then_some(*m)
        })
        .collect()
}

/// Modifiers that participate in the RFC-013 key filter. Everything
/// else (`prevent`, `stop`, `self`, `once`, `window`, `document`,
/// `debounce`, `outside`, `capture`, numeric debounce args) is handled elsewhere
/// and must be explicitly excluded so a directive like
/// `pp-on:click.outside` doesn't accidentally treat `outside` as a
/// keyboard key name and filter out every non-keyboard event.
fn is_key_modifier(m: &str) -> bool {
    if matches!(
        m,
        "prevent"
            | "stop"
            | "self"
            | "once"
            | "window"
            | "document"
            | "debounce"
            | "outside"
            | "capture"
    ) {
        return false;
    }
    matches!(m, "ctrl" | "shift" | "alt" | "meta")
        || named_key_for(m).is_some()
        || m.len() == 1  // single-letter key shortcut (e.g. `.k`, `.a`)
        || is_word_key(m)
}

/// Is this a named keyboard event modifier? Returns the
/// lowercase `KeyboardEvent.key` we expect to see.
fn named_key_for(m: &str) -> Option<&'static str> {
    Some(match m {
        "escape" | "esc" => "escape",
        "enter" => "enter",
        "tab" => "tab",
        "space" => " ",
        "backspace" => "backspace",
        "delete" | "del" => "delete",
        "arrow-up" | "up" => "arrowup",
        "arrow-down" | "down" => "arrowdown",
        "arrow-left" | "left" => "arrowleft",
        "arrow-right" | "right" => "arrowright",
        "home" => "home",
        "end" => "end",
        "page-up" => "pageup",
        "page-down" => "pagedown",
        _ => return None,
    })
}

/// Word-shaped key names that literally match the lowercased key
/// (so `.slash` could match `"/"` — but no built-in symbol aliases
/// today; extend here if needed).
fn is_word_key(m: &str) -> bool {
    !m.is_empty() && m.chars().all(|c| c.is_ascii_alphanumeric())
}

/// Return true iff the event passes every key modifier. Non-keyboard
/// events fail any key modifier check.
fn key_filter_matches(ev: &Event, modifiers: &[&str]) -> bool {
    let Ok(ke) = ev.clone().dyn_into::<KeyboardEvent>() else {
        return false;
    };
    let key = ke.key().to_lowercase();
    for m in modifiers {
        match *m {
            "ctrl" if !ke.ctrl_key() => return false,
            "shift" if !ke.shift_key() => return false,
            "alt" if !ke.alt_key() => return false,
            "meta" if !ke.meta_key() => return false,
            "ctrl" | "shift" | "alt" | "meta" => continue,
            _ => {
                let want = named_key_for(m).unwrap_or(m);
                if key != want {
                    return false;
                }
            }
        }
    }
    true
}

#[cfg(test)]
mod tests {
    use super::{collect_key_modifiers, is_key_modifier};

    #[test]
    fn capture_is_not_a_key_filter() {
        assert!(!is_key_modifier("capture"));
    }

    #[test]
    fn debounce_delay_is_not_a_key_filter() {
        assert_eq!(
            collect_key_modifiers(&["debounce", "500"]),
            Vec::<&'static str>::new()
        );
    }

    #[test]
    fn numeric_key_shortcuts_still_filter_when_not_debounce_delay() {
        assert_eq!(collect_key_modifiers(&["1"]), vec!["1"]);
    }
}