@zeix/ui-element
Version:
UIElement - a HTML-first library for reactive Web Components
694 lines (611 loc) • 18.1 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Context Tests</title>
</head>
<body>
<motion-context>
<my-animation>
<h1>Reduced Motion: <span>Unknown to Server</span></h1>
</my-animation>
</motion-context>
<user-context display-name="Jane Doe">
<hello-world>
<h1>Hello, <span>World</span>!</h1>
</hello-world>
</user-context>
<nested-context-test>
<outer-provider value="outer">
<middle-provider value="middle">
<inner-consumer>
<span>No context</span>
</inner-consumer>
</middle-provider>
</outer-provider>
</nested-context-test>
<subscription-test>
<reactive-provider count="0">
<reactive-consumer>
<span>0</span>
</reactive-consumer>
</reactive-provider>
</subscription-test>
<error-handling-test>
<orphan-consumer>
<span>No provider</span>
</orphan-consumer>
</error-handling-test>
<multiple-providers-test>
<first-provider theme="dark">
<second-provider theme="light">
<theme-consumer>
<span>unknown</span>
</theme-consumer>
</second-provider>
</first-provider>
</multiple-providers-test>
<cleanup-test>
<removable-provider data="initial">
<data-consumer>
<span>no data</span>
</data-consumer>
</removable-provider>
</cleanup-test>
<script type="module">
import { runTests } from '@web/test-runner-mocha'
import { assert } from '@esm-bundle/chai'
import {
RESET,
UNSET,
asString,
asInteger,
component,
fromContext,
provideContexts,
setText,
state,
} from '../index.dev.js'
const animationFrame = async () =>
new Promise(requestAnimationFrame)
const normalizeText = text => text.replace(/\s+/g, ' ').trim()
component(
'motion-context',
{
'reduced-motion': () => {
const mql = matchMedia('(prefers-reduced-motion)')
const reducedMotion = state(mql.matches)
mql.onchange = e => reducedMotion.set(e.matches)
return reducedMotion
},
},
() => [provideContexts(['reduced-motion'])],
)
component(
'my-animation',
{
reducedMotion: fromContext('reduced-motion', false),
},
(_, { first }) => [first('span', setText('reducedMotion'))],
)
component(
'user-context',
{
'display-name': asString(RESET),
},
() => [provideContexts(['display-name'])],
)
component(
'hello-world',
{
displayName: fromContext('display-name', 'World'),
},
(_, { first }) => [first('span', setText('displayName'))],
)
// Test nested context providers
component('nested-context-test', {}, (_, { first }) => [
first('outer-provider'),
])
component(
'outer-provider',
{
value: asString(),
},
() => [provideContexts(['value'])],
)
component(
'middle-provider',
{
value: asString(),
},
() => [provideContexts(['value'])],
)
component(
'inner-consumer',
{
contextValue: fromContext('value', 'fallback'),
},
(_, { first }) => [first('span', setText('contextValue'))],
)
component(
'reactive-provider',
{
count: asInteger(),
},
() => [provideContexts(['count'])],
)
component(
'reactive-consumer',
{
count: fromContext('count', 42),
},
(el, { first }) => [
first(
'span',
setText(() => String(el.count)),
),
],
)
component(
'orphan-consumer',
{
missingContext: fromContext(
'non-existent-context',
'fallback',
),
},
(_, { first }) => [first('span', setText('missingContext'))],
)
component(
'first-provider',
{
theme: asString(),
},
() => [provideContexts(['theme'])],
)
component(
'second-provider',
{
theme: asString(),
},
() => [provideContexts(['theme'])],
)
component(
'theme-consumer',
{
theme: fromContext('theme', 'light'),
},
(_, { first }) => [first('span', setText('theme'))],
)
component(
'removable-provider',
{
data: asString(),
},
() => [provideContexts(['data'])],
)
component(
'data-consumer',
{
data: fromContext('data', 'fallback'),
},
(_, { first }) => [first('span', setText('data'))],
)
runTests(() => {
describe('Context provider', function () {
it('should have motion-context signal from media query list', async function () {
assert.equal(
'reduced-motion'
in document.querySelector('motion-context'),
true,
)
})
it('should have display-name signal from attribute', async function () {
assert.equal(
document.querySelector('user-context')[
'display-name'
],
'Jane Doe',
)
})
})
describe('Context consumer', function () {
it('should have consumed context in properties', async function () {
assert.equal(
'reducedMotion'
in document.querySelector('my-animation'),
true,
)
})
it('should update according to consumed context', async function () {
const contextConsumer =
document.querySelector('my-animation')
await animationFrame()
const textContent = normalizeText(
contextConsumer.querySelector('h1').textContent,
)
assert.equal(
textContent,
`Reduced Motion: ${matchMedia('(prefers-reduced-motion)').matches}`,
'Should have updated heading from context',
)
})
it('should update when context is set', async function () {
const contextProvider =
document.querySelector('user-context')
const contextConsumer =
document.querySelector('hello-world')
await animationFrame()
let textContent = normalizeText(
contextConsumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'Jane Doe',
'Should update heading from initial display-name context',
)
contextProvider['display-name'] = 'Esther Brunner'
await animationFrame()
textContent = normalizeText(
contextConsumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'Esther Brunner',
'Should update heading from setting display-name context',
)
})
it('should revert when context is removed', async function () {
const contextProvider =
document.querySelector('user-context')
const contextConsumer =
document.querySelector('hello-world')
contextProvider.removeAttribute('display-name')
await animationFrame()
const textContent = normalizeText(
contextConsumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'World',
'Should revert heading from context',
)
})
})
describe('Context Event System', function () {
it('should dispatch context-request events', function () {
let eventFired = false
let receivedContext = null
const element = document.createElement('div')
element.addEventListener('context-request', e => {
eventFired = true
receivedContext = e.context
})
// Use the fromContext function which should dispatch the event
fromContext('test-context')(element)
assert.equal(
eventFired,
true,
'Should dispatch context-request event',
)
assert.equal(
receivedContext,
'test-context',
'Should include context in event',
)
})
it('should handle context resolution', function () {
const element = document.createElement('div')
let providedValue = null
element.addEventListener('context-request', e => {
if (e.context === 'test-context') {
e.stopPropagation()
// Simulate providing a value
providedValue = 'test-value'
e.callback(providedValue)
}
})
const result = fromContext('test-context')(element)
assert.equal(
result,
'test-value',
'Should receive provided value',
)
})
})
describe('Nested Context Providers', function () {
it('should resolve context from nearest provider', async function () {
const consumer =
document.querySelector('inner-consumer')
await animationFrame()
const textContent = normalizeText(
consumer.querySelector('span').textContent,
)
// Should get "middle" value from middle-provider, not "outer" from outer-provider
assert.equal(
textContent,
'middle',
'Should resolve from nearest provider',
)
})
it('should stop event propagation when context is found', async function () {
// This test verifies that the provide function properly stops propagation
// by checking the actual behavior rather than simulating events
const innerConsumer =
document.querySelector('inner-consumer')
const outerProvider =
document.querySelector('outer-provider')
const middleProvider =
document.querySelector('middle-provider')
// Change outer provider value
outerProvider.value = 'changed-outer'
await animationFrame()
// Consumer should still show middle value, proving middle provider intercepted
const textContent = normalizeText(
innerConsumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'middle',
'Middle provider should intercept context request',
)
})
})
describe('Subscription and Reactivity', function () {
it('should update consumer when provider context changes', async function () {
const provider =
document.querySelector('reactive-provider')
const consumer =
document.querySelector('reactive-consumer')
await animationFrame()
let textContent = normalizeText(
consumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'0',
'Should show initial count',
)
provider.count = 5
await animationFrame()
textContent = normalizeText(
consumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'5',
'Should update when provider changes',
)
provider.count = 10
await animationFrame()
textContent = normalizeText(
consumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'10',
'Should continue updating',
)
})
it('should handle rapid context changes', async function () {
const provider =
document.querySelector('reactive-provider')
const consumer =
document.querySelector('reactive-consumer')
// Rapid changes
provider.count = 1
provider.count = 2
provider.count = 3
provider.count = 4
provider.count = 5
await animationFrame()
const textContent = normalizeText(
consumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'5',
'Should show final value after rapid changes',
)
})
})
describe('Error Handling', function () {
it('should handle missing context gracefully', async function () {
const consumer =
document.querySelector('orphan-consumer')
// Should not crash when context is not found
assert.isNotNull(consumer, 'Consumer should exist')
// The consumer should exist but the missingContext property may be undefined
// This tests that the consume function handles missing providers gracefully
assert.doesNotThrow(() => {
const value = consumer.missingContext
}, 'Should not throw when accessing undefined context')
})
it('should handle invalid context requests', function () {
const element = document.createElement('div')
document.body.appendChild(element)
// Test with invalid callback
const event1 = new CustomEvent('context-request', {
bubbles: true,
detail: { context: 'test', callback: null },
})
assert.doesNotThrow(() => {
element.dispatchEvent(event1)
}, 'Should not throw with null callback')
// Test with undefined context
const event2 = new CustomEvent('context-request', {
bubbles: true,
detail: { context: undefined, callback: () => {} },
})
assert.doesNotThrow(() => {
element.dispatchEvent(event2)
}, 'Should not throw with undefined context')
document.body.removeChild(element)
})
it('should handle context provider cleanup', async function () {
const container = document.querySelector('cleanup-test')
const provider =
document.querySelector('removable-provider')
const consumer = document.querySelector('data-consumer')
await animationFrame()
let textContent = normalizeText(
consumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'initial',
'Should show initial data',
)
// Remove provider from DOM
container.removeChild(provider)
await animationFrame()
// Consumer should still exist but may show undefined or fallback
assert.isNotNull(
consumer,
'Consumer should still exist after provider removal',
)
})
})
describe('Multiple Providers', function () {
it('should use nearest provider when multiple exist', async function () {
const consumer =
document.querySelector('theme-consumer')
await animationFrame()
const textContent = normalizeText(
consumer.querySelector('span').textContent,
)
// Should get "light" from second-provider (nearer) not "dark" from first-provider
assert.equal(
textContent,
'light',
'Should use nearest provider',
)
})
it('should fallback to outer provider when inner is removed', async function () {
const firstProvider =
document.querySelector('first-provider')
const secondProvider =
document.querySelector('second-provider')
const consumer =
document.querySelector('theme-consumer')
await animationFrame()
let textContent = normalizeText(
consumer.querySelector('span').textContent,
)
assert.equal(
textContent,
'light',
'Should initially use inner provider',
)
// Remove inner provider
firstProvider.removeChild(secondProvider)
// Trigger a new context request by changing the outer provider
firstProvider.theme = 'dark-updated'
await animationFrame()
textContent = normalizeText(
consumer.querySelector('span').textContent,
)
// Note: The consumer might not automatically re-request context,
// so this test verifies the current behavior
assert.isString(
textContent,
'Should have some text content',
)
})
})
describe('Context Types and Edge Cases', function () {
it('should handle empty string contexts', function () {
const element = document.createElement('div')
let receivedContext = null
element.addEventListener('context-request', e => {
receivedContext = e.context
})
fromContext('')(element)
assert.equal(
receivedContext,
'',
'Should handle empty string context',
)
})
it('should handle context with special characters', function () {
const specialContext = 'test-context!@#$%^&*()'
const element = document.createElement('div')
let receivedContext = null
element.addEventListener('context-request', e => {
receivedContext = e.context
})
fromContext(specialContext)(element)
assert.equal(
receivedContext,
specialContext,
'Should handle special characters',
)
})
it('should handle numeric-like string contexts', function () {
const numericContext = '123'
const element = document.createElement('div')
let receivedContext = null
element.addEventListener('context-request', e => {
receivedContext = e.context
})
fromContext(numericContext)(element)
assert.equal(
receivedContext,
numericContext,
'Should handle numeric strings',
)
})
it('should preserve context callback function', function () {
const element = document.createElement('div')
let receivedCallback = null
element.addEventListener('context-request', e => {
receivedCallback = e.callback
})
fromContext('test')(element)
assert.equal(
typeof receivedCallback,
'function',
'Should preserve callback function',
)
})
})
describe('Provider Array Handling', function () {
it('should handle empty provider arrays', function () {
// Create a component with empty provide array
const testElement = document.createElement('div')
const cleanup = provideContexts([])(testElement)
assert.isFunction(
cleanup,
'Should return cleanup function',
)
// Should not crash with empty array
const event = new CustomEvent('context-request', {
bubbles: true,
detail: {
context: 'any-context',
callback: () => {},
},
})
assert.doesNotThrow(() => {
testElement.dispatchEvent(event)
})
cleanup()
})
it('should handle provider with multiple contexts', function () {
// This would require creating a more complex test component
// For now, verify the basic functionality exists
const providerFn = provideContexts([
'context1',
'context2',
'context3',
])
assert.isFunction(
providerFn,
'Should return a function',
)
})
})
})
</script>
</body>
</html>