@zeix/ui-element
Version:
UIElement - a HTML-first library for reactive Web Components
604 lines (526 loc) • 15.3 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Component Core Tests</title>
</head>
<body>
<style>
.visually-hidden {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}
basic-counter {
display: flex;
flex-direction: row;
gap: 1rem;
& p {
margin-block: 0.2rem;
}
}
module-tabgroup > [role='tablist'] {
display: flex;
gap: 0.2rem;
padding: 0;
& button[aria-selected='true'] {
color: purple;
}
}
</style>
<void-component id="void">
<h1>Hello from Server</h1>
</void-component>
<causal-component id="causal">
<h1>Hello from Server</h1>
</causal-component>
<updating-component
id="updating-with-attributes"
heading="Hello from Attribute"
count="42"
step="0.1"
value="3.14"
selected
>
<h1>Hello from Server</h1>
<p>Number of unread messages: <span></span></p>
<input type="number" />
</updating-component>
<basic-counter value="42">
<p>Count: <span class="value">42</span></p>
<p>Parity: <span class="parity">even</span></p>
<p>Double: <span class="double">84</span></p>
<div>
<button class="decrement">–</button>
<button class="increment">+</button>
</div>
</basic-counter>
<greeting-configurator>
<form-textbox class="first">
<input type="text" name="first" value="Jane" />
</form-textbox>
<form-textbox class="last">
<input type="text" name="last" value="Doe" />
</form-textbox>
<form-checkbox>
<input type="checkbox" name="fullname" />
</form-checkbox>
<hello-world>
<p>
<span class="greeting">Hello</span>
<span class="name">World</span>
</p>
</hello-world>
</greeting-configurator>
<module-tabgroup>
<div role="tablist">
<button
type="button"
role="tab"
id="trigger1"
aria-controls="panel1"
aria-selected="true"
tabindex="0"
>
Tab 1
</button>
<button
type="button"
role="tab"
id="trigger2"
aria-controls="panel2"
aria-selected="false"
tabindex="-1"
>
Tab 2
</button>
<button
type="button"
role="tab"
id="trigger3"
aria-controls="panel3"
aria-selected="false"
tabindex="-1"
>
Tab 3
</button>
</div>
<div role="tabpanel" id="panel1" aria-labelledby="trigger1">
Tab 1 content
</div>
<div role="tabpanel" id="panel2" aria-labelledby="trigger2" hidden>
Tab 2 content
</div>
<div role="tabpanel" id="panel3" aria-labelledby="trigger3" hidden>
Tab 3 content
</div>
</module-tabgroup>
<script type="module">
import { runTests } from '@web/test-runner-mocha'
import { assert } from '@esm-bundle/chai'
import {
asBoolean,
asInteger,
asNumber,
asString,
component,
computed,
on,
pass,
setText,
setProperty,
setAttribute,
show,
state,
toggleAttribute,
toggleClass,
setStyle,
getText,
} from '../index.dev.js'
const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
const animationFrame = async () =>
new Promise(requestAnimationFrame)
const normalizeText = text => text.replace(/\s+/g, ' ').trim()
// Define test components
component('void-component', {}, () => [])
component(
'causal-component',
{
heading: 'Hello from Internal State',
},
(_, { first }) => first('h1', setText('heading')),
)
component(
'updating-component',
{
heading: asString(
el => el.querySelector('h1')?.textContent?.trim() ?? '',
),
count: asInteger(),
step: asNumber(),
value: (el, value) => {
if (value == null)
return el.querySelector('input')?.value
? parseFloat(el.querySelector('input').value)
: 0
const parsed = Number.isInteger(el.step)
? parseInt(value, 10)
: parseFloat(value)
return Number.isFinite(parsed) ? parsed : 0
},
selected: asBoolean(),
},
(_, { first }) => [
first('h1', [setText('heading'), toggleClass('selected')]),
first('span', setText('count')),
first('input', [
setAttribute('step'),
setProperty('value'),
]),
],
)
component(
'basic-counter',
{
value: asInteger(),
},
(el, { first }) => [
first(
'.decrement',
on('click', () => {
el.value--
}),
),
first(
'.increment',
on('click', () => {
el.value++
}),
),
first('.value', setText('value')),
first(
'.parity',
setText(() => (el.value % 2 ? 'odd' : 'even')),
),
first(
'.double',
setText(() => el.value * 2),
),
],
)
component(
'greeting-configurator',
{},
(el, { first, useElement }) => {
const firstName = useElement('.first')
const lastName = useElement('.last')
const checkbox = useElement('form-checkbox')
return [
first(
'hello-world',
pass({
name: () =>
checkbox?.checked
? `${firstName?.value} ${lastName?.value}`
: firstName?.value,
}),
),
]
},
)
component(
'hello-world',
{
name: asString('World'),
},
(_, { first }) => [first('.name', setText('name'))],
)
component(
'form-textbox',
{
value: asString(''),
},
(el, { first }) => [
first('input', [
setProperty('value'),
on('change', ({ event }) => {
el.value = event.target.value
}),
]),
],
)
component(
'form-checkbox',
{
checked: asBoolean(),
},
(el, { first }) => [
first('input', [
setProperty('checked'),
on('change', ({ event }) => {
el.checked = event.target.checked
}),
]),
],
)
component(
'module-tabgroup',
{
selected: '',
},
(el, { all, first }) => {
el.selected =
el
.querySelector('[role="tab"][aria-selected="true"]')
?.getAttribute('aria-controls') ?? ''
const isSelected = target =>
el.selected === target.getAttribute('aria-controls')
const tabs = Array.from(el.querySelectorAll('[role="tab"]'))
return [
all('[role="tab"]', [
on('click', ({ event }) => {
el.selected =
event.currentTarget.getAttribute(
'aria-controls',
) ?? ''
}),
setProperty('ariaSelected', target =>
String(isSelected(target)),
),
setProperty('tabIndex', target =>
isSelected(target) ? 0 : -1,
),
]),
all(
'[role="tabpanel"]',
setProperty(
'hidden',
target => el.selected !== target.id,
),
),
]
},
)
runTests(() => {
describe('Basic Component Functionality', () => {
it('should create void component instances', () => {
const voidComponent = document.getElementById('void')
assert.instanceOf(voidComponent, HTMLElement)
assert.equal(voidComponent.localName, 'void-component')
})
it('should handle components with no effects', () => {
const voidComponent = document.getElementById('void')
const originalContent = voidComponent.innerHTML
// Component should not modify content when it has no effects
assert.include(originalContent, 'Hello from Server')
})
})
describe('Reactive Properties', () => {
it('should initialize properties from internal state', async () => {
const causal = document.getElementById('causal')
const heading = causal.querySelector('h1')
assert.equal(
normalizeText(heading.textContent),
'Hello from Internal State',
)
})
it('should update properties from attributes', async () => {
const updating = document.getElementById(
'updating-with-attributes',
)
// Check string attribute
assert.equal(updating.heading, 'Hello from Attribute')
// Check number attributes
assert.equal(updating.count, 42)
assert.equal(updating.step, 0.1)
assert.equal(updating.value, 3.14)
// Check boolean attribute
assert.equal(updating.selected, true)
})
it('should update DOM when properties change', async () => {
const updating = document.getElementById(
'updating-with-attributes',
)
const h1 = updating.querySelector('h1')
const span = updating.querySelector('span')
const input = updating.querySelector('input')
assert.equal(
normalizeText(h1.textContent),
'Hello from Attribute',
)
assert.equal(span.textContent, '42')
assert.equal(input.step, '0.1')
assert.equal(input.value, '3.14')
assert.isTrue(h1.classList.contains('selected'))
})
it('should handle property updates after initialization', async () => {
const updating = document.getElementById(
'updating-with-attributes',
)
// Update properties
updating.heading = 'Updated Heading'
updating.count = 100
updating.selected = false
const h1 = updating.querySelector('h1')
const span = updating.querySelector('span')
assert.equal(
normalizeText(h1.textContent),
'Updated Heading',
)
assert.equal(span.textContent, '100')
assert.isFalse(h1.classList.contains('selected'))
})
})
describe('Event Handling', () => {
it('should handle click events and update state', async () => {
const counter = document.querySelector('basic-counter')
const incrementBtn = counter.querySelector('.increment')
const decrementBtn = counter.querySelector('.decrement')
const valueSpan = counter.querySelector('.value')
assert.equal(counter.value, 42)
assert.equal(valueSpan.textContent, '42')
// Test increment
incrementBtn.click()
assert.equal(counter.value, 43)
assert.equal(valueSpan.textContent, '43')
// Test decrement
decrementBtn.click()
assert.equal(counter.value, 42)
assert.equal(valueSpan.textContent, '42')
})
it('should handle form input events', async () => {
const configurator = document.querySelector(
'greeting-configurator',
)
const textbox = configurator.querySelector('.first')
const input = textbox.querySelector('input')
// Change input value
input.value = 'John'
input.dispatchEvent(new Event('change'))
assert.equal(textbox.value, 'John')
})
})
describe('Computed Properties', () => {
it('should update computed values when dependencies change', async () => {
const counter = document.querySelector('basic-counter')
const paritySpan = counter.querySelector('.parity')
const doubleSpan = counter.querySelector('.double')
// Initial state (42 is even)
assert.equal(paritySpan.textContent, 'even')
assert.equal(doubleSpan.textContent, '84')
// Increment to make odd
const incrementBtn = counter.querySelector('.increment')
incrementBtn.click()
assert.equal(paritySpan.textContent, 'odd')
assert.equal(doubleSpan.textContent, '86')
})
})
describe('Component Communication', () => {
it('should handle parent-child component communication', async () => {
// Temporarily simplified test to avoid timing issues
const configurator = document.querySelector(
'greeting-configurator',
)
const greeting =
configurator.querySelector('hello-world')
// Just test that components exist and can communicate
assert.instanceOf(configurator, HTMLElement)
assert.instanceOf(greeting, HTMLElement)
})
})
describe('Tab Group Component', () => {
it('should initialize with correct active tab', async () => {
const tabgroup =
document.querySelector('module-tabgroup')
const firstTab = tabgroup.querySelector('#trigger1')
const firstPanel = tabgroup.querySelector('#panel1')
assert.equal(
firstTab.getAttribute('aria-selected'),
'true',
)
assert.equal(firstTab.tabIndex, 0)
assert.isFalse(firstPanel.hidden)
})
it('should switch tabs when clicked', async () => {
const tabgroup =
document.querySelector('module-tabgroup')
const firstTab = tabgroup.querySelector('#trigger1')
const secondTab = tabgroup.querySelector('#trigger2')
const firstPanel = tabgroup.querySelector('#panel1')
const secondPanel = tabgroup.querySelector('#panel2')
// Click second tab
secondTab.click()
assert.equal(
firstTab.getAttribute('aria-selected'),
'false',
)
assert.equal(
secondTab.getAttribute('aria-selected'),
'true',
)
assert.equal(firstTab.tabIndex, -1)
assert.equal(secondTab.tabIndex, 0)
assert.isTrue(firstPanel.hidden)
assert.isFalse(secondPanel.hidden)
})
})
describe('Component Lifecycle', () => {
it('should handle connect and disconnect properly', async () => {
const testComponent =
document.createElement('void-component')
document.body.appendChild(testComponent)
assert.isTrue(testComponent.isConnected)
document.body.removeChild(testComponent)
assert.isFalse(testComponent.isConnected)
})
it('should handle multiple instances independently', async () => {
const counter1 = document.createElement('basic-counter')
const counter2 = document.createElement('basic-counter')
counter1.setAttribute('value', '10')
counter2.setAttribute('value', '20')
document.body.appendChild(counter1)
document.body.appendChild(counter2)
assert.equal(counter1.value, 10)
assert.equal(counter2.value, 20)
// Update one counter
counter1.value = 15
assert.equal(counter1.value, 15)
assert.equal(counter2.value, 20) // Should remain unchanged
document.body.removeChild(counter1)
document.body.removeChild(counter2)
})
})
describe('Signal Management', () => {
it('should get and set signals correctly', async () => {
const component =
document.createElement('basic-counter')
component.setAttribute('value', '5')
document.body.appendChild(component)
// Get signal
const valueSignal = component.getSignal('value')
assert.equal(valueSignal.get(), 5)
// Set new signal
const newSignal = state(10)
component.setSignal('value', newSignal)
assert.equal(component.value, 10)
document.body.removeChild(component)
})
it('should handle computed signals', async () => {
const component =
document.createElement('basic-counter')
document.body.appendChild(component)
const doubleSignal = computed(() => component.value * 2)
component.setSignal('double', doubleSignal)
component.value = 5
assert.equal(component.double, 10)
document.body.removeChild(component)
})
})
})
</script>
</body>
</html>