customElement.spec.ts 9.0 KB

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