diff --git a/cpp/quickjs/bindings/ElementBindings.cpp b/cpp/quickjs/bindings/ElementBindings.cpp index bdd23e1..5f85258 100644 --- a/cpp/quickjs/bindings/ElementBindings.cpp +++ b/cpp/quickjs/bindings/ElementBindings.cpp @@ -1236,6 +1236,113 @@ JSValue js_el_insertAdjacentHTML(JSContext* ctx, JSValue this_val, int argc, JSV return JS_UNDEFINED; } +JSValue js_el_insertAdjacentElement(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) { + auto* el = unwrap_element(ctx, this_val); + if (!el || argc < 2) return JS_NULL; + const char* position = JS_ToCString(ctx, argv[0]); + if (!position) return JS_NULL; + std::string pos(position); + JS_FreeCString(ctx, position); + + auto* inserted_el = unwrap_element(ctx, argv[1]); + if (!inserted_el) return JS_NULL; + lxb_dom_node_t* inserted = lxb_dom_interface_node(inserted_el); + + lxb_dom_node_t* node = lxb_dom_interface_node(el); + lxb_dom_node_t* parent = nullptr; + lxb_dom_node_t* prev_sib = nullptr; + lxb_dom_node_t* next_sib = nullptr; + + if (pos == "beforebegin") { + parent = node->parent; + if (!parent) return JS_NULL; + prev_sib = node->prev; + lxb_dom_node_insert_before(node, inserted); + next_sib = node; + } else if (pos == "afterbegin") { + parent = node; + lxb_dom_node_t* ref = node->first_child; + if (ref) lxb_dom_node_insert_before(ref, inserted); else lxb_dom_node_insert_child(node, inserted); + next_sib = ref; + } else if (pos == "beforeend") { + parent = node; + prev_sib = node->last_child; + lxb_dom_node_insert_child(node, inserted); + } else if (pos == "afterend") { + parent = node->parent; + if (!parent) return JS_NULL; + prev_sib = node; + lxb_dom_node_t* ref = node->next; + if (ref) lxb_dom_node_insert_before(ref, inserted); else lxb_dom_node_insert_child(parent, inserted); + next_sib = ref; + } else { + return throw_dom_exception(ctx, "SyntaxError", ("invalid insertAdjacentElement position '" + pos + "'").c_str()); + } + + auto* rctx = get_ctx(ctx); + if (rctx && rctx->mutation_observers && !rctx->mutation_observers->empty()) { + rctx->mutation_observers->notifyChildList(ctx, parent, {inserted}, {}, prev_sib, next_sib); + } + return JS_DupValue(ctx, argv[1]); +} + +JSValue js_el_insertAdjacentText(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) { + auto* el = unwrap_element(ctx, this_val); + if (!el || argc < 2) return JS_UNDEFINED; + const char* position = JS_ToCString(ctx, argv[0]); + const char* text = JS_ToCString(ctx, argv[1]); + if (!position || !text) { + if (position) JS_FreeCString(ctx, position); + if (text) JS_FreeCString(ctx, text); + return JS_UNDEFINED; + } + std::string pos(position); + JS_FreeCString(ctx, position); + + lxb_dom_node_t* node = lxb_dom_interface_node(el); + void* text_node_v = doc_for_node(ctx, node)->createTextNode(text); + JS_FreeCString(ctx, text); + if (!text_node_v) return JS_UNDEFINED; + auto* text_node = static_cast(text_node_v); + + lxb_dom_node_t* parent = nullptr; + lxb_dom_node_t* prev_sib = nullptr; + lxb_dom_node_t* next_sib = nullptr; + + if (pos == "beforebegin") { + parent = node->parent; + if (!parent) { lxb_dom_node_destroy_deep(text_node); return JS_UNDEFINED; } + prev_sib = node->prev; + lxb_dom_node_insert_before(node, text_node); + next_sib = node; + } else if (pos == "afterbegin") { + parent = node; + lxb_dom_node_t* ref = node->first_child; + if (ref) lxb_dom_node_insert_before(ref, text_node); else lxb_dom_node_insert_child(node, text_node); + next_sib = ref; + } else if (pos == "beforeend") { + parent = node; + prev_sib = node->last_child; + lxb_dom_node_insert_child(node, text_node); + } else if (pos == "afterend") { + parent = node->parent; + if (!parent) { lxb_dom_node_destroy_deep(text_node); return JS_UNDEFINED; } + prev_sib = node; + lxb_dom_node_t* ref = node->next; + if (ref) lxb_dom_node_insert_before(ref, text_node); else lxb_dom_node_insert_child(parent, text_node); + next_sib = ref; + } else { + lxb_dom_node_destroy_deep(text_node); + return throw_dom_exception(ctx, "SyntaxError", ("invalid insertAdjacentText position '" + pos + "'").c_str()); + } + + auto* rctx = get_ctx(ctx); + if (rctx && rctx->mutation_observers && !rctx->mutation_observers->empty()) { + rctx->mutation_observers->notifyChildList(ctx, parent, {text_node}, {}, prev_sib, next_sib); + } + return JS_UNDEFINED; +} + JSValue js_el_matches(JSContext* ctx, JSValue this_val, int argc, JSValue* argv) { auto* el = unwrap_element(ctx, this_val); if (!el || argc < 1) return JS_FALSE; @@ -1603,6 +1710,8 @@ void ElementBindings::install(JSContext* ctx) { JS_SetPropertyStr(ctx, proto, "getElementsByTagName", JS_NewCFunction(ctx, js_el_getElementsByTagName, "getElementsByTagName", 1)); JS_SetPropertyStr(ctx, proto, "closest", JS_NewCFunction(ctx, js_el_closest, "closest", 1)); JS_SetPropertyStr(ctx, proto, "insertAdjacentHTML", JS_NewCFunction(ctx, js_el_insertAdjacentHTML, "insertAdjacentHTML", 2)); + JS_SetPropertyStr(ctx, proto, "insertAdjacentElement", JS_NewCFunction(ctx, js_el_insertAdjacentElement, "insertAdjacentElement", 2)); + JS_SetPropertyStr(ctx, proto, "insertAdjacentText", JS_NewCFunction(ctx, js_el_insertAdjacentText, "insertAdjacentText", 2)); JS_SetPropertyStr(ctx, proto, "append", JS_NewCFunction(ctx, js_el_append, "append", 0)); JS_SetPropertyStr(ctx, proto, "prepend", JS_NewCFunction(ctx, js_el_prepend, "prepend", 0)); JS_SetPropertyStr(ctx, proto, "getBoundingClientRect", JS_NewCFunction(ctx, js_el_getBoundingClientRect, "getBoundingClientRect", 0)); diff --git a/cpp/quickjs/bindings/EventBindings.cpp b/cpp/quickjs/bindings/EventBindings.cpp index 5171b8f..0297467 100644 --- a/cpp/quickjs/bindings/EventBindings.cpp +++ b/cpp/quickjs/bindings/EventBindings.cpp @@ -528,7 +528,7 @@ JSValue js_doc_dispatchEvent(JSContext* ctx, JSValue, int argc, JSValue* argv) { return dispatch_event_on_target(ctx, rctx, argv[0], node); } -const char* kHandlerEventTypes[] = { "click", "load", "error", "unhandledrejection" }; +const char* kHandlerEventTypes[] = { "click", "load", "error", "unhandledrejection", "change", "input", "submit", "reset" }; JSValue get_handler_prop_for_node(JSContext* ctx, RuntimeContext* rctx, void* node, const std::string& event_type) { if (!rctx || !node) return JS_NULL; @@ -684,9 +684,13 @@ void EventBindings::install(JSContext* ctx) { JS_SetPropertyStr(ctx, node_proto, "removeEventListener", JS_NewCFunction(ctx, js_el_removeEventListener, "removeEventListener", 2)); JS_SetPropertyStr(ctx, node_proto, "dispatchEvent", JS_NewCFunction(ctx, js_el_dispatchEvent, "dispatchEvent", 1)); - define_element_handler(ctx, node_proto, "onclick", 0); - define_element_handler(ctx, node_proto, "onload", 1); - define_element_handler(ctx, node_proto, "onerror", 2); + define_element_handler(ctx, node_proto, "onclick", 0); + define_element_handler(ctx, node_proto, "onload", 1); + define_element_handler(ctx, node_proto, "onerror", 2); + define_element_handler(ctx, node_proto, "onchange", 4); + define_element_handler(ctx, node_proto, "oninput", 5); + define_element_handler(ctx, node_proto, "onsubmit", 6); + define_element_handler(ctx, node_proto, "onreset", 7); JS_FreeValue(ctx, node_proto); JSValue element_proto = JS_GetClassProto(ctx, js_element_class_id); diff --git a/cpp/quickjs/bindings/WindowBindings.cpp b/cpp/quickjs/bindings/WindowBindings.cpp index c4fe9ec..5748f80 100644 --- a/cpp/quickjs/bindings/WindowBindings.cpp +++ b/cpp/quickjs/bindings/WindowBindings.cpp @@ -354,6 +354,15 @@ const char* kLocationBootstrapScript = R"JS( globalThis.Location = Location; globalThis.location = new Location(globalThis.__initialHref); delete globalThis.__initialHref; + + if (typeof document !== 'undefined') { + Object.defineProperty(document, 'location', { + get: function() { return location; }, + set: function(v) { location.href = v; }, + enumerable: true, + configurable: true, + }); + } })(); )JS"; diff --git a/example/src/__harness__/JSDOM.events.harness.ts b/example/src/__harness__/JSDOM.events.harness.ts index 35513b6..bcdea1d 100644 --- a/example/src/__harness__/JSDOM.events.harness.ts +++ b/example/src/__harness__/JSDOM.events.harness.ts @@ -199,6 +199,30 @@ describe('JSDOM events', () => { expect(JSON.parse(result)).toEqual({ log: [], onclick: null }); }); + it('el.onchange/oninput/onsubmit/onreset fire like el.onclick', async () => { + dom = JSDOM.create(` + + +
+ + `); + const result = await dom.evaluate(` + const input = document.getElementById('i'); + const form = document.getElementById('f'); + const log = []; + input.onchange = () => log.push('change'); + input.oninput = () => log.push('input'); + form.onsubmit = (e) => { e.preventDefault(); log.push('submit'); }; + form.onreset = () => log.push('reset'); + input.dispatchEvent(new Event('change')); + input.dispatchEvent(new Event('input')); + form.dispatchEvent(new Event('submit', { cancelable: true })); + form.dispatchEvent(new Event('reset')); + JSON.stringify(log); + `); + expect(JSON.parse(result)).toEqual(['change', 'input', 'submit', 'reset']); + }); + it('window.onload and document.onload share one handler slot', async () => { dom = JSDOM.create(''); const result = await dom.evaluate(` diff --git a/example/src/__harness__/JSDOM.mutation.harness.ts b/example/src/__harness__/JSDOM.mutation.harness.ts index 0a3eec7..e4b2459 100644 --- a/example/src/__harness__/JSDOM.mutation.harness.ts +++ b/example/src/__harness__/JSDOM.mutation.harness.ts @@ -91,6 +91,56 @@ describe('JSDOM DOM mutation', () => { ); }); + it('insertAdjacentElement() inserts an existing element at the four standard positions and returns it', async () => { + dom = JSDOM.create('
mid
'); + const result = await dom.evaluate(` + const div = document.getElementById('d'); + function make(id) { const e = document.createElement('i'); e.id = id; return e; } + const ab = div.insertAdjacentElement('afterbegin', make('ab')); + const be = div.insertAdjacentElement('beforeend', make('be')); + const bb = div.insertAdjacentElement('beforebegin', make('bb')); + const ae = div.insertAdjacentElement('afterend', make('ae')); + JSON.stringify({ + html: document.body.innerHTML, + returnedSameElement: ab.id === 'ab' && be.id === 'be' && bb.id === 'bb' && ae.id === 'ae', + }); + `); + expect(JSON.parse(result)).toEqual({ + html: '
mid
', + returnedSameElement: true, + }); + }); + + it('insertAdjacentElement() with an invalid position throws a SyntaxError DOMException and returns null on detached nodes', async () => { + dom = JSDOM.create('
'); + const result = await dom.evaluate(` + const div = document.getElementById('d'); + const detached = document.createElement('span'); + let caught; + try { div.insertAdjacentElement('nowhere', document.createElement('i')); } + catch (e) { caught = { name: e.name, isDOMException: e instanceof DOMException }; } + const returnedForDetachedBeforebegin = detached.insertAdjacentElement('beforebegin', document.createElement('i')); + JSON.stringify({ caught, returnedForDetachedBeforebegin }); + `); + expect(JSON.parse(result)).toEqual({ + caught: { name: 'SyntaxError', isDOMException: true }, + returnedForDetachedBeforebegin: null, + }); + }); + + it('insertAdjacentText() inserts a text node at the four standard positions', async () => { + dom = JSDOM.create('
mid
'); + const result = await dom.evaluate(` + const div = document.getElementById('d'); + div.insertAdjacentText('afterbegin', 'ab'); + div.insertAdjacentText('beforeend', 'be'); + div.insertAdjacentText('beforebegin', 'bb'); + div.insertAdjacentText('afterend', 'ae'); + document.body.innerHTML; + `); + expect(result).toBe('bb
abmidbe
ae'); + }); + it('document.createComment()/createDocumentFragment() create nodes usable with appendChild', async () => { dom = JSDOM.create('
'); const result = await dom.evaluate(` diff --git a/example/src/__harness__/JSDOM.navigator.harness.ts b/example/src/__harness__/JSDOM.navigator.harness.ts index 96fef86..ccaa061 100644 --- a/example/src/__harness__/JSDOM.navigator.harness.ts +++ b/example/src/__harness__/JSDOM.navigator.harness.ts @@ -60,6 +60,36 @@ describe('JSDOM navigator/matchMedia', () => { expect(JSON.parse(result)).toEqual({ media: '(min-width: 600px)', matches: false, changeFired: false }); }); + it('document.location mirrors window.location as the same instance', async () => { + dom = JSDOM.create('', { url: 'https://example.com/widget?a=1' }); + const result = await dom.evaluate(` + JSON.stringify({ + sameInstance: document.location === location, + href: document.location.href, + search: document.location.search, + pathname: document.location.pathname, + }); + `); + expect(JSON.parse(result)).toEqual({ + sameInstance: true, + href: 'https://example.com/widget?a=1', + search: '?a=1', + pathname: '/widget', + }); + }); + + it('setting document.location navigates like window.location.href', async () => { + dom = JSDOM.create('', { url: 'https://example.com/' }); + const result = await dom.evaluate(` + document.location = '/next-page?x=1'; + JSON.stringify({ href: location.href, docHref: document.location.href }); + `); + expect(JSON.parse(result)).toEqual({ + href: 'https://example.com/next-page?x=1', + docHref: 'https://example.com/next-page?x=1', + }); + }); + it('history.pushState/replaceState track state and length without firing popstate', async () => { dom = JSDOM.create('', { url: 'https://example.com/' }); const result = await dom.evaluate(` diff --git a/example/src/__harness__/RealWorld.harness.ts b/example/src/__harness__/RealWorld.harness.ts index 34bcdd7..720cc10 100644 --- a/example/src/__harness__/RealWorld.harness.ts +++ b/example/src/__harness__/RealWorld.harness.ts @@ -228,4 +228,229 @@ describe('Real-world scenarios', () => { `); expect(total).toBe('56.50'); }); + + // Adapted from mdn/dom-examples/url-params: builds a table of the current + // URL's query params on window 'load', mirroring how a CMS widget reads + // personalization data (coupon code, referral id) out of its own URL. + it('builds a param table from the URL on window load (adapted from mdn/dom-examples/url-params)', async () => { + dom = JSDOM.create( + ` + +

+        
+ + + `, + { url: 'https://example.com/widget?excitement=high&from=MDN' } + ); + const result = await dom.evaluate(` + JSON.stringify({ + rows: Array.from(document.querySelectorAll('.param-table tr')).map((tr) => + Array.from(tr.querySelectorAll('td')).map((td) => td.textContent) + ), + output: document.getElementById('url-output').textContent, + }); + `); + expect(JSON.parse(result)).toEqual({ + rows: [ + ['excitement', 'high'], + ['from', 'MDN'], + ], + output: 'Current URL: https://example.com/widget?excitement=high&from=MDN', + }); + }); + + // Adapted from mdn/dom-examples/web-storage: persists a widget's chosen + // color/font into localStorage and reflects it back into inline styles, + // then confirms a rebound onchange handler round-trips a new value. + it('persists widget preferences to localStorage and reflects them into styles (adapted from mdn/dom-examples/web-storage)', async () => { + dom = JSDOM.create(` + +

Sample text

+ + + + `); + const first = await dom.evaluate(` + const htmlElem = document.querySelector('html'); + const pElem = document.querySelector('p'); + const bgcolorInput = document.getElementById('bgcolor'); + const fontSelect = document.getElementById('font'); + + function setStyles() { + htmlElem.style.backgroundColor = '#' + localStorage.getItem('bgcolor'); + pElem.style.fontFamily = localStorage.getItem('font'); + } + function populateStorage() { + localStorage.setItem('bgcolor', bgcolorInput.value); + localStorage.setItem('font', fontSelect.value); + setStyles(); + } + if (!localStorage.getItem('bgcolor')) { + populateStorage(); + } else { + setStyles(); + } + bgcolorInput.onchange = populateStorage; + + JSON.stringify({ + storedColor: localStorage.getItem('bgcolor'), + storedFont: localStorage.getItem('font'), + htmlBg: htmlElem.style.backgroundColor, + pFont: pElem.style.fontFamily, + }); + `); + expect(JSON.parse(first)).toEqual({ + storedColor: 'FF0000', + storedFont: 'sans-serif', + htmlBg: '#FF0000', + pFont: 'sans-serif', + }); + + const second = await dom.evaluate(` + bgcolorInput.value = '00FF00'; + bgcolorInput.dispatchEvent(new Event('change')); + JSON.stringify({ + storedColor: localStorage.getItem('bgcolor'), + htmlBg: document.querySelector('html').style.backgroundColor, + }); + `); + expect(JSON.parse(second)).toEqual({ storedColor: '00FF00', htmlBg: '#00FF00' }); + }); + + // Adapted from mdn/dom-examples/insert-adjacent/insertAdjacentElement.html: + // clicking a box selects it, then "insert before"/"insert after" add a new + // box next to the selection — the same "insert a sibling next to the + // clicked item" pattern a CMS widget uses to grow a list around a click. + it('click-to-select then insert-before/after grows a box list around the selection (adapted from mdn/dom-examples/insert-adjacent)', async () => { + dom = JSDOM.create(` + +
+
+
+
+
+ + + + `); + const result = await dom.evaluate(` + const container = document.querySelector('section'); + let activeElem; + let nextId = 3; + + function setListener(elem) { + elem.addEventListener('click', () => { activeElem = elem; }); + } + Array.from(container.querySelectorAll('.box')).forEach(setListener); + + document.querySelector('.before').addEventListener('click', () => { + const box = document.createElement('div'); + box.className = 'box'; + box.dataset.id = String(nextId++); + if (activeElem) activeElem.insertAdjacentElement('beforebegin', box); + setListener(box); + }); + document.querySelector('.after').addEventListener('click', () => { + const box = document.createElement('div'); + box.className = 'box'; + box.dataset.id = String(nextId++); + if (activeElem) activeElem.insertAdjacentElement('afterend', box); + setListener(box); + }); + + container.querySelectorAll('.box')[1].dispatchEvent(new Event('click')); + document.querySelector('.before').dispatchEvent(new Event('click')); + document.querySelector('.after').dispatchEvent(new Event('click')); + + JSON.stringify(Array.from(container.querySelectorAll('.box')).map((b) => b.dataset.id)); + `); + expect(JSON.parse(result)).toEqual(['0', '3', '1', '4', '2']); + }); + + // Adapted from mdn/dom-examples/mediaquerylist: a responsive widget that + // reacts to matchMedia() and wires both addEventListener('change') and the + // legacy .onchange property, common in CMS widgets that adapt their layout. + it('reacts to matchMedia() results and wires both addEventListener and onchange (adapted from mdn/dom-examples/mediaquerylist)', async () => { + dom = JSDOM.create(` + +

+ + `); + const result = await dom.evaluate(` + const para = document.querySelector('p'); + const mql = window.matchMedia('(max-width: 600px)'); + + function screenTest(e) { + if (e.matches) { + para.textContent = 'narrow screen'; + document.body.style.backgroundColor = 'red'; + } else { + para.textContent = 'wide screen'; + document.body.style.backgroundColor = 'blue'; + } + } + + screenTest(mql); + mql.addEventListener('change', screenTest); + let onchangeAssigned = false; + mql.onchange = function () { onchangeAssigned = true; }; + + JSON.stringify({ + text: para.textContent, + bg: document.body.style.backgroundColor, + media: mql.media, + onchangeIsFunction: typeof mql.onchange === 'function', + }); + `); + expect(JSON.parse(result)).toEqual({ + text: 'wide screen', + bg: 'blue', + media: '(max-width: 600px)', + onchangeIsFunction: true, + }); + }); + + // Adapted from mdn/dom-examples/css-progress: reads layout geometry and + // writes it back as a CSS custom property, the "measure then react" pattern + // a widget uses even though there's no real layout engine behind the stub. + it('reads getBoundingClientRect() and writes it back as a CSS custom property (adapted from mdn/dom-examples/css-progress)', async () => { + dom = JSDOM.create(` + +
+ + `); + const result = await dom.evaluate(` + const articleElem = document.querySelector('article'); + function setContainerWidth() { + const clientWidth = articleElem.getBoundingClientRect().width; + articleElem.style.setProperty('--container-width', Math.floor(clientWidth) + 'px'); + } + setContainerWidth(); + articleElem.style.getPropertyValue('--container-width'); + `); + expect(result).toBe('0px'); + }); });