@nutin/cli 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +322 -0
  2. package/README.md +14 -6
  3. package/bin/add-feature.mjs +1 -1
  4. package/bin/cli.mjs +1 -1
  5. package/bin/create-app.mjs +1 -1
  6. package/bin/update-app.mjs +1 -1
  7. package/lib/index.mjs +6 -2
  8. package/lib/src/add-feature/base-template-patcher.mjs +139 -0
  9. package/lib/src/add-feature/feature-adder.mjs +20 -9
  10. package/lib/src/add-feature/feature-context-builder.mjs +1 -0
  11. package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
  12. package/lib/src/add-feature/package-json-updater.mjs +7 -9
  13. package/lib/src/common/feature-registry.mjs +4 -0
  14. package/lib/src/common/file-generator.mjs +25 -3
  15. package/lib/src/common/git.mjs +31 -0
  16. package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
  17. package/lib/src/common/package-json-helper.mjs +14 -4
  18. package/lib/src/common/project-meta.mjs +2 -2
  19. package/lib/src/common/template-compiler.mjs +2 -0
  20. package/lib/src/common/transpile-ts.mjs +94 -0
  21. package/lib/src/create-app/context-builder.mjs +1 -0
  22. package/lib/src/create-app/git-manager.mjs +12 -2
  23. package/lib/src/create-app/json-manager.mjs +21 -18
  24. package/lib/src/create-app/new-app-prompt.mjs +4 -2
  25. package/lib/src/create-app/success-message.mjs +1 -1
  26. package/lib/src/update-app/conflict-report-writer.mjs +33 -21
  27. package/lib/src/update-app/json-drift.mjs +54 -0
  28. package/lib/src/update-app/project-updater.mjs +123 -14
  29. package/lib/src/update-app/template-differ.mjs +5 -5
  30. package/lib/src/update-app/three-way-merge.mjs +42 -0
  31. package/lib/src/update-app/update-context-builder.mjs +1 -0
  32. package/lib/src/update-app/update-summary-printer.mjs +10 -2
  33. package/package.json +12 -11
  34. package/templates/base/.gitignore.hbs +1 -0
  35. package/templates/base/AGENTS.md.hbs +45 -43
  36. package/templates/base/GETTING_STARTED.md.hbs +32 -23
  37. package/templates/base/README.md.hbs +7 -4
  38. package/templates/base/nutin.config.js.hbs +1 -1
  39. package/templates/base/src/app/components/.gitkeep +0 -0
  40. package/templates/base/src/app/globals.d.ts.hbs +6 -3
  41. package/templates/base/src/app/guards.ts.hbs +1 -3
  42. package/templates/base/src/app/main.ts.hbs +2 -9
  43. package/templates/base/src/app/routes.ts.hbs +9 -3
  44. package/templates/base/src/app/services/.gitkeep +0 -0
  45. package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
  46. package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
  47. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
  48. package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
  49. package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
  50. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
  51. package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
  52. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +307 -21
  53. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
  54. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +105 -4
  55. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +136 -62
  56. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
  57. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +101 -51
  58. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
  59. package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
  60. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
  61. package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
  62. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
  63. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
  64. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
  65. package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
  66. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
  67. package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
  68. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
  69. package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
  70. package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
  71. package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
  72. package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
  73. package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
  74. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
  75. package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
  76. package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
  77. package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
  78. package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
  79. package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
  80. package/templates/base/src/core/index.ts.hbs +4 -2
  81. package/templates/base/src/core/internals.d.ts.hbs +199 -20
  82. package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
  83. package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
  84. package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
  85. package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
  86. package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
  87. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
  88. package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
  89. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
  90. package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
  91. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
  92. package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
  93. package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
  94. package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
  95. package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
  96. package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
  97. package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
  98. package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
  99. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +217 -4
  100. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +210 -65
  101. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
  102. package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
  103. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
  104. package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
  105. package/templates/base/src/core/services/router/router.test.js.hbs +321 -0
  106. package/templates/base/src/core/services/router/router.ts.hbs +122 -54
  107. package/templates/base/src/index.html.hbs +1 -0
  108. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  109. package/templates/base/src/styles/main.scss.hbs +1 -1
  110. package/templates/base/tools/builder/builder.config.js.hbs +6 -0
  111. package/templates/base/tools/builder/builder.js.hbs +26 -8
  112. package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
  113. package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
  114. package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
  115. package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
  116. package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
  117. package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
  118. package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
  119. package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
  120. package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
  121. package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
  122. package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
  123. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  124. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  125. package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
  126. package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
  127. package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
  128. package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
  129. package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
  130. package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
  131. package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
  132. package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
  133. package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
  134. package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
  135. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +25 -8
  136. package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
  137. package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
  138. package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
  139. package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
  140. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +61 -15
  141. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
  142. package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
  143. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +48 -18
  144. package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
  145. package/templates/base/tools/builder/core/seo/route-output.js.hbs +26 -8
  146. package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
  147. package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
  148. package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
  149. package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
  150. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
  151. package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
  152. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +26 -9
  153. package/templates/base/tools/builder/core/styles/sass.js.hbs +7 -5
  154. package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
  155. package/templates/base/tools/dev/dev-serve.js.hbs +71 -7
  156. package/templates/base/tools/dev/serve.js.hbs +152 -22
  157. package/templates/base/tools/dev/watcher.js.hbs +75 -28
  158. package/templates/base/tools/generator/generator.js.hbs +23 -18
  159. package/templates/base/tools/generator/handle-file.js.hbs +4 -24
  160. package/templates/base/tools/generator/handle-file.test.js.hbs +4 -23
  161. package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
  162. package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
  163. package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
  164. package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
  165. package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
  166. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  167. package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
  168. package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
  169. package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
  170. package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
  171. package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
  172. package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
  173. package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
  174. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
  175. package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
  176. package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
  177. package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
  178. package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
  179. package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
  180. package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
  181. package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
  182. package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
  183. package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
  184. package/templates/base/tools/utils/build-lock.js.hbs +97 -0
  185. package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
  186. package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
  187. package/templates/base/tools/utils/format.js.hbs +1 -1
  188. package/templates/base/tools/utils/index.js.hbs +3 -1
  189. package/templates/base/tools/utils/print.js.hbs +2 -2
  190. package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
  191. package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
  192. package/templates/base/tools/utils/run-command.js.hbs +30 -1
  193. package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
  194. package/templates/base/tools/utils/run-script.js.hbs +2 -2
  195. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +5 -2
  196. package/templates/features/docker/tools/docker/nginx.conf.template +19 -2
  197. package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
  198. package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
  199. package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
  200. package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
  201. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
  202. package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
  203. package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
  204. package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
  205. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +29 -0
  206. package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
  207. package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
  208. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
  209. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
  210. package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
  211. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
  212. package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
  213. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
  214. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
  215. package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
  216. package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
  217. package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
  218. package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
  219. package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
  220. package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
  221. package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
  222. package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
  223. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
  224. package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
  225. package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
  226. package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
  227. package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
  228. package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
  229. package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
  230. package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
  231. package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
  232. package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
  233. package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
  234. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
  235. package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
  236. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
  237. package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
  238. package/templates/base/src/app/components/index.ts.hbs +0 -1
  239. package/templates/base/src/app/services/index.ts.hbs +0 -1
  240. package/templates/base/src/app/views/index.ts.hbs +0 -3
  241. /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
