My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add widget test page with counter and slider examples

Add js_top_worker-widget and note to the example worker's dependencies,
and create widget_test.html with three interactive test cases: a static
widget, an FRP counter with increment/decrement buttons, and a slider
with a cross-cell derived display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+219 -2
+3 -2
js_top_worker/example/dune
··· 38 38 (js_of_ocaml 39 39 (javascript_files ../lib/stubs.js) 40 40 (flags --effects=disabled --toplevel --opt 3 +toplevel.js +dynlink.js)) 41 - (libraries js_top_worker-web logs.browser mime_printer tyxml)) 41 + (libraries js_top_worker-web logs.browser mime_printer tyxml js_top_worker-widget note)) 42 42 43 43 (rule 44 44 (targets 45 45 (dir _opam)) 46 46 (action 47 - (run jtw opam -o _opam str stringext mime_printer))) 47 + (run jtw opam -o _opam str stringext mime_printer note))) 48 48 49 49 (alias 50 50 (name default) ··· 62 62 index3.html 63 63 index4.html 64 64 index5.html 65 + widget_test.html 65 66 _opam 66 67 server.py 67 68 (alias_rec all)))
+216
js_top_worker/example/widget_test.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>Widget Protocol Test</title> 6 + <style> 7 + body { font-family: monospace; max-width: 800px; margin: 2em auto; } 8 + #log { background: #f5f5f5; padding: 1em; white-space: pre-wrap; max-height: 400px; overflow-y: auto; } 9 + .widget-container { border: 1px solid #ccc; padding: 1em; margin: 1em 0; } 10 + .widget-container h3 { margin-top: 0; color: #666; } 11 + </style> 12 + </head> 13 + <body> 14 + <h1>Widget Protocol Test</h1> 15 + <div id="widgets"></div> 16 + <h2>Log</h2> 17 + <div id="log"></div> 18 + 19 + <script type="module"> 20 + import { OcamlWorker } from '../client/ocaml-worker.js'; 21 + 22 + const logEl = document.getElementById('log'); 23 + const widgetsEl = document.getElementById('widgets'); 24 + let worker; 25 + 26 + function log(msg) { 27 + logEl.textContent += msg + '\n'; 28 + logEl.scrollTop = logEl.scrollHeight; 29 + } 30 + 31 + // --- View.node JSON renderer --- 32 + function renderNode(node) { 33 + if (node.t === 'txt') { 34 + return document.createTextNode(node.v); 35 + } 36 + if (node.t === 'el') { 37 + const el = document.createElement(node.tag); 38 + for (const attr of (node.a || [])) { 39 + switch (attr.t) { 40 + case 'prop': 41 + el.setAttribute(attr.k, attr.v); 42 + break; 43 + case 'style': 44 + el.style[attr.k] = attr.v; 45 + break; 46 + case 'cls': 47 + el.classList.add(attr.v); 48 + break; 49 + case 'handler': 50 + el.addEventListener(attr.ev, () => { 51 + const isInput = ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName); 52 + const value = isInput ? el.value : null; 53 + const widgetId = el.closest('[data-widget-id]')?.dataset.widgetId; 54 + if (widgetId) { 55 + log('Event: widget=' + widgetId + ' handler=' + attr.id + ' value=' + value); 56 + worker.sendWidgetEvent(widgetId, attr.id, attr.ev, value); 57 + } 58 + }); 59 + break; 60 + } 61 + } 62 + for (const child of (node.c || [])) { 63 + el.appendChild(renderNode(child)); 64 + } 65 + return el; 66 + } 67 + return document.createTextNode(''); 68 + } 69 + 70 + function renderWidget(widgetId, viewJson) { 71 + let container = document.getElementById('widget-' + widgetId); 72 + if (!container) { 73 + container = document.createElement('div'); 74 + container.id = 'widget-' + widgetId; 75 + container.className = 'widget-container'; 76 + container.dataset.widgetId = widgetId; 77 + container.innerHTML = '<h3>' + widgetId + '</h3>'; 78 + widgetsEl.appendChild(container); 79 + } 80 + const heading = container.querySelector('h3'); 81 + container.innerHTML = ''; 82 + container.appendChild(heading); 83 + container.dataset.widgetId = widgetId; 84 + container.appendChild(renderNode(viewJson)); 85 + } 86 + 87 + async function run() { 88 + log('Creating worker...'); 89 + worker = new OcamlWorker('_opam/worker.js', { 90 + onWidgetUpdate: (msg) => { 91 + log('WidgetUpdate: id=' + msg.widget_id); 92 + renderWidget(msg.widget_id, msg.view); 93 + }, 94 + onWidgetClear: (msg) => { 95 + log('WidgetClear: id=' + msg.widget_id); 96 + const container = document.getElementById('widget-' + msg.widget_id); 97 + if (container) container.remove(); 98 + }, 99 + onOutputAt: (msg) => { 100 + if (msg.caml_ppf) log('OutputAt: ' + msg.caml_ppf); 101 + }, 102 + }); 103 + 104 + log('Initializing...'); 105 + await worker.init({ 106 + findlib_requires: [], 107 + findlib_index: null, 108 + }); 109 + log('Worker ready.'); 110 + 111 + // Test 1: Static widget 112 + log('\n--- Test 1: Static widget ---'); 113 + const r1 = await worker.eval( 114 + 'Widget.display ~id:"hello" ~handlers:[] ' + 115 + '(Widget_view.Element { tag = "div"; attrs = []; ' + 116 + 'children = [Widget_view.Text "Hello from OCaml!"] });;' 117 + ); 118 + log('Eval: ' + r1.caml_ppf); 119 + 120 + // Test 2: Interactive counter with Note 121 + log('\n--- Test 2: Interactive counter ---'); 122 + const r2 = await worker.eval([ 123 + 'let inc_e, send_inc = Note.E.create ();;', 124 + 'let dec_e, send_dec = Note.E.create ();;', 125 + 'let count =', 126 + ' let delta = Note.E.select [', 127 + ' Note.E.map (fun () n -> n + 1) inc_e;', 128 + ' Note.E.map (fun () n -> n - 1) dec_e;', 129 + ' ] in', 130 + ' Note.S.accum 0 delta;;', 131 + '', 132 + 'let counter_view n =', 133 + ' let open Widget_view in', 134 + ' Element { tag = "div"; attrs = [Class "counter"]; children = [', 135 + ' Element { tag = "button";', 136 + ' attrs = [Handler ("click", "dec")];', 137 + ' children = [Text "-"] };', 138 + ' Element { tag = "span";', 139 + ' attrs = [Style ("margin", "0 1em")];', 140 + ' children = [Text (string_of_int n)] };', 141 + ' Element { tag = "button";', 142 + ' attrs = [Handler ("click", "inc")];', 143 + ' children = [Text "+"] };', 144 + ' ] };;', 145 + '', 146 + 'Widget.display ~id:"counter"', 147 + ' ~handlers:[', 148 + ' "inc", (fun _ -> send_inc ());', 149 + ' "dec", (fun _ -> send_dec ());', 150 + ' ]', 151 + ' (counter_view 0);;', 152 + '', 153 + 'let _logr = Note.S.log', 154 + ' (Note.S.map counter_view count)', 155 + ' (Widget.update ~id:"counter");;', 156 + 'Note.Logr.hold _logr;;', 157 + ].join('\n')); 158 + log('Eval: ' + r2.caml_ppf); 159 + 160 + // Test 3: Slider with cross-cell signal 161 + log('\n--- Test 3: Slider ---'); 162 + await worker.eval([ 163 + 'let x_e, send_x = Note.E.create ();;', 164 + 'let x = Note.S.hold 50 x_e;;', 165 + '', 166 + 'let slider_view v =', 167 + ' let open Widget_view in', 168 + ' Element { tag = "div"; attrs = []; children = [', 169 + ' Element { tag = "label"; attrs = [];', 170 + ' children = [Text (Printf.sprintf "X: %d" v)] };', 171 + ' Element { tag = "input"; attrs = [', 172 + ' Property ("type", "range");', 173 + ' Property ("min", "0");', 174 + ' Property ("max", "100");', 175 + ' Property ("value", string_of_int v);', 176 + ' Handler ("input", "x");', 177 + ' ]; children = [] };', 178 + ' ] };;', 179 + '', 180 + 'Widget.display ~id:"slider"', 181 + ' ~handlers:["x", (fun v ->', 182 + ' send_x (int_of_string (Option.get v)))]', 183 + ' (slider_view 50);;', 184 + '', 185 + 'let _logr2 = Note.S.log', 186 + ' (Note.S.map slider_view x)', 187 + ' (Widget.update ~id:"slider");;', 188 + 'Note.Logr.hold _logr2;;', 189 + ].join('\n')); 190 + 191 + // Test 3b: Cross-cell derived widget 192 + log('\n--- Test 3b: Derived widget (uses x from cell above) ---'); 193 + await worker.eval([ 194 + 'let doubled_view v =', 195 + ' let open Widget_view in', 196 + ' Element { tag = "div"; attrs = []; children = [', 197 + ' Text (Printf.sprintf "2x = %d" (v * 2))', 198 + ' ] };;', 199 + '', 200 + 'Widget.display ~id:"doubled"', 201 + ' ~handlers:[]', 202 + ' (doubled_view (Note.S.value x));;', 203 + '', 204 + 'let _logr3 = Note.S.log', 205 + ' (Note.S.map doubled_view x)', 206 + ' (Widget.update ~id:"doubled");;', 207 + 'Note.Logr.hold _logr3;;', 208 + ].join('\n')); 209 + 210 + log('\nAll tests dispatched. Interact with widgets above.'); 211 + } 212 + 213 + run().catch(e => log('Error: ' + e.message)); 214 + </script> 215 + </body> 216 + </html>