diff --git a/client/anchorableNode.js b/client/anchorableNode.js index f6fb177d..bee172e6 100644 --- a/client/anchorableNode.js +++ b/client/anchorableNode.js @@ -3,10 +3,13 @@ import router from './router' export function anchorableElement(element) { const links = element.querySelectorAll('a[href^="/"]:not([target])') for (const link of links) { - link.onclick = (event) => { - if (event.ctrlKey || event.shiftKey) return - event.preventDefault() - router.url = link.getAttribute('href') - } + if (link.dataset.nullstack) return + link.dataset.nullstack = true + link.addEventListener('click', (event) => { + if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) { + event.preventDefault() + router.url = link.getAttribute('href') + } + }) } -} \ No newline at end of file +} diff --git a/client/render.js b/client/render.js index 9ce54d9a..5b1eaf60 100644 --- a/client/render.js +++ b/client/render.js @@ -1,62 +1,64 @@ -import {isFalse, isText} from '../shared/nodes'; -import {anchorableElement} from './anchorableNode'; +import { isFalse, isText } from '../shared/nodes'; +import { anchorableElement } from './anchorableNode'; export default function render(node, options) { - if(isFalse(node) || node.type === 'head') { + if (isFalse(node) || node.type === 'head') { return document.createComment(""); } - if(isText(node)) { + if (isText(node)) { return document.createTextNode(node); } const svg = (options && options.svg) || node.type === 'svg'; let element; - if(svg) { + if (svg) { element = document.createElementNS("http://www.w3.org/2000/svg", node.type); } else { element = document.createElement(node.type); } - if(node.instance) { + if (node.instance) { node.instance._self.element = element; } - for(let name in node.attributes) { - if(name === 'html') { + for (let name in node.attributes) { + if (name === 'html') { element.innerHTML = node.attributes[name]; anchorableElement(element); - } else if(name.startsWith('on')) { - const eventName = name.replace('on', ''); - const key = '_event.' + eventName; - node[key] = (event) => { - if(node.attributes.default !== true) { - event.preventDefault(); - } - node.attributes[name]({...node.attributes, event}); - }; - element.addEventListener(eventName, node[key]); + } else if (name.startsWith('on')) { + if (node.attributes[name] !== undefined) { + const eventName = name.replace('on', ''); + const key = '_event.' + eventName; + node[key] = (event) => { + if (node.attributes.default !== true) { + event.preventDefault(); + } + node.attributes[name]({ ...node.attributes, event }); + }; + element.addEventListener(eventName, node[key]); + } } else { - const type = typeof(node.attributes[name]); - if(type !== 'object' && type !== 'function') { - if(name != 'value' && node.attributes[name] === true) { + const type = typeof (node.attributes[name]); + if (type !== 'object' && type !== 'function') { + if (name != 'value' && node.attributes[name] === true) { element.setAttribute(name, ''); - } else if(name == 'value' || (node.attributes[name] !== false && node.attributes[name] !== null && node.attributes[name] !== undefined)) { + } else if (name == 'value' || (node.attributes[name] !== false && node.attributes[name] !== null && node.attributes[name] !== undefined)) { element.setAttribute(name, node.attributes[name]); } } } } - if(!node.attributes.html) { - for(let i = 0; i < node.children.length; i++) { - const child = render(node.children[i], {svg}); + if (!node.attributes.html) { + for (let i = 0; i < node.children.length; i++) { + const child = render(node.children[i], { svg }); element.appendChild(child); } - - if(node.type == 'select') { + + if (node.type == 'select') { element.value = node.attributes.value; } } diff --git a/client/rerender.js b/client/rerender.js index 4078b867..7500b565 100644 --- a/client/rerender.js +++ b/client/rerender.js @@ -60,8 +60,8 @@ export default function rerender(selector, current, next) { if (name === 'html') { if (next.attributes[name] !== current.attributes[name]) { selector.innerHTML = next.attributes[name]; - anchorableElement(selector); } + anchorableElement(selector); } else if (name === 'checked') { if (next.attributes[name] !== selector.value) { selector.checked = next.attributes[name]; diff --git a/plugins/anchorable.js b/plugins/anchorable.js index 82fcc5c4..d3f92925 100644 --- a/plugins/anchorable.js +++ b/plugins/anchorable.js @@ -13,9 +13,10 @@ function transform({ node, router }) { const originalEvent = node.attributes.onclick node.attributes.default = true node.attributes.onclick = ({ event }) => { - if (event.ctrlKey || event.shiftKey) return - event.preventDefault() - router.url = node.attributes.href + if (!event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey) { + event.preventDefault() + router.url = node.attributes.href + } if (originalEvent) { setTimeout(() => { originalEvent({ ...node.attributes, event }) @@ -24,4 +25,4 @@ function transform({ node, router }) { } } -export default { transform, client: true } \ No newline at end of file +export default { transform, client: true } diff --git a/tests/src/AnchorModifiers.njs b/tests/src/AnchorModifiers.njs index 3fa87e44..1306e73f 100644 --- a/tests/src/AnchorModifiers.njs +++ b/tests/src/AnchorModifiers.njs @@ -6,11 +6,23 @@ class AnchorModifiers extends Nullstack { html ` - render() { + hydrate(context) { + context.self.element.querySelector('a').addEventListener('click', () => { + context.clickedHTML = true + }) + } + + clickJSX(context) { + context.clickedJSX = true + } + + render({ clickedJSX, clickedHTML }) { return ( -
+
- jsx + + jsx +
) } diff --git a/tests/src/AnchorModifiers.test.js b/tests/src/AnchorModifiers.test.js index b294fa55..1e071298 100644 --- a/tests/src/AnchorModifiers.test.js +++ b/tests/src/AnchorModifiers.test.js @@ -12,10 +12,19 @@ describe('AnchorModifiers jsx', () => { expect(url).toEqual('http://localhost:6969/anchor-modifiers'); }); - test('Clicking html link with control opens in new tab', async () => { - await page.keyboard.down('Control'); + test('Clicking html link with control or meta opens in new tab', async () => { + const key = process.platform === 'darwin' ? 'Meta' : 'Control' + await page.keyboard.down(key); await page.click('[href="/anchor-modifiers?source=html"]'); - await page.keyboard.up('Control'); + await page.keyboard.up(key); + const url = await page.url() + expect(url).toEqual('http://localhost:6969/anchor-modifiers'); + }); + + test('Clicking html link with alt downloads the link', async () => { + await page.keyboard.down('Alt'); + await page.click('[href="/anchor-modifiers?source=html"]'); + await page.keyboard.up('Alt'); const url = await page.url() expect(url).toEqual('http://localhost:6969/anchor-modifiers'); }); @@ -28,12 +37,39 @@ describe('AnchorModifiers jsx', () => { expect(url).toEqual('http://localhost:6969/anchor-modifiers'); }); - test('Clicking jsx link with control opens in new tab', async () => { - await page.keyboard.down('Control'); + test('Clicking jsx link with control or meta opens in new tab', async () => { + const key = process.platform === 'darwin' ? 'Meta' : 'Control' + await page.keyboard.down(key); await page.click('[href="/anchor-modifiers?source=jsx"]'); - await page.keyboard.up('Control'); + await page.keyboard.up(key); const url = await page.url() expect(url).toEqual('http://localhost:6969/anchor-modifiers'); }); -}); \ No newline at end of file + test('Clicking jsx link with alt downloads the link', async () => { + await page.keyboard.down('Alt'); + await page.click('[href="/anchor-modifiers?source=jsx"]'); + await page.keyboard.up('Alt'); + const url = await page.url() + expect(url).toEqual('http://localhost:6969/anchor-modifiers'); + }); + + test('Clicking html link with modifier runs the original event', async () => { + await page.keyboard.down('Shift'); + await page.click('[href="/anchor-modifiers?source=html"]'); + await page.keyboard.up('Shift'); + await page.waitForSelector('[data-clicked-html]'); + const element = await page.$('[data-clicked-html]'); + expect(element).toBeTruthy(); + }); + + test('Clicking jsx link with modifier runs the original event', async () => { + await page.keyboard.down('Shift'); + await page.click('[href="/anchor-modifiers?source=jsx"]'); + await page.keyboard.up('Shift'); + await page.waitForSelector('[data-clicked-jsx]'); + const element = await page.$('[data-clicked-jsx]'); + expect(element).toBeTruthy(); + }); + +}); diff --git a/tests/src/RoutesAndParams.njs b/tests/src/RoutesAndParams.njs index 28032c12..b0cfb15c 100644 --- a/tests/src/RoutesAndParams.njs +++ b/tests/src/RoutesAndParams.njs @@ -4,6 +4,8 @@ class RoutesAndParams extends Nullstack { eventTriggered = false; paramHydrated = false; + shownLink = true; + htmlLinkBtn = ''; hydrate(context) { const { router, params } = context; @@ -11,6 +13,8 @@ class RoutesAndParams extends Nullstack { window.addEventListener(router.event, () => { context.eventTriggered = true; }); + this.shownLink = !params.hideLink; + this.htmlLinkBtn = 'click'; } renderOther({ params }) { @@ -29,6 +33,22 @@ class RoutesAndParams extends Nullstack { ) } + renderInnerHTML({ params, route }) { + const html = ` + innerHTML + ` + return ( +
+ - -

{this.empty}

- + <>
+ + {this.visible && } ) } diff --git a/tests/src/StatefulComponent.test.js b/tests/src/StatefulComponent.test.js index b803574a..d39d4802 100644 --- a/tests/src/StatefulComponent.test.js +++ b/tests/src/StatefulComponent.test.js @@ -54,4 +54,14 @@ describe('StatefulComponent', () => { expect(text).toMatch('not'); }); + test('rendered attributes undefined values do not raise errors', async () => { + await page.click('[data-toggle]'); + await page.waitForSelector('[data-undefined-event]'); + await page.click('[data-undefined-event]'); + let hasConsoleError = false + page.on("console", () => hasConsoleError = true) + await page.waitForTimeout(2000) + expect(hasConsoleError).toBeFalsy(); + }); + }); \ No newline at end of file