import { Injector } from '@furystack/inject'
import { ObservableValue, usingAsync } from '@furystack/utils'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { initializeShadeRoot } from './initialize.js'
import { createComponent } from './shade-component.js'
import { flushUpdates, Shade } from './shade.js'
describe('useHostProps integration tests', () => {
beforeEach(() => {
document.body.innerHTML = '
'
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should set data attributes on the host element', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade<{ variant: string }>({
customElementName: 'host-props-data-attr-test',
render: ({ props, useHostProps }) => {
useHostProps({
'data-variant': props.variant,
})
return content
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const el = document.querySelector('host-props-data-attr-test') as HTMLElement
expect(el).toBeTruthy()
expect(el.getAttribute('data-variant')).toBe('primary')
})
})
it('should set aria attributes on the host element', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
customElementName: 'host-props-aria-test',
render: ({ useHostProps }) => {
useHostProps({
role: 'progressbar',
'aria-valuenow': '50',
'aria-valuemin': '0',
'aria-valuemax': '100',
})
return progress
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const el = document.querySelector('host-props-aria-test') as HTMLElement
expect(el.getAttribute('role')).toBe('progressbar')
expect(el.getAttribute('aria-valuenow')).toBe('50')
expect(el.getAttribute('aria-valuemin')).toBe('0')
expect(el.getAttribute('aria-valuemax')).toBe('100')
})
})
it('should apply CSS custom properties via style', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
customElementName: 'host-props-css-vars-test',
render: ({ useHostProps }) => {
useHostProps({
style: {
'--my-color': 'red',
'--my-size': '16px',
},
})
return styled
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const el = document.querySelector('host-props-css-vars-test') as HTMLElement
expect(el.style.getPropertyValue('--my-color')).toBe('red')
expect(el.style.getPropertyValue('--my-size')).toBe('16px')
})
})
it('should apply standard inline styles', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
customElementName: 'host-props-inline-style-test',
render: ({ useHostProps }) => {
useHostProps({
style: {
display: 'flex',
gap: '8px',
},
})
return styled
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const el = document.querySelector('host-props-inline-style-test') as HTMLElement
expect(el.style.display).toBe('flex')
expect(el.style.gap).toBe('8px')
})
})
it('should merge multiple useHostProps calls', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const ExampleComponent = Shade({
customElementName: 'host-props-merge-test',
render: ({ useHostProps }) => {
useHostProps({
'data-first': 'one',
style: { '--color-a': 'red' },
})
useHostProps({
'data-second': 'two',
style: { '--color-b': 'blue' },
})
return merged
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const el = document.querySelector('host-props-merge-test') as HTMLElement
expect(el.getAttribute('data-first')).toBe('one')
expect(el.getAttribute('data-second')).toBe('two')
expect(el.style.getPropertyValue('--color-a')).toBe('red')
expect(el.style.getPropertyValue('--color-b')).toBe('blue')
})
})
it('should remove attributes when they are no longer set on re-render', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const showExtra = new ObservableValue(true)
const ExampleComponent = Shade({
customElementName: 'host-props-remove-attr-test',
render: ({ useHostProps, useObservable }) => {
const [show] = useObservable('showExtra', showExtra)
useHostProps({
'data-always': 'yes',
...(show ? { 'data-extra': 'present' } : {}),
})
return content
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const el = document.querySelector('host-props-remove-attr-test') as HTMLElement
expect(el.getAttribute('data-always')).toBe('yes')
expect(el.getAttribute('data-extra')).toBe('present')
showExtra.setValue(false)
await flushUpdates()
expect(el.getAttribute('data-always')).toBe('yes')
expect(el.getAttribute('data-extra')).toBeNull()
})
})
it('should remove CSS custom properties when they are no longer set', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const showColor = new ObservableValue(true)
const ExampleComponent = Shade({
customElementName: 'host-props-remove-css-var-test',
render: ({ useHostProps, useObservable }) => {
const [show] = useObservable('showColor', showColor)
useHostProps({
style: {
display: 'block',
...(show ? { '--my-color': 'red' } : {}),
},
})
return content
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const el = document.querySelector('host-props-remove-css-var-test') as HTMLElement
expect(el.style.getPropertyValue('--my-color')).toBe('red')
expect(el.style.display).toBe('block')
showColor.setValue(false)
await flushUpdates()
expect(el.style.getPropertyValue('--my-color')).toBe('')
expect(el.style.display).toBe('block')
})
})
it('should set event handlers on the host element', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
let clicked = false
const ExampleComponent = Shade({
customElementName: 'host-props-event-test',
render: ({ useHostProps }) => {
useHostProps({
onclick: () => {
clicked = true
},
})
return clickable
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const el = document.querySelector('host-props-event-test') as HTMLElement
el.click()
expect(clicked).toBe(true)
})
})
})
describe('useRef integration tests', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
it('should set ref.current to the mounted element', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
let capturedRef: { readonly current: HTMLDivElement | null } | undefined
const ExampleComponent = Shade({
customElementName: 'use-ref-basic-test',
render: ({ useRef }) => {
const divRef = useRef('myDiv')
capturedRef = divRef
return (
hello
)
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(capturedRef).toBeTruthy()
expect(capturedRef!.current).toBeTruthy()
expect(capturedRef!.current).toBe(document.getElementById('target'))
expect(capturedRef!.current?.textContent).toBe('hello')
})
})
it('should return the same ref object across re-renders', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const counter = new ObservableValue(0)
const capturedRefs: Array<{ readonly current: Element | null }> = []
const ExampleComponent = Shade({
customElementName: 'use-ref-stable-test',
render: ({ useRef, useObservable }) => {
const [count] = useObservable('counter', counter)
const divRef = useRef('myDiv')
capturedRefs.push(divRef)
return {count}
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
counter.setValue(1)
await flushUpdates()
counter.setValue(2)
await flushUpdates()
expect(capturedRefs.length).toBe(3)
expect(capturedRefs[0]).toBe(capturedRefs[1])
expect(capturedRefs[1]).toBe(capturedRefs[2])
})
})
it('should set ref.current on nested child elements', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
let capturedInputRef: { readonly current: HTMLInputElement | null } | undefined
const ExampleComponent = Shade({
customElementName: 'use-ref-nested-test',
render: ({ useRef }) => {
const inputRef = useRef('input')
capturedInputRef = inputRef
return (
)
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(capturedInputRef).toBeTruthy()
expect(capturedInputRef!.current).toBeTruthy()
expect(capturedInputRef!.current).toBe(document.getElementById('my-input'))
expect(capturedInputRef!.current?.type).toBe('text')
})
})
it('should clear ref.current when element is unmounted', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const showChild = new ObservableValue(true)
let capturedRef: { readonly current: HTMLSpanElement | null } | undefined
const ExampleComponent = Shade({
customElementName: 'use-ref-unmount-test',
render: ({ useRef, useObservable }) => {
const [show] = useObservable('showChild', showChild)
const spanRef = useRef('span')
capturedRef = spanRef
return {show ? visible : null}
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
expect(capturedRef!.current).toBeTruthy()
expect(capturedRef!.current?.textContent).toBe('visible')
showChild.setValue(false)
await flushUpdates()
expect(capturedRef!.current).toBeNull()
})
})
it('should work with useRef in onChange callbacks', async () => {
await usingAsync(new Injector(), async (injector) => {
const rootElement = document.getElementById('root') as HTMLDivElement
const counter = new ObservableValue(0)
const ExampleComponent = Shade({
customElementName: 'use-ref-onchange-test',
render: ({ useRef, useObservable }) => {
const spanRef = useRef('counterSpan')
useObservable('counter', counter, {
onChange: (value) => {
if (spanRef.current) {
spanRef.current.textContent = String(value)
}
},
})
return (
0
)
},
})
initializeShadeRoot({
injector,
rootElement,
jsxElement: ,
})
await flushUpdates()
const span = document.getElementById('counter-span')
expect(span?.textContent).toBe('0')
counter.setValue(42)
await flushUpdates()
expect(span?.textContent).toBe('42')
})
})
})