customElement.spec.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import {
  2. defineCustomElement,
  3. h,
  4. nextTick,
  5. ref,
  6. renderSlot,
  7. VueElement
  8. } from '../src'
  9. describe('defineCustomElement', () => {
  10. const container = document.createElement('div')
  11. document.body.appendChild(container)
  12. beforeEach(() => {
  13. container.innerHTML = ''
  14. })
  15. describe('mounting/unmount', () => {
  16. const E = defineCustomElement({
  17. props: {
  18. msg: {
  19. type: String,
  20. default: 'hello'
  21. }
  22. },
  23. render() {
  24. return h('div', this.msg)
  25. }
  26. })
  27. customElements.define('my-element', E)
  28. test('should work', () => {
  29. container.innerHTML = `<my-element></my-element>`
  30. const e = container.childNodes[0] as VueElement
  31. expect(e).toBeInstanceOf(E)
  32. expect(e._instance).toBeTruthy()
  33. expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
  34. })
  35. test('should work w/ manual instantiation', () => {
  36. const e = new E({ msg: 'inline' })
  37. // should lazy init
  38. expect(e._instance).toBe(null)
  39. // should initialize on connect
  40. container.appendChild(e)
  41. expect(e._instance).toBeTruthy()
  42. expect(e.shadowRoot!.innerHTML).toBe(`<div>inline</div>`)
  43. })
  44. test('should unmount on remove', async () => {
  45. container.innerHTML = `<my-element></my-element>`
  46. const e = container.childNodes[0] as VueElement
  47. container.removeChild(e)
  48. await nextTick()
  49. expect(e._instance).toBe(null)
  50. expect(e.shadowRoot!.innerHTML).toBe('')
  51. })
  52. test('should not unmount on move', async () => {
  53. container.innerHTML = `<div><my-element></my-element></div>`
  54. const e = container.childNodes[0].childNodes[0] as VueElement
  55. const i = e._instance
  56. // moving from one parent to another - this will trigger both disconnect
  57. // and connected callbacks synchronously
  58. container.appendChild(e)
  59. await nextTick()
  60. // should be the same instance
  61. expect(e._instance).toBe(i)
  62. expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
  63. })
  64. })
  65. describe('props', () => {
  66. const E = defineCustomElement({
  67. props: ['foo', 'bar', 'bazQux'],
  68. render() {
  69. return [
  70. h('div', null, this.foo),
  71. h('div', null, this.bazQux || (this.bar && this.bar.x))
  72. ]
  73. }
  74. })
  75. customElements.define('my-el-props', E)
  76. test('props via attribute', async () => {
  77. // bazQux should map to `baz-qux` attribute
  78. container.innerHTML = `<my-el-props foo="hello" baz-qux="bye"></my-el-props>`
  79. const e = container.childNodes[0] as VueElement
  80. expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div><div>bye</div>')
  81. // change attr
  82. e.setAttribute('foo', 'changed')
  83. await nextTick()
  84. expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div><div>bye</div>')
  85. e.setAttribute('baz-qux', 'changed')
  86. await nextTick()
  87. expect(e.shadowRoot!.innerHTML).toBe(
  88. '<div>changed</div><div>changed</div>'
  89. )
  90. })
  91. test('props via properties', async () => {
  92. const e = new E()
  93. e.foo = 'one'
  94. e.bar = { x: 'two' }
  95. container.appendChild(e)
  96. expect(e.shadowRoot!.innerHTML).toBe('<div>one</div><div>two</div>')
  97. // reflect
  98. // should reflect primitive value
  99. expect(e.getAttribute('foo')).toBe('one')
  100. // should not reflect rich data
  101. expect(e.hasAttribute('bar')).toBe(false)
  102. e.foo = 'three'
  103. await nextTick()
  104. expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>two</div>')
  105. expect(e.getAttribute('foo')).toBe('three')
  106. e.foo = null
  107. await nextTick()
  108. expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>two</div>')
  109. expect(e.hasAttribute('foo')).toBe(false)
  110. e.bazQux = 'four'
  111. await nextTick()
  112. expect(e.shadowRoot!.innerHTML).toBe('<div></div><div>four</div>')
  113. expect(e.getAttribute('baz-qux')).toBe('four')
  114. })
  115. test('attribute -> prop type casting', async () => {
  116. const E = defineCustomElement({
  117. props: {
  118. foo: Number,
  119. bar: Boolean
  120. },
  121. render() {
  122. return [this.foo, typeof this.foo, this.bar, typeof this.bar].join(
  123. ' '
  124. )
  125. }
  126. })
  127. customElements.define('my-el-props-cast', E)
  128. container.innerHTML = `<my-el-props-cast foo="1"></my-el-props-cast>`
  129. const e = container.childNodes[0] as VueElement
  130. expect(e.shadowRoot!.innerHTML).toBe(`1 number false boolean`)
  131. e.setAttribute('bar', '')
  132. await nextTick()
  133. expect(e.shadowRoot!.innerHTML).toBe(`1 number true boolean`)
  134. e.setAttribute('foo', '2e1')
  135. await nextTick()
  136. expect(e.shadowRoot!.innerHTML).toBe(`20 number true boolean`)
  137. })
  138. test('handling properties set before upgrading', () => {
  139. const E = defineCustomElement({
  140. props: ['foo'],
  141. render() {
  142. return `foo: ${this.foo}`
  143. }
  144. })
  145. const el = document.createElement('my-el-upgrade') as any
  146. el.foo = 'hello'
  147. container.appendChild(el)
  148. customElements.define('my-el-upgrade', E)
  149. expect(el.shadowRoot.innerHTML).toBe(`foo: hello`)
  150. })
  151. })
  152. describe('emits', () => {
  153. const E = defineCustomElement({
  154. setup(_, { emit }) {
  155. emit('created')
  156. return () =>
  157. h('div', {
  158. onClick: () => emit('my-click', 1)
  159. })
  160. }
  161. })
  162. customElements.define('my-el-emits', E)
  163. test('emit on connect', () => {
  164. const e = new E()
  165. const spy = jest.fn()
  166. e.addEventListener('created', spy)
  167. container.appendChild(e)
  168. expect(spy).toHaveBeenCalled()
  169. })
  170. test('emit on interaction', () => {
  171. container.innerHTML = `<my-el-emits></my-el-emits>`
  172. const e = container.childNodes[0] as VueElement
  173. const spy = jest.fn()
  174. e.addEventListener('my-click', spy)
  175. e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('click'))
  176. expect(spy).toHaveBeenCalled()
  177. expect(spy.mock.calls[0][0]).toMatchObject({
  178. detail: [1]
  179. })
  180. })
  181. })
  182. describe('slots', () => {
  183. const E = defineCustomElement({
  184. render() {
  185. return [
  186. h('div', null, [
  187. renderSlot(this.$slots, 'default', undefined, () => [
  188. h('div', 'fallback')
  189. ])
  190. ]),
  191. h('div', null, renderSlot(this.$slots, 'named'))
  192. ]
  193. }
  194. })
  195. customElements.define('my-el-slots', E)
  196. test('default slot', () => {
  197. container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
  198. const e = container.childNodes[0] as VueElement
  199. // native slots allocation does not affect innerHTML, so we just
  200. // verify that we've rendered the correct native slots here...
  201. expect(e.shadowRoot!.innerHTML).toBe(
  202. `<div><slot><div>fallback</div></slot></div><div><slot name="named"></slot></div>`
  203. )
  204. })
  205. })
  206. describe('provide/inject', () => {
  207. const Consumer = defineCustomElement({
  208. inject: ['foo'],
  209. render(this: any) {
  210. return h('div', this.foo.value)
  211. }
  212. })
  213. customElements.define('my-consumer', Consumer)
  214. test('over nested usage', async () => {
  215. const foo = ref('injected!')
  216. const Provider = defineCustomElement({
  217. provide: {
  218. foo
  219. },
  220. render() {
  221. return h('my-consumer')
  222. }
  223. })
  224. customElements.define('my-provider', Provider)
  225. container.innerHTML = `<my-provider><my-provider>`
  226. const provider = container.childNodes[0] as VueElement
  227. const consumer = provider.shadowRoot!.childNodes[0] as VueElement
  228. expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
  229. foo.value = 'changed!'
  230. await nextTick()
  231. expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
  232. })
  233. test('over slot composition', async () => {
  234. const foo = ref('injected!')
  235. const Provider = defineCustomElement({
  236. provide: {
  237. foo
  238. },
  239. render() {
  240. return renderSlot(this.$slots, 'default')
  241. }
  242. })
  243. customElements.define('my-provider-2', Provider)
  244. container.innerHTML = `<my-provider-2><my-consumer></my-consumer><my-provider-2>`
  245. const provider = container.childNodes[0]
  246. const consumer = provider.childNodes[0] as VueElement
  247. expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
  248. foo.value = 'changed!'
  249. await nextTick()
  250. expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
  251. })
  252. })
  253. describe('styles', () => {
  254. test('should attach styles to shadow dom', () => {
  255. const Foo = defineCustomElement({
  256. styles: [`div { color: red; }`],
  257. render() {
  258. return h('div', 'hello')
  259. }
  260. })
  261. customElements.define('my-el-with-styles', Foo)
  262. container.innerHTML = `<my-el-with-styles></my-el-with-styles>`
  263. const el = container.childNodes[0] as VueElement
  264. const style = el.shadowRoot?.querySelector('style')!
  265. expect(style.textContent).toBe(`div { color: red; }`)
  266. })
  267. })
  268. })