UNPKG

@zeix/ui-element

Version:

UIElement - a HTML-first library for reactive Web Components

1,991 lines 55.1 kB
<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<title>UI Functions Tests</title>
	</head>
	<body>
		<div class="test-element">Content</div>
		<read-component id="read" value="test-value"></read-component>
		<test-signal-producers-integration>
			<div class="item" data-value="10">Item 1</div>
			<div class="item" data-value="20">Item 2</div>
			<input type="text" />
			<p></p>
		</test-signal-producers-integration>
		<module-todo-test id="todo-test">
			<form-radiogroup value="all" class="filter">
				<fieldset>
					<legend>Filter</legend>
					<label class="selected">
						<input type="radio" name="filter" value="all" checked />
						<span>All</span>
					</label>
					<label>
						<input type="radio" name="filter" value="active" />
						<span>Active</span>
					</label>
					<label>
						<input type="radio" name="filter" value="completed" />
						<span>Completed</span>
					</label>
				</fieldset>
			</form-radiogroup>
			<div class="filter-display"></div>
		</module-todo-test>

		<script type="module">
			import { runTests } from '@web/test-runner-mocha'
			import { assert } from '@esm-bundle/chai'
			import {
				RESET,
				asString,
				component,
				computed,
				effect,
				fromEvents,
				fromSelector,
				on,
				read,
				reduced,
				requireDescendant,
				setText,
				state,
			} from '../index.dev.js'

			const wait = ms => new Promise(resolve => setTimeout(resolve, ms))
			const animationFrame = async () =>
				new Promise(requestAnimationFrame)
			const microtask = async () => new Promise(queueMicrotask)
			const normalizeText = text => text.replace(/\s+/g, ' ').trim()

			// Helper to wait for component to be fully upgraded with signal methods
			const waitForComponentUpgrade = async (element, maxWait = 1000) => {
				if (!element) return false

				await customElements.whenDefined(element.localName)
				await animationFrame()

				// Poll until getSignal method is available or timeout
				const startTime = Date.now()
				while (Date.now() - startTime < maxWait) {
					if (typeof element.getSignal === 'function') {
						return true
					}
					await wait(10)
				}
				return false
			}

			component(
				'read-component',
				{
					value: asString(),
				},
				() => [],
			)

			component(
				'form-textbox',
				{
					value: asString(),
					length: 0,
				},
				(el, { first }) => [
					first(
						'input',
						on('input', e => {
							el.length = e.target.value.length
						}),
						on('change', e => {
							el.value = e.target.value
						}),
					),
				],
			)

			component(
				'test-signal-producers-integration',
				{
					items: fromSelector('.item'),
					total: el =>
						reduced(
							el,
							'[data-value]',
							(sum, element) =>
								sum + parseInt(element.dataset.value || '0'),
							0,
						),
					lastInput: fromEvents('', 'input', {
						input: ({ target }) => target.value,
					}),
				},
				(_, { first }) => [first('p', setText('lastInput'))],
			)

			// Define form-radiogroup component for testing
			component(
				'form-radiogroup',
				{
					value: asString(),
				},
				(el, { all }) => [
					all(
						'input[type="radio"]',
						on('change', e => {
							if (e.target.checked) {
								el.value = e.target.value
							}
						}),
					),
				],
			)

			// Define module-todo-test component that reads from form-radiogroup
			component('module-todo-test', {}, (el, { first }) => {
				const filter = el.querySelector('form-radiogroup')

				return [
					first(
						'.filter-display',
						setText(read(filter, 'value', 'all')),
					),
				]
			})

			class InvalidComponent extends HTMLElement {
				constructor() {
					super()
					throw new Error('Invalid component')
				}
			}
			customElements.define('invalid-component', InvalidComponent)

			runTests(() => {
				describe('fromSelector()', () => {
					it('should create a signal returning an empty array for no matching elements', () => {
						const signal = fromSelector('.nonexistent')(document)
						assert.deepEqual(signal.get(), [])
					})

					it('should return an array of elements matching the selector', () => {
						const signal = fromSelector('.test-element')(document)
						const elements = Array.from(
							document.querySelectorAll('.test-element'),
						)
						assert.deepEqual(signal.get(), elements)
					})

					it('should update the signal when elements are added or removed', () => {
						const signal = fromSelector('.test-element')(document)

						const newElement = document.createElement('div')
						newElement.classList.add('test-element')
						document.body.appendChild(newElement)
						let elements = Array.from(
							document.querySelectorAll('.test-element'),
						)
						assert.deepEqual(signal.get(), elements)

						newElement.remove()
						elements = Array.from(
							document.querySelectorAll('.test-element'),
						)
						assert.deepEqual(signal.get(), elements)
					})

					it('should update the signal when matching class is added or removed', () => {
						const signal = fromSelector('.test-element')(document)
						const newElement = document.createElement('div')
						document.body.appendChild(newElement)
						let elements = Array.from(
							document.querySelectorAll('.test-element'),
						)
						assert.deepEqual(signal.get(), elements)

						newElement.classList.add('test-element')
						elements = Array.from(
							document.querySelectorAll('.test-element'),
						)
						assert.deepEqual(signal.get(), elements)

						newElement.classList.remove('test-element')
						elements = Array.from(
							document.querySelectorAll('.test-element'),
						)
						assert.deepEqual(signal.get(), elements)
					})

					it('should update the signal when matching id is added or removed', () => {
						const signal = fromSelector('#test-element')(document)
						const newElement = document.createElement('div')
						document.body.appendChild(newElement)
						let elements = Array.from(
							document.querySelectorAll('#test-element'),
						)
						assert.deepEqual(signal.get(), elements)

						newElement.id = 'test-element'
						elements = Array.from(
							document.querySelectorAll('#test-element'),
						)
						assert.deepEqual(signal.get(), elements)

						newElement.removeAttribute('id')
						elements = Array.from(
							document.querySelectorAll('#test-element'),
						)
						assert.deepEqual(signal.get(), elements)
					})

					it('should update the computed signal watching the element selection when elements are added or removed', async () => {
						const signal = fromSelector('.test-element')(document)
						const contents = computed(elements =>
							signal.get().map(element => element.textContent),
						)

						// Wait for initial setup
						await microtask()
						assert.deepEqual(contents.get(), ['Content'])

						const newElement = document.createElement('div')
						newElement.textContent = 'New Content'
						newElement.classList.add('test-element')
						document.body.appendChild(newElement)
						await microtask()
						assert.deepEqual(contents.get(), [
							'Content',
							'New Content',
						])

						newElement.remove()
						await microtask()
						assert.deepEqual(contents.get(), ['Content'])
					})

					it('should apply the effect to an updated array of elements when elements are added or removed', async () => {
						const signal = fromSelector('.test-element')(document)

						// Reset hidden state first
						document
							.querySelectorAll('.test-element')
							.forEach(element => {
								element.hidden = false
							})

						const cleanup = effect({
							signals: [signal],
							ok: elements =>
								elements
									.filter(element => !element.hidden)
									.map(element => {
										element.hidden = true
									}),
						})

						// Wait for initial effect to run
						await microtask()

						const newElement = document.createElement('div')
						newElement.textContent = 'New Content'
						newElement.classList.add('test-element')
						document.body.appendChild(newElement)
						await microtask()
						let expected = Array.from(
							document.querySelectorAll('.test-element'),
						).map(element => element.hidden)
						assert.deepEqual(expected, [true, true])
						document
							.querySelectorAll('.test-element')
							.forEach(element => {
								element.hidden = false
							})

						newElement.remove()
						await microtask()
						expected = Array.from(
							document.querySelectorAll('.test-element'),
						).map(element => element.hidden)
						assert.deepEqual(expected, [true])
						document
							.querySelectorAll('.test-element')
							.forEach(element => {
								element.hidden = false
							})

						cleanup()
					})

					it('should handle complex selectors with multiple attributes', async () => {
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							const signal = fromSelector(
								'div.active[role="button"][data-test="value"]',
							)(container)

							// Initially empty
							assert.deepEqual(signal.get(), [])

							// Add element that partially matches
							const partial = document.createElement('div')
							partial.setAttribute('data-test', 'value')
							container.appendChild(partial)

							await microtask()
							assert.deepEqual(signal.get(), [])

							// Add all required attributes
							partial.classList.add('active')
							partial.setAttribute('role', 'button')

							await microtask()
							assert.deepEqual(signal.get(), [partial])

							// Remove one attribute
							partial.removeAttribute('role')

							await microtask()
							assert.deepEqual(signal.get(), [])
						} finally {
							container.remove()
						}
					})

					it('should handle attribute selectors with different operators', async () => {
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							const signal = fromSelector(
								'[data-prefix^="test"]',
							)(container)

							const element1 = document.createElement('div')
							element1.setAttribute('data-prefix', 'testing')
							container.appendChild(element1)

							await microtask()
							assert.deepEqual(signal.get(), [element1])

							const element2 = document.createElement('div')
							element2.setAttribute('data-prefix', 'nottesting')
							container.appendChild(element2)

							await microtask()
							assert.deepEqual(signal.get(), [element1])

							element2.setAttribute('data-prefix', 'test-value')

							await microtask()
							assert.deepEqual(signal.get(), [element1, element2])
						} finally {
							container.remove()
						}
					})

					it('should properly disconnect observer when no watchers exist', () => {
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							const signal = fromSelector('.test')(container)

							// Get value to create observer
							signal.get()

							// Add element to trigger mutation
							const element = document.createElement('div')
							element.classList.add('test')
							container.appendChild(element)

							// Observer should disconnect automatically since no watchers
							assert.deepEqual(signal.get(), [element])
						} finally {
							container.remove()
						}
					})

					it('should handle rapid DOM mutations efficiently', async () => {
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							const signal =
								fromSelector('.rapid-test')(container)
							let updateCount = 0

							const cleanup = effect({
								signals: [signal],
								ok: () => {
									updateCount++
								},
							})

							// Rapid mutations
							for (let i = 0; i < 10; i++) {
								const element = document.createElement('div')
								element.classList.add('rapid-test')
								container.appendChild(element)
							}

							await animationFrame()

							// Should handle rapid mutations without excessive updates
							assert.isBelow(
								updateCount,
								15,
								'Should not update excessively for rapid mutations',
							)
							assert.equal(signal.get().length, 10)

							cleanup()
						} finally {
							container.remove()
						}
					})

					it('should detect circular mutations and throw CircularMutationError', async () => {
						// Create a container div
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							// Add an initial element to trigger the effect
							const initialElement = document.createElement('div')
							initialElement.classList.add('circular-test')
							container.appendChild(initialElement)

							// Create a selection signal watching for .circular-test elements
							const signal =
								fromSelector('.circular-test')(container)

							let errorCaught = false
							let effectRanCount = 0

							// Set up an effect that creates a circular dependency
							const cleanup = effect({
								signals: [signal],
								ok: elements => {
									effectRanCount++
									if (effectRanCount <= 3) {
										elements.forEach(element => {
											const newElement =
												document.createElement('div')
											newElement.classList.add(
												'circular-test',
											)
											newElement.textContent = `Element ${effectRanCount}`
											element.appendChild(newElement)
										})
									}
								},
								err: error => {
									errorCaught = true
									assert.equal(
										error.name,
										'CircularMutationError',
									)
									assert.include(
										error.message,
										'Circular mutation in element selection detected',
									)
									cleanup()
								},
							})

							// Wait for effects to run
							await animationFrame()

							// Either error should be caught or effect should have run multiple times
							assert.isTrue(
								errorCaught || effectRanCount > 1,
								'Should either catch circular mutation error or run effect multiple times',
							)

							if (!errorCaught) {
								cleanup()
							}
						} finally {
							// Clean up
							container.remove()
						}
					})

					it('should create a signal producer that selects elements', () => {
						const container = document.createElement('div')
						container.innerHTML = `
						<div class="test-item">Item 1</div>
						<div class="test-item">Item 2</div>
						<div class="other">Other</div>
					`
						document.body.appendChild(container)

						try {
							const signal = fromSelector('.test-item')(container)
							const elements = signal.get()

							assert.equal(elements.length, 2)
							assert.equal(elements[0].textContent, 'Item 1')
							assert.equal(elements[1].textContent, 'Item 2')
						} finally {
							container.remove()
						}
					})

					it('should update when matching elements change', async () => {
						const container = document.createElement('div')
						container.innerHTML = `<div class="item">Item 1</div>`
						document.body.appendChild(container)

						try {
							const signal = fromSelector('.item')(container)

							assert.equal(signal.get().length, 1)

							// Add new matching element
							const newItem = document.createElement('div')
							newItem.className = 'item'
							newItem.textContent = 'Item 2'
							container.appendChild(newItem)

							await animationFrame()
							assert.equal(signal.get().length, 2)

							// Remove element
							newItem.remove()
							await animationFrame()
							assert.equal(signal.get().length, 1)
						} finally {
							container.remove()
						}
					})

					it('should work with complex selectors', () => {
						const container = document.createElement('div')
						container.innerHTML = `
						<input type="text" class="form-control" />
						<input type="password" class="form-control" />
						<input type="text" />
						<textarea class="form-control"></textarea>
					`
						document.body.appendChild(container)

						try {
							const signal =
								fromSelector('input.form-control')(container)
							const elements = signal.get()

							assert.equal(elements.length, 2)
							assert.equal(elements[0].type, 'text')
							assert.equal(elements[1].type, 'password')
						} finally {
							container.remove()
						}
					})

					it('should handle selection with rapidly changing DOM', async () => {
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							const signal = fromSelector('.dynamic')(container)
							let lastCount = 0

							const cleanup = effect({
								signals: [signal],
								ok: elements => {
									lastCount = elements.length
								},
							})

							// Simulate rapid DOM changes
							const elements = []
							for (let i = 0; i < 5; i++) {
								const el = document.createElement('div')
								el.classList.add('dynamic')
								container.appendChild(el)
								elements.push(el)
							}

							await animationFrame()
							assert.equal(lastCount, 5)

							// Remove elements
							elements.forEach(el => el.remove())
							await animationFrame()
							assert.equal(lastCount, 0)

							cleanup()
						} finally {
							container.remove()
						}
					})

					it('should handle events during DOM mutations', async () => {
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							const signal = fromSelector('.clickable')(container)
							let clicksReceived = 0

							// Add element
							const button = document.createElement('button')
							button.classList.add('clickable')
							container.appendChild(button)

							await microtask()

							// Attach event listener
							const cleanup = on('click', () => {
								clicksReceived++
								// Modify DOM during event handling
								const newButton =
									document.createElement('button')
								newButton.classList.add('clickable')
								container.appendChild(newButton)
							})(null, button)

							button.click()
							await animationFrame()

							assert.equal(clicksReceived, 1)
							assert.equal(signal.get().length, 2) // Original + new button

							cleanup()
						} finally {
							container.remove()
						}
					})

					it('should handle memory cleanup properly', () => {
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							// Create many signals and let them go out of scope
							for (let i = 0; i < 100; i++) {
								const signal = fromSelector(`.test-${i}`)(
									container,
								)
								signal.get() // Access to trigger observer creation
							}

							// Force garbage collection if possible
							if (typeof window !== 'undefined' && window.gc) {
								window.gc()
							}

							// Should not throw or cause memory issues
							assert.isTrue(true, 'Memory cleanup should work')
						} finally {
							container.remove()
						}
					})
				})

				describe('read()', () => {
					it('should return fallback when source is null', () => {
						const reader = read(null, 'value', 'default')
						assert.equal(reader(), 'default')
					})

					it('should return fallback when property does not exist on source', () => {
						const container = document.createElement('div')
						const comp = document.createElement('my-component')
						container.appendChild(comp)
						const reader = read(
							container.querySelector('my-component'),
							'value',
							'fallback',
						)
						assert.equal(reader(), 'fallback')
					})

					it('should return fallback until component is defined', async () => {
						const container = document.createElement('div')
						// Create a mock component that's not yet defined
						const mockElement = document.createElement(
							'undefined-component',
						)
						container.appendChild(mockElement)
						const reader = read(
							container.querySelector('undefined-component'),
							'value',
							'fallback',
						)
						assert.equal(reader(), 'fallback')
					})

					it('should read component property after component is defined', async () => {
						const element = document.getElementById('read')
						const reader = read(element, 'value', 'fallback')

						// Since async computed, initially returns UNSET, so fallback
						assert.equal(reader(), 'fallback')

						// Wait for component to be fully upgraded
						const upgraded = await waitForComponentUpgrade(element)
						assert.isTrue(upgraded, 'Component should be upgraded')

						// Now should return component value
						assert.equal(reader(), 'test-value')
					})

					it('should work with module-todo pattern reading input length', async () => {
						// Simulate the module-todo pattern with form-textbox

						const textbox = document.createElement('form-textbox')
						const input = document.createElement('input')
						textbox.appendChild(input)
						document.body.appendChild(textbox)

						try {
							const inputLength = read(
								document.body.querySelector('form-textbox'),
								'length',
								0,
							)

							// Should return fallback initially
							assert.equal(inputLength(), 0)

							// Wait for component to be fully upgraded
							const upgraded =
								await waitForComponentUpgrade(textbox)
							assert.isTrue(
								upgraded,
								'Component should be upgraded',
							)

							// Initially should be 0
							assert.equal(inputLength(), 0)

							// Change value and test length updates
							input.value = 'hello'
							input.dispatchEvent(new Event('input'))
							assert.equal(inputLength(), 5)

							// Test that reader function can be used in effects
							let effectResult = 0
							const cleanup = effect(() => {
								effectResult = inputLength()
							})

							await animationFrame()
							assert.equal(effectResult, 5)

							// Change value again - use setter to trigger length update
							input.value = 'world!'
							input.dispatchEvent(new Event('input'))
							assert.equal(effectResult, 6)

							cleanup()
						} finally {
							input.remove()
						}
					})

					it('should handle component upgrade timing correctly', async () => {
						// Test the actual timing issue that read() solves
						const container = document.createElement('div')
						container.innerHTML =
							'<timing-test-component></timing-test-component>'
						document.body.appendChild(container)

						try {
							// Get element before it's defined (like querySelector in real usage)
							const element = container.querySelector(
								'timing-test-component',
							)

							// Create reader before component is defined
							const reader = read(element, 'status', 'not-ready')

							// Should return fallback initially
							assert.equal(reader(), 'not-ready')

							// Now define the component
							component(
								'timing-test-component',
								{ status: 'ready' },
								() => [],
							)

							// Wait for upgrade
							await customElements.whenDefined(
								'timing-test-component',
							)
							await animationFrame()

							// Now should return component value
							assert.equal(reader(), 'ready')
						} finally {
							container.remove()
						}
					})

					it('should handle missing child elements', () => {
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							const reader = read(null, 'value', 'fallback')

							assert.equal(reader(), 'fallback')
						} finally {
							container.remove()
						}
					})

					it('should work with real-world server-rendered markup like module-todo', async () => {
						const todoTest = document.getElementById('todo-test')
						const radioGroup =
							todoTest.querySelector('form-radiogroup')
						const display =
							todoTest.querySelector('.filter-display')

						// Wait for components to be defined and upgraded
						await customElements.whenDefined('module-todo-test')
						await customElements.whenDefined('form-radiogroup')
						await waitForComponentUpgrade(todoTest)
						await waitForComponentUpgrade(radioGroup)

						console.log(
							'Initial display text:',
							display.textContent,
						)

						// Initially should show "all" filter
						assert.include(display.textContent, 'all')

						// Change radio selection to "active"
						const activeRadio = radioGroup.querySelector(
							'input[value="active"]',
						)
						activeRadio.checked = true
						activeRadio.dispatchEvent(
							new Event('change', { bubbles: true }),
						)

						// Wait for read() to detect change and effect to update
						await animationFrame()

						console.log('After active change:', display.textContent)

						// Display should update to show "active" filter
						assert.include(display.textContent, 'active')

						// Change radio selection to "completed"
						const completedRadio = radioGroup.querySelector(
							'input[value="completed"]',
						)
						completedRadio.checked = true
						completedRadio.dispatchEvent(
							new Event('change', { bubbles: true }),
						)

						// Wait for read() to detect change and effect to update
						await animationFrame()

						console.log(
							'After completed change:',
							display.textContent,
						)

						// Display should update to show "completed" filter
						assert.include(display.textContent, 'completed')
					})
				})

				describe('fromEvents()', () => {
					it('should create a computed signal from event data', async () => {
						const button = document.createElement('button')
						button.className = 'click-me'
						button.textContent = 'Click me'
						document.body.appendChild(button)

						try {
							const clickSignal = fromEvents(
								0,
								'button.click-me',
								{
									click: ({ event, host, target, value }) => {
										assert.equal(host, document.body)
										assert.equal(target, button)
										assert.instanceOf(event, MouseEvent)
										return value + 1
									},
								},
							)(document.body)

							// Use with a computed to ensure it behaves like a signal
							let lastValue = 0
							const cleanup = effect(() => {
								lastValue = clickSignal.get()
							})

							// Should start with initial value
							assert.equal(lastValue, 0)

							// Should update when event fires
							button.click()
							await animationFrame()
							assert.equal(lastValue, 1)

							button.click()
							await animationFrame()
							assert.equal(lastValue, 2)

							cleanup()
						} finally {
							button.remove()
						}
					})

					it('should handle input events with proper typing', async () => {
						const input = document.createElement('input')
						input.type = 'text'
						document.body.appendChild(input)

						try {
							const valueSignal = fromEvents('', 'input', {
								input: ({ event, target }) => {
									assert.instanceOf(event, Event)
									return target.value
								},
							})(document.body)

							// Use in an effect to test reactivity
							let currentValue = ''
							const cleanup = effect(() => {
								currentValue = valueSignal.get()
							})

							assert.equal(currentValue, '')

							input.value = 'hello'
							input.dispatchEvent(
								new Event('input', { bubbles: true }),
							)
							await animationFrame()
							assert.equal(currentValue, 'hello')

							cleanup()
						} finally {
							input.remove()
						}
					})

					it('should work with computed signals', async () => {
						const container = document.createElement('div')
						const button = document.createElement('button')
						container.append(button)
						document.body.appendChild(container)

						try {
							const clickSignal = fromEvents(0, 'button', {
								click: ({ value }) => value + 1,
							})(container)

							const doubledSignal = computed(
								() => clickSignal.get() * 2,
							)

							// Test initial computed value
							assert.equal(doubledSignal.get(), 0)

							// Test that clicking updates the computed
							button.click()
							await animationFrame()
							assert.equal(doubledSignal.get(), 2) // 1 * 2
						} finally {
							container.remove()
						}
					})

					it('should cleanup event listeners when no watchers remain', async () => {
						const container = document.createElement('div')
						const button = document.createElement('button')
						button.id = 'cleanup-test-button'
						container.appendChild(button)
						document.body.appendChild(container)

						try {
							let eventCount = 0
							const clickSignal = fromEvents(
								0,
								'#cleanup-test-button',
								{
									click: () => ++eventCount,
								},
							)(container)

							// Access signal in an effect - creates watcher
							let effectRanCount = 0
							const cleanup = effect(() => {
								clickSignal.get()
								effectRanCount++
							})

							// Wait for initial effect
							await animationFrame()

							// Click should trigger the effect
							button.click()
							await animationFrame()
							assert.equal(effectRanCount, 2) // initial + after click

							// Remove the watcher by cleaning up the effect
							cleanup()

							// Next click should not trigger the effect and remove listener
							button.click()
							await animationFrame()
							assert.equal(effectRanCount, 2) // initial + after click

							// Reset event count to test if listener is removed
							eventCount = 0

							// Click should not trigger the effect anymore
							button.click()

							// Event count should remain 0 if cleanup worked
							assert.equal(eventCount, 0)
						} finally {
							container.remove()
						}
					})

					it('should create a signal producer from input events', async () => {
						const container = document.createElement('div')
						container.innerHTML = `<input type="text" id="test-input" />`
						document.body.appendChild(container)

						try {
							const signal = fromEvents('', '#test-input', {
								input: ({ event, host, target }) => {
									assert.equal(host, container)
									assert.equal(target.id, 'test-input')
									assert.instanceOf(event, Event)
									return target.value
								},
							})(container)

							// Use in effect to test reactivity
							let currentValue = ''
							const cleanup = effect(() => {
								currentValue = signal.get()
							})

							assert.equal(currentValue, '')

							const input = container.querySelector('#test-input')
							input.value = 'hello'
							input.dispatchEvent(
								new Event('input', { bubbles: true }),
							)

							assert.equal(currentValue, 'hello')

							cleanup()
						} finally {
							container.remove()
						}
					})

					it('should do nothing when element not found', () => {
						const container = document.createElement('div')
						document.body.appendChild(container)

						try {
							const signal = fromEvents('', '.nonexistent', {
								click: () => 'clicked',
							})(container)

							// Use in effect to test reactivity
							let currentValue = ''
							const cleanup = effect(() => {
								currentValue = signal.get()
							})

							assert.equal(currentValue, '')

							cleanup()
						} finally {
							container.remove()
						}
					})

					it('should work with function initializers', async () => {
						const container = document.createElement('div')
						container.innerHTML = `<button id="counter">Count: 0</button>`
						document.body.appendChild(container)

						try {
							const signal = fromEvents(
								host => {
									assert.equal(host, container)
									assert.equal(
										host.querySelector('button').id,
										'counter',
									)
									return 5 // Start from 5
								},
								'#counter',
								{
									click: ({ value }) => ++value,
								},
							)(container)

							// Use in effect to test reactivity
							let currentValue = 0
							const cleanup = effect(() => {
								currentValue = signal.get()
							})

							assert.equal(currentValue, 5)

							const button = container.querySelector('#counter')
							button.click()

							assert.equal(currentValue, 6)

							cleanup()
						} finally {
							container.remove()
						}
					})

					it('should handle form events', async () => {
						const container = document.createElement('div')
						container.innerHTML = `
						<form>
							<input type="text" name="username" value="john" />
						</form>
					`
						document.body.appendChild(container)

						try {
							const signal = fromEvents(null, 'form', {
								submit: ({ event, target }) => {
									event.preventDefault()
									return new FormData(target)
								},
							})(container)

							// Use in effect to test reactivity
							let formData = null
							const cleanup = effect(() => {
								formData = signal.get()
							})

							assert.equal(formData, null)

							const form = container.querySelector('form')
							const submitEvent = new Event('submit', {
								bubbles: true,
								cancelable: true,
							})
							form.dispatchEvent(submitEvent)

							assert.instanceOf(formData, FormData)
							assert.equal(formData.get('username'), 'john')

							cleanup()
						} finally {
							container.remove()
						}
					})

					it('should reproduce fromEvents component issue', async () => {
						// This test reproduces the issue where fromEvents properties
						// in components don't update when events are dispatched
						const container = document.createElement('div')
						container.innerHTML = `
							<div class="test-component">
								<input type="text" id="comp-input" />
								<span class="length-display"></span>
								<span class="value-display"></span>
							</div>
						`
						document.body.appendChild(container)

						try {
							const host =
								container.querySelector('.test-component')

							// Create signals
							const lengthSignal = fromEvents(0, '#comp-input', {
								input: ({ target }) => target.value.length,
							})(host)
							const valueSignal = fromEvents('', '#comp-input', {
								change: ({ target }) => target.value,
							})(host)

							// Track updates (this simulates component property access)
							let lengthValue = 0
							let valueValue = ''

							// WITHOUT effect wrapper (simulates direct property access)
							lengthValue = lengthSignal.get()
							valueValue = valueSignal.get()

							assert.equal(
								lengthValue,
								0,
								'Initial length should be 0',
							)
							assert.equal(
								valueValue,
								'',
								'Initial value should be empty',
							)

							// Simulate typing (like test helper does)
							const input = host.querySelector('#comp-input')
							input.value = 'hello'
							input.dispatchEvent(
								new Event('input', { bubbles: true }),
							)

							// Check if length updated (this should fail if bug exists)
							lengthValue = lengthSignal.get()
							console.log(
								'Length after input event:',
								lengthValue,
							)
							// This assertion may fail, reproducing the component issue
							// assert.equal(lengthValue, 5, 'Length should be 5 after typing hello')

							// Dispatch change event
							input.dispatchEvent(
								new Event('change', { bubbles: true }),
							)

							// Check if value updated
							valueValue = valueSignal.get()
							console.log('Value after change event:', valueValue)
							// This assertion may fail, reproducing the component issue
							// assert.equal(valueValue, 'hello', 'Value should be hello after change event')

							// Now test WITH effect wrapper (should work)
							let reactiveLength = 0
							let reactiveValue = ''

							const lengthCleanup = effect(() => {
								reactiveLength = lengthSignal.get()
							})
							const valueCleanup = effect(() => {
								reactiveValue = valueSignal.get()
							})

							// Reset and test again
							input.value = 'world'
							input.dispatchEvent(
								new Event('input', { bubbles: true }),
							)

							assert.equal(
								reactiveLength,
								5,
								'Reactive length should update correctly',
							)

							input.dispatchEvent(
								new Event('change', { bubbles: true }),
							)

							assert.equal(
								reactiveValue,
								'world',
								'Reactive value should update correctly',
							)

							lengthCleanup()
							valueCleanup()
						} finally {
							container.remove()
						}
					})

					it('should handle custom events', async () => {
						const container = document.createElement('div')
						container.innerHTML = `<div class="custom-target">Custom Event Target</div>`
						document.body.appendChild(container)

						try {
							const signal = fromEvents(
								'initial',
								'.custom-target',
								{
									myCustomEvent: ({ event, target }) => {
										assert.equal(
											target.className,
											'custom-target',
										)
										assert.equal(
											event.type,
											'myCustomEvent',
										)
										assert.equal(
											event.detail.message,
											'Hello from custom event',
										)
										return event.detail.value
									},
								},
							)(container)

							// Use in effect to test reactivity
							let currentValue = 'initial'
							const cleanup = effect(() => {
								currentValue = signal.get()
							})

							assert.equal(currentValue, 'initial')

							// Dispatch custom event
							const target =
								container.querySelector('.custom-target')
							const customEvent = new CustomEvent(
								'myCustomEvent',
								{
									detail: {
										message: 'Hello from custom event',
										value: 'custom-value',
									},
									bubbles: true,
								},
							)
							target.dispatchEvent(customEvent)

							await microtask()
							assert.equal(currentValue, 'custom-value')

							cleanup()
						} finally {
							container.remove()
						}
					})

					it('should provide type safety with ElementFromSelector', async () => {
						// This test demonstrates that TypeScript now knows about element types
						const container = document.createElement('div')
						container.innerHTML = `
							<input type="text" value="hello" />
							<button type="button">Click me</button>
						`
						document.body.appendChild(container)

						try {
							// TypeScript should infer that elements are HTMLInputElement[]
							const inputSignal = fromSelector('input')(container)
							const inputs = inputSignal.get()

							// This should work because TypeScript knows inputs are HTMLInputElement[]
							assert.equal(inputs.length, 1)
							assert.equal(inputs[0].value, 'hello')
							assert.equal(inputs[0].type, 'text')

							// Test with reduced - should know about input properties
							const totalLength = reduced(
								container,
								'input',
								(sum, input) => sum + input.value.length,
								0,
							).get()

							assert.equal(totalLength, 5) // "hello".length

							// Test with fromEvents - should know about button elements
							const clickSignal = fromEvents('', 'button', {
								click: ({ target }) => target.textContent || '',
							})(container)

							let lastValue = ''
							const cleanup = effect(() => {
								lastValue = clickSignal.get()
							})

							const button = container.querySelector('button')
							button.click()
							await microtask()

							assert.equal(lastValue, 'Click me')
							cleanup()
						} finally {
							container.remove()
						}
					})

					it('should handle multiple event listeners (click and keyup)', async () => {
						const container = document.createElement('div')
						container.innerHTML = `
							<button id="tab1" aria-controls="panel1">Tab 1</button>
							<button id="tab2" aria-controls="panel2">Tab 2</button>
							<button id="tab3" aria-controls="panel3">Tab 3</button>
						`
						document.body.appendChild(container)

						try {
							const tabs = Array.from(
								container.querySelectorAll('button'),
							)

							// Helper function similar to module-tabgroup
							const getAriaControls = target =>
								target?.getAttribute('aria-controls') ?? ''

							const getSelected = (
								elements,
								isCurrent,
								offset = 0,
							) =>
								getAriaControls(
									elements[
										Math.min(
											Math.max(
												elements.findIndex(isCurrent)
													+ offset,
												0,
											),
											elements.length - 1,
										)
									],
								)

							const signal = fromEvents(
								'panel1', // initial selected
								'button',
								{
									click: ({ target }) =>
										getAriaControls(target),
									keyup: ({ event, target }) => {
										const key = event.key
										if (
											[
												'ArrowLeft',
												'ArrowRight',
												'Home',
												'End',
											].includes(key)
										) {
											event.preventDefault()
											event.stopPropagation()
											return getSelected(
												tabs,
												tab => tab === target,
												key === 'Home'
													? -tabs.length
													: key === 'End'
														? tabs.length
														: key === 'ArrowLeft'
															? -1
															: 1,
											)
										}
									},
								},
							)(container)

							// Use in effect to test reactivity
							let currentValue = 'panel1'
							const cleanup = effect(() => {
								currentValue = signal.get()
							})

							// Test initial value
							assert.equal(currentValue, 'panel1')

							// Test click event
							tabs[1].click()
							await animationFrame()
							assert.equal(currentValue, 'panel2')

							// Test keyup event - ArrowRight should move to next tab
							tabs[1].dispatchEvent(
								new KeyboardEvent('keyup', {
									key: 'ArrowRight',
									bubbles: true,
								}),
							)
							await animationFrame()
							assert.equal(currentValue, 'panel3')

							// Test keyup event - ArrowLeft should move to previous tab
							tabs[2].dispatchEvent(
								new KeyboardEvent('keyup', {
									key: 'ArrowLeft',
									bubbles: true,
								}),
							)
							await animationFrame()
							assert.equal(currentValue, 'panel2')

							// Test keyup event - Home should move to first tab
							tabs[1].dispatchEvent(
								new KeyboardEvent('keyup', {
									key: 'Home',
									bubbles: true,
								}),
							)
							await animationFrame()
							assert.equal(currentValue, 'panel1')

							// Test keyup event - End should move to last tab
							tabs[0].dispatchEvent(
								new KeyboardEvent('keyup', {
									key: 'End',
									bubbles: true,
								}),
							)
							await animationFrame()
							assert.equal(currentValue, 'panel3')

							// Test that other keys don't trigger changes
							tabs[2].dispatchEvent(
								new KeyboardEvent('keyup', {
									key: 'Enter',
									bubbles: true,
								}),
							)
							await animationFrame()
							assert.equal(currentValue, 'panel3') // Should remain unchanged

							cleanup()
						} finally {
							container.remove()
						}
					})
				})

				describe('reduced()', () => {
					it('should reduce child elements to a single value', () => {
						const container = document.createElement('div')
						container.innerHTML = `
						<div data-value="10">Item 1</div>
						<div data-value="20">Item 2</div>
						<div data-value="30">Item 3</div>
					`
						document.body.appendChild(container)

						try {
							const reducer = reduced(
								container,
								'[data-value]',
								(total, element) =>
									total
									+ parseInt(element.dataset.value || '0'),
								0,
							)
							assert.equal(reducer.get(), 60) // 10 + 20 + 30
						} finally {
							container.remove()
						}
					})

					it('should update when child elements change', async () => {
						const container = document.createElement('div')
						container.innerHTML = `
						<div data-count="1">Item 1</div>
						<div data-count="1">Item 2</div>
					`
						document.body.appendChild(container)

						try {
							const reducer = reduced(
								container,
								'[data-count]',
								(total, element) =>
									total
									+ parseInt(element.dataset.count || '0'),
								0,
							)
							assert.equal(reducer.get(), 2)

							// Add new element
							const newItem = document.createElement('div')
							newItem.dataset.count = '3'
							newItem.textContent = 'Item 3'
							container.appendChild(newItem)

							await animationFrame()
							assert.equal(reducer.get(), 5) // 1 + 1 + 3
						} finally {
							container.remove()
						}
					})

					it('should work with complex reducers', () => {
						const container = document.createElement('div')
						container.innerHTML = `
						<input type="checkbox" checked />
						<input type="checkbox" />
						<input type="checkbox" checked />
						<input type="radio" />
					`
						document.body.appendChild(container)

						try {
							const reducer = reduced(
								container,
								'input[type="checkbox"]',
								(acc, input) => ({
									total: acc.total + 1,
									checked:
										acc.checked + (input.checked ? 1 : 0),
								}),
								{ total: 0, checked: 0 },
							)
							const result = reducer.get()

							assert.equal(result.total, 3)
							assert.equal(result.checked, 2)
						} finally {
							container.remove()
						}
					})
				})

				describe('requireDescendant()', () => {
					it('should return the element when it exists', () => {
						const container = document.createElement('div')
						container.innerHTML = `
							<form>
								<input type="text" id="test-input" />
								<button type="submit">Submit</button>
							</form>
						`
						document.body.appendChild(container)

						try {
							const input = requireDescendant(
								container,
								'#test-input',
							)
							assert.instanceOf(input, HTMLInputElement)
							assert.equal(input.id, 'test-input')
							assert.equal(input.type, 'text')
						} finally {
							container.remove()
						}
					})

					it('should return the correct element type for different selectors', () => {
						const container = document.createElement('div')
						container.innerHTML = `
							<form>
								<input type="text" />
								<button type="submit">Submit</button>
								<textarea></textarea>
								<select><option>Option</option></select>
							</form>
						`
						document.body.appendChild(container)

						try {
							const input = requireDescendant(container, 'input')
							assert.instanceOf(input, HTMLInputElement)

							const button = requireDescendant(
								container,
								'button',
							)
							assert.instanceOf(button, HTMLButtonElement)

							const textarea = requireDescendant(
								container,
								'textarea',
							)
							assert.instanceOf(textarea, HTMLTextAreaElement)

							const select = requireDescendant(
								container,
								'select',
							)
							assert.instanceOf(select, HTMLSelectElement)
						} finally {
							container.remove()
						}
					})

					it('should return the first matching element when multiple exist', () => {
						const container = document.createElement('div')
						container.innerHTML = `
							<ul>
								<li data-value="1">First</li>
								<li data-value="2">Second</li>
								<li data-value="3">Third</li>
							</ul>
						`
						document.body.appendChild(container)

						try {
							const firstLi = requireDescendant(container, 'li')
							assert.instanceOf(firstLi, HTMLLIElement)
							assert.equal(firstLi.dataset.value, '1')
							assert.equal(firstLi.textContent, 'First')
						} finally {
							container.remove()
						}
					})

					it('should work with complex selectors', () => {
						const container = document.createElement('div')
						container.innerHTML = `
							<div class="content">
								<form class="todo-form">
									<input type="text" class="todo-input" />
									<button class="submit-btn" type="submit">Add</button>
								</form>
								<ol class="todo-list">
									<li><span>Task 1</span></li>
								</ol>
							</div>
						`
						document.body.appendChild(container)

						try {
							const form = requireDescendant(
								container,
								'form.todo-form',
							)
							assert.instanceOf(form, HTMLFormElement)
							assert.equal(form.className, 'todo-form')

							const input = requireDescendant(
								container,
								'input.todo-input',
							)
							assert.instanceOf(input, HTMLInputElement)
							assert.equal(input.className, 'todo-input')

							const list = requireDescendant(
								container,
								'ol.todo-list',
							)
							assert.instanceOf(list, HTMLOListElement)
							assert.equal(list.className, 'todo-list')

							const button = requireDescendant(
								container,
								'button[type="submit"]',
							)
							assert.instanceOf(button, HTMLButtonElement)
							assert.equal(button.type, 'submit')
						} finally {
							container.remove()
						}
					})

					it('should throw an error when element does not exist', () => {
						const container = document.createElement('div')
						container.innerHTML = `<p>No form here</p>`
						document.body.appendChild(container)

						try {
							assert.throws(
								() => requireDescendant(container, 'form'),
								Error,
								'Component <div> does not contain required <form> element',
							)

							assert.throws(
								() =>
									requireDescendant(container, '#missing-id'),
								Error,
								'Component <div> does not contain required <#missing-id> element',
							)

							assert.throws(
								() =>
									requireDescendant(
										container,
										'.missing-class',
									),
								Error,
								'Component <div> does not contain required <.missing-class> element',
							)
						} finally {
							container.remove()
						}
					})

					it('should throw an error with correct component name', () => {
						const container = document.createElement('module-todo')
						container.innerHTML = `<p>No required elements</p>`
						document.body.appendChild(container)

						try {
							assert.throws(
								() =>
									requireDescendant(
										container,
										'form-textbox',
									),
								Error,
								'Component <module-todo> does not contain required <form-textbox> element',
							)

							assert.throws(
								() => requireDescendant(container, 'template'),
								Error,
								'Component <module-todo> does not contain required <template> element',
							)

							assert.throws(
								() => requireDescendant(container, 'ol'),
								Error,
								'Component <module-todo> does not contain required <ol> element',
							)
						} finally {
							container.remove()
						}
					})

					it('should work with custom elements', () => {
						// Define a simple custom element for testing
						if (!customElements.get('test-custom-element')) {
							class TestCustomElement extends HTMLElement {
								constructor() {
									super()
									this.innerHTML =
										'<span>Custom content</span>'
								}
							}
							customElements.define(
								'test-custom-element',
								TestCustomElement,
							)
						}

						const container = document.createElement('div')
						container.innerHTML = `
							<test-custom-element id="custom-1">
								<span>Content</span>
							</test-custom-element>
						`
						document.body.appendChild(container)

						try {
							const customElement = requireDescendant(
								container,
								'test-custom-element',
							)
							assert.instanceOf(customElement, HTMLElement)
							assert.equal(
								customElement.tagName.toLowerCase(),
								'test-custom-element',
							)
							assert.equal(customElement.id, 'custom-1')
						} finally {
							container.remove()
						}
					})

					it('should work with template elements', () => {
						const container = document.createElement('div')
						container.innerHTML = `
							<template id="todo-template">
								<li>
									<form-checkbox></form-checkbox>
									<slot></slot>
									<button type="button">Delete</button>
								</li>
							</template>
						`
						document.body.appendChild(container)

						try {
							const template = requireDescendant(
								container,
								'template',
							)
							assert.instanceOf(template, HTMLTemplateElement)
							assert.equal(template.id, 'todo-template')
							assert.instanceOf(
								template.content,
								DocumentFragment,
							)
						} finally {
							container.remove()
						}
					})

					it('should provide proper type inference for ElementFromSelector', () => {
						const container = document.createElement('div')
						container.innerHTML = `
							<form>
								<input type="email" name="email" required />
								<button type="submit">Submit</button>
							</form>
						`
						document.body.appendChild(container)

						try {
							// Test that TypeScript would infer HTMLInputElement for input selector
							const input = requireDescendant(
								container,
								'input[type="email"]',
							)
							assert.instanceOf(input, HTMLInputElement)
							assert.equal(input.type, 'email')
							assert.equal(input.name, 'email')
							assert.equal(input.required, true)

							// Test that TypeScript would infer HTMLButtonElement for button selector
							const button = requireDescendant(
								container,
								'button[type="submit"]',
							)
							assert.instanceOf(button, HTMLButtonElement)
							assert.equal(button.type, 'submit')
							assert.equal(button.textContent, 'Submit')
						} finally {
							container.remove()
						}
					})
				})

				describe('Integration: Signal Producers in Components', () => {
					it('should work together in a component setup', async () => {
						const name = 'test-signal-producers-integration'
						const container = document.querySelector(name)
						await customElements.whenDefined(name)

						// Test fromSelector
						assert.equal(container.items.length, 2)

						// Test reduced
						assert.equal(container.total, 30)

						// Test fromEvent
						assert.equal(container.lastInput, '')

						const input = container.querySelector('input')
						input.value = 'hello'
						input.dispatchEvent(
							new Event('input', { bubbles: true }),
						)
						assert.equal(container.lastInput, 'hello')

						// Add new item
						const newItem = document.createElement('div')
						newItem.className = 'item'
						newItem.dataset.value = '15'
						newItem.textContent = 'Item 3'
						container.appendChild(newItem)
						await microtask()

						assert.equal(container.items.length, 3)
						assert.equal(container.total, 45) // 10 + 20 + 15
					})
				})
			})
		</script>
	</body>
</html>