@zeix/ui-element
Version:
UIElement - minimal reactive framework based on Web Components
531 lines (456 loc) • 20.2 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Effects Tests</title>
</head>
<body>
<update-text text="Text from Attribute">
<p>Text from Server</p>
</update-text>
<update-property value="Value from Attribute">
<input type="text" value="Value from Server">
</update-property>
<update-attribute required>
<input type="text" required>
<p id="update-attribute-error">Please fill this required field</p>
</update-attribute>
<update-class active="0">
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li class="selected">Item 3</li>
</ul>
</update-class>
<update-style color="red">
<p style="color: blue">Text from Server</p>
</update-style>
<dangerous-inner-html id="dangerous"></dangerous-inner-html>
<shadow-dangerous-inner-html id="shadow-dangerous"></shadow-dangerous-inner-html>
<dangerous-with-scripts></dangerous-with-scripts>
<create-element>
<ul></ul>
</create-element>
<remove-element>
<ul>
<li data-value="1">Item 1</li>
<li data-value="2">Item 2</li>
<li data-value="3">Item 3</li>
</ul>
</remove-element>
<insert-template id="insert-light">
<template class="li">
<li></li>
</template>
<template class="p">
<p></p>
</template>
<ul></ul>
</insert-template>
<insert-template id="insert-shadow">
<template class="li">
<li></li>
</template>
<template class="p">
<p></p>
</template>
<template shadowrootmode="open">
<ul></ul>
</template>
</insert-template>
<script type="module">
import { runTests } from '@web/test-runner-mocha'
import { assert } from '@esm-bundle/chai'
import {
UIElement, effect, RESET, UNSET,
asBoolean, asInteger, asNumber,
setText, setProperty, setAttribute, toggleAttribute, toggleClass, setStyle,
dangerouslySetInnerHTML, createElement, removeElement, insertTemplate
} from '../'
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
const animationFrame = async () => new Promise(requestAnimationFrame)
const normalizeText = text => text.replace(/\s+/g, ' ').trim()
class UpdateText extends UIElement {
static observedAttributes = ['text']
connectedCallback() {
this.first('p').sync(setText('text'))
}
}
UpdateText.define('update-text')
class UpdateProperty extends UIElement {
static observedAttributes = ['value']
connectedCallback() {
this.first('input').sync(setProperty('value'))
}
}
UpdateProperty.define('update-property')
class UpdateAttribute extends UIElement {
static observedAttributes = ['required']
init = {
required: asBoolean,
ariaInvalid: 'false'
}
connectedCallback() {
super.connectedCallback()
this.first('input')
.on('change', e => {
this.set('ariaInvalid', String(!e.target.checkValidity()))
})
.sync(
toggleAttribute('required'),
setProperty('ariaInvalid'),
setAttribute(
'aria-errormessage',
() => this.get('required')
&& this.get('ariaInvalid') !== 'false'
? this.querySelector('p').id
: RESET
)
)
}
}
UpdateAttribute.define('update-attribute')
class UpdateClass extends UIElement {
static observedAttributes = ['active']
init = {
active: asInteger()
}
connectedCallback() {
this.all('li')
.sync(toggleClass(
'selected',
(_, index) => this.get('active') === index
))
}
}
UpdateClass.define('update-class')
class UpdateStyle extends UIElement {
static observedAttributes = ['color']
connectedCallback() {
this.first('p').sync(setStyle('color'))
}
}
UpdateStyle.define('update-style')
class DangerousInnerHTMLComponent extends UIElement {
init = {
content: '<p>Initial content</p>'
}
connectedCallback() {
super.connectedCallback()
this.self.sync(dangerouslySetInnerHTML('content'))
}
}
DangerousInnerHTMLComponent.define('dangerous-inner-html')
class ShadowDangerousInnerHTMLComponent extends UIElement {
init = {
content: '<p>Initial shadow content</p>'
}
connectedCallback() {
super.connectedCallback()
this.self.sync(dangerouslySetInnerHTML('content', 'open'))
}
}
ShadowDangerousInnerHTMLComponent.define('shadow-dangerous-inner-html')
class DangerousWithScripts extends UIElement {
init = {
content: '<p id="test-p-shadow">Original</p>'
}
connectedCallback() {
super.connectedCallback()
this.self.sync(dangerouslySetInnerHTML('content', 'open', true))
}
}
DangerousWithScripts.define('dangerous-with-scripts')
class CreateElement extends UIElement {
init = {
before: false,
prepend: false,
append: false,
after: false
}
connectedCallback() {
super.connectedCallback()
this.first('ul').sync(
createElement('p', 'before', 'beforebegin', {}, 'Before'),
createElement('li', 'prepend', 'afterbegin', { value: 'foo' }, 'Prepend'),
createElement('li', 'append'),
createElement('p', 'after', 'afterend', { value: 'bar' })
)
}
}
CreateElement.define('create-element')
class RemoveElement extends UIElement {
init = {
items: [1, 2, 3]
}
connectedCallback() {
super.connectedCallback()
this.all('li').sync(
removeElement((_, index) => !this.get('items').includes(index + 1)),
)
}
}
RemoveElement.define('remove-element')
class InsertTemplate extends UIElement {
init = {
before: false,
prepend: false,
append: false,
after: false
}
connectedCallback() {
super.connectedCallback()
const pTemplate = this.querySelector('.p')
const liTemplate = this.querySelector('.li')
this.first('ul').sync(
insertTemplate(pTemplate, 'before', 'beforebegin'),
insertTemplate(liTemplate, 'prepend', 'afterbegin'),
insertTemplate(liTemplate, 'append'),
insertTemplate(pTemplate, 'after', 'afterend')
)
}
}
InsertTemplate.define('insert-template')
runTests(() => {
describe('setText', function () {
it('should prove setText() working correctly', async function () {
const component = document.querySelector('update-text')
const paragraph = component.querySelector('p')
await animationFrame()
let textContent = normalizeText(paragraph.textContent)
assert.equal(textContent, 'Text from Attribute', 'Should display text content from attribute')
component.set('text', 'New Text')
await animationFrame()
textContent = normalizeText(paragraph.textContent)
assert.equal(textContent, 'New Text', 'Should update text content from text signal')
component.set('text', RESET)
await animationFrame()
textContent = normalizeText(paragraph.textContent)
assert.equal(textContent, 'Text from Server', 'Should revert text content to server-rendered version')
})
})
describe('setProperty', function () {
it('should prove setProperty() working correctly', async function () {
const component = document.querySelector('update-property')
const input = component.querySelector('input')
await animationFrame()
assert.equal(input.value, 'Value from Attribute', 'Should display value from attribute')
component.set('value', 'New Value')
await animationFrame()
assert.equal(input.value, 'New Value', 'Should update value from text signal')
component.set('value', RESET)
await animationFrame()
assert.equal(input.value, 'Value from Server', 'Should revert value to server-rendered version')
})
})
describe('setAttribute and toggleAttribute', function () {
it('should prove setAttribute() and toggleAttribute() working correctly', async function () {
const component = document.querySelector('update-attribute')
const input = component.querySelector('input')
await animationFrame()
assert.equal(input.required, true, 'Should set required property from attribute')
assert.equal(input.hasAttribute('aria-errormessage'), false, 'Should not have aria-errormessage before interaction')
input.value = 'New Value'
input.dispatchEvent(new Event('change'))
await animationFrame()
assert.equal(input.hasAttribute('aria-errormessage'), false, 'Should not have aria-errormessage if field is not empty')
input.value = ''
input.dispatchEvent(new Event('change'))
await animationFrame()
assert.equal(input.hasAttribute('aria-errormessage'), true, 'Should have aria-errormessage if field is empty and required')
component.toggleAttribute('required')
await animationFrame()
assert.equal(input.hasAttribute('aria-errormessage'), false, 'Should not have aria-errormessage if field is not required')
component.set('required', RESET)
await animationFrame()
assert.equal(input.required, true, 'Should revert required attribute to server-rendered version')
})
})
describe('toggleClass', function () {
it('should prove toggleClass() working correctly', async function () {
const component = document.querySelector('update-class')
const items = Array.from(component.querySelectorAll('li'))
await animationFrame()
assert.equal(items[0].classList.contains('selected'), true, 'First item should have selected class from active attribute')
assert.equal(items[2].classList.contains('selected'), false, 'Third item should not have selected class removed')
component.set('active', 1)
await animationFrame()
assert.equal(items[1].classList.contains('selected'), true, 'Second item should have selected class from active signal')
assert.equal(items[0].classList.contains('selected'), false, 'First item should not have selected class removed')
component.set('active', RESET)
await animationFrame()
assert.equal(items[1].classList.contains('selected'), false, 'Second item should have selected class removed')
// restore can't work because the selected class for each item is derived on the fly and not stored in a signal
// assert.equal(items[2].classList.contains('selected'), true, 'Third item should not have selected class restored to server-rendered version')
})
})
describe('setStyle', function () {
it('should prove setStyle() working correctly', async function () {
const component = document.querySelector('update-style')
const paragraph = component.querySelector('p')
await animationFrame()
assert.equal(paragraph.style.color, 'red', 'Should set color from attribute')
component.set('color', 'green')
await animationFrame()
assert.equal(paragraph.style.color,'green', 'Should update color from color signal')
component.set('color', RESET)
await animationFrame()
assert.equal(paragraph.style.color, 'blue', 'Should revert color to server-rendered version')
})
})
describe('dangerouslySetInnerHTML', () => {
let dangerous, shadowDangerous
before(() => {
dangerous = document.getElementById('dangerous')
shadowDangerous = document.getElementById('shadow-dangerous')
})
it('should set inner HTML for non-shadow component', async () => {
assert.equal(dangerous.innerHTML, '<p>Initial content</p>')
dangerous.set('content', '<div>Updated content</div>')
await animationFrame()
assert.equal(dangerous.innerHTML, '<div>Updated content</div>')
})
it('should set inner HTML for shadow component', async () => {
assert.equal(shadowDangerous.shadowRoot.innerHTML, '<p>Initial shadow content</p>')
shadowDangerous.set('content', '<div>Updated shadow content</div>')
await animationFrame()
assert.equal(shadowDangerous.shadowRoot.innerHTML, '<div>Updated shadow content</div>')
})
it('should ignore empty content', async () => {
dangerous.set('content', '')
await animationFrame()
assert.equal(dangerous.innerHTML, '<div>Updated content</div>')
shadowDangerous.set('content', '')
await animationFrame()
assert.equal(shadowDangerous.shadowRoot.innerHTML, '<div>Updated shadow content</div>')
})
it('should not execute scripts by default, but allow execution when specified', async () => {
const scriptContent = 'ipt>document.getElementById("test-p").textContent = "Modified";</scr'
const shadowScriptContent = 'ipt>document.querySelector("dangerous-with-scripts").shadowRoot.getElementById("test-p-shadow").textContent = "Modified";</scr'
// Test default behavior (scripts not executed)
dangerous.set('content', `<p id="test-p">Original</p><scr${scriptContent}ipt>`)
await animationFrame()
assert.equal(dangerous.querySelector('#test-p').textContent, 'Original', 'Script should not modify content by default')
const dangerousWithScripts = document.querySelector('dangerous-with-scripts')
dangerousWithScripts.set('content', `<p id="test-p-shadow">Original</p><scr${shadowScriptContent}ipt>`)
await animationFrame()
assert.equal(dangerousWithScripts.shadowRoot.querySelector('#test-p-shadow').textContent, 'Modified', 'Script should modify content when allowScripts is true')
})
})
describe('createElement', function () {
let createElementComponent;
before(() => {
createElementComponent = document.querySelector('create-element');
});
it('should insert a paragraph before the UL', async function () {
createElementComponent.set('before', true);
await animationFrame();
const insertedParagraph = createElementComponent.querySelector('p:first-child');
assert.isNotNull(insertedParagraph, 'Paragraph should be inserted before the UL');
assert.equal(insertedParagraph.textContent, 'Before', 'Paragraph should have correct text content');
assert.equal(createElementComponent.get('before'), false, 'Before signal should be reset to false');
});
it('should insert a LI at the beginning of the UL', async function () {
createElementComponent.set('prepend', true);
await animationFrame();
const insertedLi = createElementComponent.querySelector('ul li:first-child');
assert.isNotNull(insertedLi, 'LI should be inserted at the beginning of the UL');
assert.equal(insertedLi.textContent, 'Prepend', 'LI should have correct text content');
assert.equal(insertedLi.getAttribute('value'), 'foo', 'LI should have correct attribute');
assert.equal(createElementComponent.get('prepend'), false, 'Prepend signal should be reset to false');
});
it('should insert a LI at the end of the UL', async function () {
createElementComponent.set('append', true);
await animationFrame();
const insertedLi = createElementComponent.querySelector('ul li:last-child');
assert.isNotNull(insertedLi, 'LI should be inserted at the end of the UL');
assert.equal(insertedLi.textContent, '', 'LI should have empty text content');
assert.equal(createElementComponent.get('append'), false, 'Append signal should be reset to false');
});
it('should insert a paragraph after the UL', async function () {
createElementComponent.set('after', true);
await animationFrame();
const insertedParagraph = createElementComponent.querySelector('ul + p');
assert.isNotNull(insertedParagraph, 'Paragraph should be inserted after the UL');
assert.equal(insertedParagraph.textContent, '', 'Paragraph should have empty text content');
assert.equal(insertedParagraph.getAttribute('value'), 'bar', 'Paragraph should have correct attribute');
assert.equal(createElementComponent.get('after'), false, 'After signal should be reset to false');
});
it('should allow re-triggering effects', async function () {
// Re-trigger the 'before' effect
createElementComponent.set('before', true);
await animationFrame();
const beforeParagraph2 = createElementComponent.querySelector('p:first-child + p');
assert.isNotNull(beforeParagraph2, 'LI should be inserted at the beginning of the UL');
// Re-trigger the 'prepend' effect
createElementComponent.set('prepend', true);
await animationFrame();
const prependLis = createElementComponent.querySelectorAll('li[value="foo"]');
assert.equal(prependLis.length, 2, 'Should insert another LI at the beginning of the UL');
// Verify that all signals are reset to false
assert.equal(createElementComponent.get('before'), false, 'Before signal should be reset to false');
assert.equal(createElementComponent.get('prepend'), false, 'Prepend signal should be reset to false');
assert.equal(createElementComponent.get('append'), false, 'Append signal should be reset to false');
assert.equal(createElementComponent.get('after'), false, 'After signal should be reset to false');
});
});
describe('removeElement', function () {
let removeElementComponent;
before(() => {
removeElementComponent = document.querySelector('remove-element');
});
it('should remove an item using immutable update (toSpliced)', async function () {
removeElementComponent.set('items', v => v.toSpliced(1, 1));
await animationFrame();
const items = removeElementComponent.querySelectorAll('li');
assert.equal(items.length, 2, 'Should have 2 items after removal');
assert.equal(items[0].textContent, 'Item 1', 'First item should remain');
assert.equal(items[1].textContent, 'Item 3', 'Third item should now be second');
});
/** Mutable updates don't work
* @TODO log a warning
it('should remove an item using mutable update (splice)', async function () {
removeElementComponent.set('items', v => v.splice(0, 1));
await animationFrame();
const items = removeElementComponent.querySelectorAll('li');
assert.equal(items.length, 1, 'Should have 1 item after removal');
assert.equal(items[0].textContent, 'Item 3', 'Third item should remain last');
}); */
it('should handle removing all items', async function () {
removeElementComponent.set('items', []);
await animationFrame();
const items = removeElementComponent.querySelectorAll('li');
assert.equal(items.length, 0, 'Should remove all items');
});
});
describe('insertTemplate', function () {
const testInsertTemplate = async (id) => {
const component = document.getElementById(id);
const root = id === 'insert-shadow' ? component.shadowRoot : component;
const ul = root.querySelector('ul');
await animationFrame();
assert.equal(ul.children.length, 0, 'Initially, ul should be empty');
component.set('before', true);
await animationFrame();
assert.equal(ul.previousElementSibling.tagName, 'P', 'Should insert p element before ul');
component.set('prepend', true);
await animationFrame();
assert.equal(ul.firstElementChild.tagName, 'LI', 'Should prepend li element to ul');
component.set('append', true);
await animationFrame();
assert.equal(ul.lastElementChild.tagName, 'LI', 'Should append li element to ul');
component.set('after', true);
await animationFrame();
assert.equal(ul.nextElementSibling.tagName, 'P', 'Should insert p element after ul');
};
it('should prove insertTemplate() working correctly in light DOM', async function () {
await testInsertTemplate('insert-light');
});
it('should prove insertTemplate() working correctly in Shadow DOM', async function () {
await testInsertTemplate('insert-shadow');
});
});
})
</script>
</body>
</html>