@@ -1,4 +1,4 @@
1
- import { DomHelper } from '#root/dist/src/core/base-classes/base-component/helpers/dom.helper.js';
1
+ import * as DomHelper from '#root/dist/src/core/base-classes/base-component/helpers/dom.helper.js';
2
2
 
3
3
  describe('DomHelper', () => {
4
4
  let container;
@@ -20,10 +20,19 @@ describe('DomHelper', () => {
20
20
  expect(container.contains(el)).toBe(true);
21
21
  });
22
22
 
23
- it('mountElement does nothing when the string selector target is not found', () => {
23
+ it('mountElement mounts nothing and logs an error when the target is not found', () => {
24
24
  const el = document.createElement('span');
25
- DomHelper.mountElement(el, '#does-not-exist');
26
- expect(el.parentElement).toBe(null);
25
+ const errorSpy = spyOn(console, 'error').andCallFake(() => {});
26
+ try {
27
+ DomHelper.mountElement(el, '#does-not-exist');
28
+ DomHelper.mountElement(document.createElement('div'), null);
29
+ expect(el.parentElement).toBe(null);
30
+ expect(errorSpy.callCount).toBe(2);
31
+ expect(errorSpy.calls[0][0]).toBe('Nothing to mount <span> into: "#does-not-exist" isn\'t in the page.');
32
+ expect(errorSpy.calls[1][0]).toContain('its mount target is null');
33
+ } finally {
34
+ errorSpy.restore();
35
+ }
27
36
  });
28
37
 
29
38
  it('mountElement replaces an HTMLElement target with the element', () => {
@@ -39,6 +48,45 @@ describe('DomHelper', () => {
39
48
  expect(container.contains(el)).toBe(true);
40
49
  });
41
50
 
51
+ it('mountElement carries the placeholder attributes onto the element, merging classes', () => {
52
+ const placeholder = document.createElement('div');
53
+ placeholder.className = 'my-class other-class';
54
+ placeholder.id = 'foo';
55
+ placeholder.setAttribute('aria-label', 'Label');
56
+ placeholder.setAttribute('data-extra', 'bar');
57
+ container.appendChild(placeholder);
58
+
59
+ const el = document.createElement('span');
60
+ el.className = 'own-class';
61
+ DomHelper.mountElement(el, placeholder);
62
+
63
+ expect(el.classList.contains('own-class')).toBe(true);
64
+ expect(el.classList.contains('my-class')).toBe(true);
65
+ expect(el.classList.contains('other-class')).toBe(true);
66
+ expect(el.id).toBe('foo');
67
+ expect(el.getAttribute('aria-label')).toBe('Label');
68
+ expect(el.getAttribute('data-extra')).toBe('bar');
69
+ });
70
+
71
+ it('mountElement does not carry data-component over from the placeholder', () => {
72
+ const placeholder = document.createElement('div');
73
+ placeholder.setAttribute('data-component', 'widget');
74
+ container.appendChild(placeholder);
75
+
76
+ const el = document.createElement('span');
77
+ DomHelper.mountElement(el, placeholder);
78
+
79
+ expect(el.hasAttribute('data-component')).toBe(false);
80
+ });
81
+
82
+ it('mountElement leaves the element attributes untouched in append mode', () => {
83
+ container.className = 'container-class';
84
+ const el = document.createElement('span');
85
+ DomHelper.mountElement(el, '#mount-target');
86
+
87
+ expect(el.attributes.length).toBe(0);
88
+ });
89
+
42
90
  it('createElement creates an element with the given tag name', () => {
43
91
  const el = DomHelper.createElement('section');
44
92
  expect(el.tagName).toBe('SECTION');
@@ -142,10 +190,63 @@ describe('DomHelper', () => {
142
190
  expect(container.querySelectorAll('span').length).toBe(0);
143
191
  });
144
192
 
193
+ it('cleanupOptionalContent only touches the given root, attached or not', () => {
194
+ container.innerHTML = '<p class="outside" data-optional></p>';
195
+ const detached = document.createElement('div');
196
+ detached.innerHTML = '<p class="inside" data-optional></p>';
197
+
198
+ DomHelper.cleanupOptionalContent(detached);
199
+
200
+ expect(detached.querySelector('.inside')).toBe(null);
201
+ expect(container.querySelector('.outside')).not.toBe(null);
202
+ });
203
+
145
204
  it('cleanupOptionalContent always strips the data-optional attribute from surviving elements', () => {
146
205
  container.innerHTML = '<span data-optional>hello</span>';
147
206
  DomHelper.cleanupOptionalContent();
148
207
  const span = container.querySelector('span');
149
208
  expect(span.hasAttribute('data-optional')).toBe(false);
150
209
  });
210
+
211
+ it('isInsideNestedComponent is true only below another component root inside the given root', () => {
212
+ container.innerHTML = '<p class="own"></p><div class="child"><p class="nested"></p></div>';
213
+ const child = container.querySelector('.child');
214
+ DomHelper.markComponentRoot(child);
215
+
216
+ expect(DomHelper.isInsideNestedComponent(container.querySelector('.own'), container)).toBe(false);
217
+ expect(DomHelper.isInsideNestedComponent(child, container)).toBe(false);
218
+ expect(DomHelper.isInsideNestedComponent(container.querySelector('.nested'), container)).toBe(true);
219
+ });
220
+
221
+ it('replacePlaceholder swaps the placeholder for the element', () => {
222
+ container.innerHTML = '<div class="placeholder"></div>';
223
+ const el = document.createElement('section');
224
+
225
+ DomHelper.replacePlaceholder(el, container.querySelector('.placeholder'));
226
+
227
+ expect(container.firstElementChild).toBe(el);
228
+ expect(container.querySelector('.placeholder')).toBe(null);
229
+ });
230
+
231
+ it('replacePlaceholder uses moveBefore when the element is connected and the browser supports it', () => {
232
+ const moved = [];
233
+ Element.prototype.moveBefore = function (node, child) {
234
+ moved.push(node);
235
+ this.insertBefore(node, child);
236
+ };
237
+
238
+ try {
239
+ container.innerHTML = '<div class="placeholder"></div>';
240
+ const el = document.createElement('section');
241
+ document.body.appendChild(el);
242
+
243
+ DomHelper.replacePlaceholder(el, container.querySelector('.placeholder'));
244
+
245
+ expect(moved).toEqual([el]);
246
+ expect(container.firstElementChild).toBe(el);
247
+ expect(container.querySelector('.placeholder')).toBe(null);
248
+ } finally {
249
+ delete Element.prototype.moveBefore;
250
+ }
251
+ });
151
252
  });
@@ -1,4 +1,4 @@
1
- import { SecurityHelper, TrustLevel } from "./security.helper.js";
1
+ import * as SecurityHelper from "./security.helper.js";
2
2
 
3
3
  interface DomElementConfig {
4
4
  target: Element | null;
@@ -6,76 +6,150 @@ interface DomElementConfig {
6
6
  element: HTMLElement
7
7
  }
8
8
 
9
- export class DomHelper {
10
- public static mountElement(element: HTMLElement, mountTarget: string | HTMLElement): void {
11
- const target = typeof mountTarget === 'string'
12
- ? document.querySelector(mountTarget)
13
- : mountTarget;
9
+ // Element.moveBefore() isn't in TypeScript's DOM lib yet.
10
+ type MovableParent = ParentNode & { moveBefore?: (node: Node, child: Node | null) => void };
14
11
 
15
- this.appendOrReplace({ target, element, mountTarget });
12
+ // Root element of every component instance, so a parent can tell its own
13
+ // nodes apart from the ones nested components own.
14
+ const componentRoots = new WeakSet<Element>();
15
+
16
+ export function markComponentRoot(element: HTMLElement): void {
17
+ componentRoots.add(element);
18
+ }
19
+
20
+ // True when `el` lives inside another component's root below `root`.
21
+ export function isInsideNestedComponent(el: Element, root: Element): boolean {
22
+ let current = el.parentElement;
23
+ while (current && current !== root) {
24
+ if (componentRoots.has(current)) return true;
25
+ current = current.parentElement;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ // State-preserving move (focus, iframes, animations, custom elements), only
31
+ // possible when both the node and the target parent are in the document.
32
+ export function canMoveBefore(parent: ParentNode, node: Node): boolean {
33
+ return typeof (parent as MovableParent).moveBefore === 'function'
34
+ && parent.isConnected
35
+ && node.isConnected;
36
+ }
37
+
38
+ export function moveBefore(parent: ParentNode, node: Node, child: Node | null): void {
39
+ (parent as MovableParent).moveBefore!(node, child);
40
+ }
41
+
42
+ // Chrome restarts the CSS animations of a subtree moved into a parent whose style
43
+ // hasn't been computed yet (e.g. created in the same task). Computing it first,
44
+ // before any move dirties it again, keeps them running.
45
+ export function prepareMoveTarget(parent: ParentNode | null, node: Node): void {
46
+ if (parent instanceof Element && canMoveBefore(parent, node)) {
47
+ void getComputedStyle(parent).display;
48
+ }
49
+ }
50
+
51
+ // Puts an already-mounted element where a placeholder is, moving it without
52
+ // detaching it when the browser supports it.
53
+ export function replacePlaceholder(element: HTMLElement, placeholder: HTMLElement): void {
54
+ const parent = placeholder.parentNode;
55
+ if (parent && canMoveBefore(parent, element)) {
56
+ moveBefore(parent, element, placeholder);
57
+ placeholder.remove();
58
+ } else {
59
+ placeholder.replaceWith(element);
16
60
  }
61
+ }
17
62
 
18
- public static createElement<T extends HTMLElement>(
19
- tagName: keyof HTMLElementTagNameMap,
20
- template: string = '',
21
- trustLevel?: TrustLevel
22
- ): T {
23
- const element = document.createElement(tagName) as T;
24
- element.innerHTML = SecurityHelper.sanitizeTemplate(template, trustLevel);
25
- return element;
63
+ export function mountElement(element: HTMLElement, mountTarget: string | HTMLElement): void {
64
+ const target = typeof mountTarget === 'string'
65
+ ? document.querySelector(mountTarget)
66
+ : mountTarget;
67
+
68
+ // Nothing would show up, and nothing else says why (e.g. a misspelled selector, or no #app).
69
+ if (!(target instanceof HTMLElement)) {
70
+ const where = typeof mountTarget === 'string' ? `"${mountTarget}" isn't in the page` : `its mount target is ${mountTarget}`;
71
+ console.error(`Nothing to mount <${element.tagName.toLowerCase()}> into: ${where}.`);
26
72
  }
27
-
28
- public static cleanupOptionalContent(): void {
29
- const isEmpty = (el: HTMLElement): boolean => {
30
- if (el instanceof HTMLImageElement) {
31
- return !el.src || el.src.trim() === "";
32
- }
33
-
34
- if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
35
- return !el.value?.trim();
36
- }
37
-
38
- if (el instanceof HTMLMediaElement || el instanceof HTMLSourceElement) {
39
- return !el.getAttribute("src");
40
- }
41
-
42
- const content = el.textContent?.trim();
43
- return !content || content === "undefined" || content === "null";
44
- };
45
-
46
- const isValueUndefined = (el: HTMLElement): boolean => {
47
- const value = el.getAttribute("data-optional");
48
- /*
49
- A bare/empty data-optional (getAttribute returns "") is a plain marker and
50
- defers entirely to isEmpty(el) below rather than forcing removal itself.
51
- Only a present-but-blank value (whitespace) or the literal string produced
52
- by interpolating an undefined/null expression counts as "value undefined".
53
- */
54
- if (!value) return false;
55
- return (
56
- value.trim() === '' ||
57
- value === "undefined" ||
58
- value === "null"
59
- );
73
+
74
+ appendOrReplace({ target, element, mountTarget });
75
+ }
76
+
77
+ export function createElement<T extends HTMLElement>(
78
+ tagName: keyof HTMLElementTagNameMap,
79
+ template: string = '',
80
+ trustLevel?: Nutin.TrustLevel
81
+ ): T {
82
+ const element = document.createElement(tagName) as T;
83
+ element.replaceChildren(SecurityHelper.sanitizeToFragment(template, trustLevel));
84
+ return element;
85
+ }
86
+
87
+ // Scoped to the rendering component (a detached one included); the document-wide
88
+ // default only serves direct callers.
89
+ export function cleanupOptionalContent(root: ParentNode = document): void {
90
+ const isEmpty = (el: HTMLElement): boolean => {
91
+ if (el instanceof HTMLImageElement) {
92
+ return !el.src || el.src.trim() === "";
93
+ }
94
+
95
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
96
+ return !el.value?.trim();
97
+ }
98
+
99
+ if (el instanceof HTMLMediaElement || el instanceof HTMLSourceElement) {
100
+ return !el.getAttribute("src");
60
101
  }
61
102
 
62
- document.querySelectorAll<HTMLElement>("[data-optional]").forEach(el => {
63
- if (isValueUndefined(el) || isEmpty(el)) el.remove();
64
- el.removeAttribute("data-optional");
65
- });
103
+ const content = el.textContent?.trim();
104
+ return !content || content === "undefined" || content === "null";
105
+ };
106
+
107
+ const isValueUndefined = (el: HTMLElement): boolean => {
108
+ const value = el.getAttribute("data-optional");
109
+ /*
110
+ A bare/empty data-optional (getAttribute returns "") is a plain marker and
111
+ defers entirely to isEmpty(el) below rather than forcing removal itself.
112
+ Only a present-but-blank value (whitespace) or the literal string produced
113
+ by interpolating an undefined/null expression counts as "value undefined".
114
+ */
115
+ if (!value) return false;
116
+ return (
117
+ value.trim() === '' ||
118
+ value === "undefined" ||
119
+ value === "null"
120
+ );
66
121
  }
67
122
 
68
- private static appendOrReplace(config: DomElementConfig) {
69
- if (config.target instanceof HTMLElement) {
70
- if (typeof config.mountTarget === 'string') {
71
- // Append mode
72
- config.target.appendChild(config.element);
73
- } else {
74
- // Replace placeholder mode
75
- config.target.replaceWith(config.element);
76
- }
123
+ root.querySelectorAll<HTMLElement>("[data-optional]").forEach(el => {
124
+ if (isValueUndefined(el) || isEmpty(el)) el.remove();
125
+ el.removeAttribute("data-optional");
126
+ });
127
+ }
128
+
129
+ function appendOrReplace(config: DomElementConfig) {
130
+ if (config.target instanceof HTMLElement) {
131
+ if (typeof config.mountTarget === 'string') {
132
+ // Append mode
133
+ config.target.appendChild(config.element);
77
134
  } else {
78
- return;
135
+ // Replace placeholder mode
136
+ copyAttributes(config.target, config.element);
137
+ config.target.replaceWith(config.element);
79
138
  }
139
+ } else {
140
+ return;
80
141
  }
81
142
  }
143
+
144
+ // Carries placeholder attributes (class, id, aria-*, data-*...) onto the mounted
145
+ // element. data-component is skipped so an ancestor's child lookup can't re-match it.
146
+ function copyAttributes(from: HTMLElement, to: HTMLElement): void {
147
+ Array.from(from.attributes).forEach(attr => {
148
+ if (attr.name === 'data-component') return;
149
+ if (attr.name === 'class') {
150
+ to.classList.add(...Array.from(from.classList));
151
+ return;
152
+ }
153
+ to.setAttribute(attr.name, attr.value);
154
+ });
155
+ }
@@ -1,4 +1,5 @@
1
- import { EventHelper } from '#root/dist/src/core/base-classes/base-component/helpers/event.helper.js';
1
+ import * as EventHelper from '#root/dist/src/core/base-classes/base-component/helpers/event.helper.js';
2
+ import * as DomHelper from '#root/dist/src/core/base-classes/base-component/helpers/dom.helper.js';
2
3
 
3
4
  describe('EventHelper', () => {
4
5
  let container;
@@ -56,11 +57,33 @@ describe('EventHelper', () => {
56
57
  expect(eventListeners.length).toBe(0);
57
58
  });
58
59
 
59
- it('does nothing when the referenced handler does not exist or is not a function', () => {
60
- container.innerHTML = '<button data-event="click:missingHandler"></button>';
60
+ it('binds nothing and warns when the referenced handler does not exist or is not a function', () => {
61
+ container.innerHTML = '<button data-event="click:missingHandler"></button><a data-event="click:typo"></a>';
61
62
  const component = { missingHandler: 'not-a-function' };
63
+ const warnSpy = spyOn(console, 'warn').andCallFake(() => {});
64
+ try {
65
+ EventHelper.bindEvents(component, container, eventListeners);
66
+ expect(eventListeners.length).toBe(0);
67
+ expect(warnSpy.callCount).toBe(2);
68
+ expect(warnSpy.calls[1][0]).toContain('has no method "typo"');
69
+ } finally {
70
+ warnSpy.restore();
71
+ }
72
+ });
73
+
74
+ it('keeps commas inside quoted literal args', () => {
75
+ container.innerHTML = `<button data-event="click:handleClick:'a,b',2,&quot;c,d&quot;"></button>`;
76
+ const button = container.querySelector('button');
77
+
78
+ let received = null;
79
+ const component = {
80
+ handleClick(...args) { received = args; },
81
+ };
82
+
62
83
  EventHelper.bindEvents(component, container, eventListeners);
63
- expect(eventListeners.length).toBe(0);
84
+ click(button);
85
+
86
+ expect(received).toEqual(['a,b', 2, 'c,d']);
64
87
  });
65
88
 
66
89
  it('tracks each bound listener in the eventListeners array', () => {
@@ -73,6 +96,64 @@ describe('EventHelper', () => {
73
96
  expect(eventListeners[0][1]).toBe('click');
74
97
  });
75
98
 
99
+ it('skips data-event elements inside a nested component root, which binds its own', () => {
100
+ container.innerHTML = '<button class="own" data-event="click:toggle"></button><div class="child"><button class="nested" data-event="click:toggle"></button></div>';
101
+ DomHelper.markComponentRoot(container.querySelector('.child'));
102
+ let calls = 0;
103
+ const component = { toggle() { calls++; } };
104
+
105
+ EventHelper.bindEvents(component, container, eventListeners);
106
+ click(container.querySelector('.nested'));
107
+ expect(calls).toBe(0);
108
+
109
+ click(container.querySelector('.own'));
110
+ expect(calls).toBe(1);
111
+ });
112
+
113
+ it('cancels link navigation, with or without args', () => {
114
+ container.innerHTML = '<a href="/x" data-event="click:go"></a><a href="/y" data-event="click:go:1"></a>';
115
+ const component = { go() {} };
116
+ EventHelper.bindEvents(component, container, eventListeners);
117
+
118
+ container.querySelectorAll('a').forEach((a) => {
119
+ const ev = new window.MouseEvent('click', { bubbles: true, cancelable: true });
120
+ a.dispatchEvent(ev);
121
+ expect(ev.defaultPrevented).toBe(true);
122
+ });
123
+ });
124
+
125
+ it('cancels form submission from a submit event or a submit button click', () => {
126
+ container.innerHTML = '<form data-event="submit:save"><button type="submit" data-event="click:save"></button></form>';
127
+ const component = { save() {} };
128
+ EventHelper.bindEvents(component, container, eventListeners);
129
+
130
+ const submit = new window.Event('submit', { bubbles: true, cancelable: true });
131
+ container.querySelector('form').dispatchEvent(submit);
132
+ const buttonClick = new window.MouseEvent('click', { bubbles: true, cancelable: true });
133
+ container.querySelector('button').dispatchEvent(buttonClick);
134
+
135
+ expect(submit.defaultPrevented).toBe(true);
136
+ expect(buttonClick.defaultPrevented).toBe(true);
137
+ });
138
+
139
+ it('keeps the default of other events, args or not (typing, checking a box)', () => {
140
+ container.innerHTML = '<input class="text" data-event="keydown:onKey:@key"><input class="box" type="checkbox" data-event="click:onToggle:@checked">';
141
+ let checked = null;
142
+ const component = { onKey() {}, onToggle(value) { checked = value; } };
143
+ EventHelper.bindEvents(component, container, eventListeners);
144
+
145
+ const keydown = new window.KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true });
146
+ container.querySelector('.text').dispatchEvent(keydown);
147
+ const box = container.querySelector('.box');
148
+ const boxClick = new window.MouseEvent('click', { bubbles: true, cancelable: true });
149
+ box.dispatchEvent(boxClick);
150
+
151
+ expect(keydown.defaultPrevented).toBe(false);
152
+ expect(boxClick.defaultPrevented).toBe(false);
153
+ expect(checked).toBe(true);
154
+ expect(box.checked).toBe(true);
155
+ });
156
+
76
157
  it('destroyEvents removes every tracked listener', () => {
77
158
  container.innerHTML = '<button data-event="click:handleClick"></button>';
78
159
  const button = container.querySelector('button');
@@ -1,56 +1,106 @@
1
- import { BaseComponent } from '../base-component.js';
2
- import { TokenHelper } from './token.helper.js';
3
-
4
- export class EventHelper {
5
- public static bindEvents(
6
- component: BaseComponent,
7
- element: HTMLElement,
8
- eventListeners: Array<[EventTarget, string, EventListener]>
9
- ): void {
10
- element.querySelectorAll('[data-event]').forEach(el => {
11
- const [eventName, handlerName, ...argParts] = el.getAttribute('data-event')!.split(':');
12
- const argsString = argParts.length ? argParts.join(':') : '';
13
-
14
- if (!handlerName || !eventName) return;
15
-
16
- const handler = (component as any)[handlerName];
17
-
18
- if (typeof handler === 'function') {
19
- const rawArgs = argsString ? argsString.split(',') : [];
20
- const boundHandler = this.createBoundHandler(el, component, handler, rawArgs);
21
- this.addEvent(el, eventName, boundHandler, eventListeners);
22
- }
23
- });
24
- }
1
+ import type { BaseComponent } from '../base-component.js';
2
+ import * as DomHelper from './dom.helper.js';
3
+ import * as TokenHelper from './token.helper.js';
25
4
 
26
- public static destroyEvents(
27
- eventListeners: Array<[EventTarget, string, EventListener]>
28
- ): void {
29
- eventListeners.forEach(([target, event, listener]) => {
30
- target.removeEventListener(event, listener);
31
- });
32
- }
5
+ export function bindEvents(
6
+ component: BaseComponent,
7
+ element: HTMLElement,
8
+ eventListeners: Array<[EventTarget, string, EventListener]>
9
+ ): void {
10
+ element.querySelectorAll('[data-event]').forEach(el => {
11
+ // A nested component binds its own data-event elements to its own methods.
12
+ if (DomHelper.isInsideNestedComponent(el, element)) return;
33
13
 
34
- private static createBoundHandler(
35
- el: Element,
36
- component: BaseComponent,
37
- handler: (...args: any[]) => void,
38
- rawArgs: string[]
39
- ): EventListener {
40
- return (event: Event) => {
41
- const resolvedArgs = rawArgs.map(arg => TokenHelper.resolve(arg.trim(), el, event));
42
-
43
- handler.call(component, ...resolvedArgs);
44
- };
45
- }
14
+ const [eventName, handlerName, ...argParts] = el.getAttribute('data-event')!.split(':');
15
+ const argsString = argParts.length ? argParts.join(':') : '';
16
+
17
+ if (!handlerName || !eventName) return;
18
+
19
+ const handler = (component as any)[handlerName];
20
+
21
+ if (typeof handler !== 'function') {
22
+ // TypeScript builds already fail on a handler the class doesn't have (see handler-refs.js).
23
+ console.warn(`data-event="${el.getAttribute('data-event')}": ${component.constructor.name} has no method "${handlerName}".`);
24
+ return;
25
+ }
46
26
 
47
- private static addEvent(
48
- target: EventTarget,
49
- event: string,
50
- listener: EventListener,
51
- eventListeners: Array<[EventTarget, string, EventListener]>
52
- ): void {
53
- target.addEventListener(event, listener);
54
- eventListeners.push([target, event, listener]);
27
+ const rawArgs = argsString ? splitArgs(argsString) : [];
28
+ const boundHandler = createBoundHandler(el, component, handler, rawArgs);
29
+ addEvent(el, eventName, boundHandler, eventListeners);
30
+ });
31
+ }
32
+
33
+ // Splits data-event args on commas, except inside a quoted literal ('a,b' or "a,b").
34
+ function splitArgs(argsString: string): string[] {
35
+ const args: string[] = [];
36
+ let current = '';
37
+ let quote: string | null = null;
38
+ for (const ch of argsString) {
39
+ if (quote) {
40
+ if (ch === quote) quote = null;
41
+ } else if (ch === '"' || ch === "'") {
42
+ quote = ch;
43
+ } else if (ch === ',') {
44
+ args.push(current);
45
+ current = '';
46
+ continue;
47
+ }
48
+ current += ch;
55
49
  }
50
+ args.push(current);
51
+ return args;
52
+ }
53
+
54
+ export function destroyEvents(
55
+ eventListeners: Array<[EventTarget, string, EventListener]>
56
+ ): void {
57
+ eventListeners.forEach(([target, event, listener]) => {
58
+ target.removeEventListener(event, listener);
59
+ });
60
+ }
61
+
62
+ function createBoundHandler(
63
+ el: Element,
64
+ component: BaseComponent,
65
+ handler: (...args: any[]) => void,
66
+ rawArgs: string[]
67
+ ): EventListener {
68
+ return (event: Event) => {
69
+ if (isModifiedAnchorClick(el, event)) return;
70
+ if (isNavigationDefault(el, event)) event.preventDefault();
71
+
72
+ const resolvedArgs = rawArgs.map(arg => TokenHelper.resolve(arg.trim(), el, event));
73
+
74
+ handler.call(component, ...resolvedArgs);
75
+ };
76
+ }
77
+
78
+ // Lets a real <a href> fall through to native browser behavior (open in new tab, etc.)
79
+ // on a modified or non-primary click, instead of always intercepting via preventDefault.
80
+ function isModifiedAnchorClick(el: Element, event: Event): boolean {
81
+ if (event.type !== 'click' || el.tagName !== 'A' || !el.getAttribute('href')) return false;
82
+
83
+ const mouseEvent = event as MouseEvent;
84
+ return mouseEvent.ctrlKey || mouseEvent.metaKey || mouseEvent.shiftKey || mouseEvent.altKey || mouseEvent.button !== 0;
85
+ }
86
+
87
+ // Only the defaults that would leave the page are cancelled: following a link and
88
+ // submitting a form. Everything else (typing, checking a box, ...) keeps its default.
89
+ function isNavigationDefault(el: Element, event: Event): boolean {
90
+ if (event.type === 'submit') return true;
91
+ if (event.type !== 'click') return false;
92
+ if (el.tagName === 'A') return el.hasAttribute('href');
93
+ return (el instanceof HTMLButtonElement || el instanceof HTMLInputElement)
94
+ && el.type === 'submit'
95
+ && el.form !== null;
96
+ }
97
+
98
+ function addEvent(
99
+ target: EventTarget,
100
+ event: string,
101
+ listener: EventListener,
102
+ eventListeners: Array<[EventTarget, string, EventListener]>
103
+ ): void {
104
+ target.addEventListener(event, listener);
105
+ eventListeners.push([target, event, listener]);
56
106
  }