├── .editorconfig ├── .ember-cli ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .template-lintrc.js ├── .tool-versions ├── .travis.yml ├── .watchmanconfig ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── addon ├── .gitkeep ├── index.js ├── instance-initializers │ └── ember-custom-elements.js └── lib │ ├── block-content.js │ ├── common.js │ ├── custom-element.js │ ├── ember-compat.js │ ├── glimmer-compat.js │ ├── outlet-element.js │ ├── route-connections.js │ └── template-compiler.js ├── app ├── .gitkeep ├── index.js └── instance-initializers │ └── ember-custom-elements.js ├── config ├── ember-try.js └── environment.js ├── ember-cli-build.js ├── index.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── testem.js ├── tests ├── acceptance │ └── ember-custom-elements-test.js ├── dummy-add-on │ ├── addon │ │ └── components │ │ │ └── dummy-add-on-component.js │ ├── app │ │ ├── .gitkeep │ │ ├── components │ │ │ └── dummy-add-on-component.js │ │ └── templates │ │ │ └── components │ │ │ └── dummy-add-on-component.hbs │ ├── index.js │ └── package.json ├── dummy │ ├── app │ │ ├── app.js │ │ ├── components │ │ │ ├── .gitkeep │ │ │ └── test-component.js │ │ ├── controllers │ │ │ └── .gitkeep │ │ ├── helpers │ │ │ └── .gitkeep │ │ ├── index.html │ │ ├── models │ │ │ └── .gitkeep │ │ ├── router.js │ │ ├── routes │ │ │ ├── .gitkeep │ │ │ └── test.js │ │ ├── styles │ │ │ └── app.css │ │ └── templates │ │ │ ├── application.hbs │ │ │ ├── components │ │ │ └── test-component.hbs │ │ │ ├── test-loading.hbs │ │ │ ├── test.hbs │ │ │ └── test │ │ │ ├── bar.hbs │ │ │ ├── foo.hbs │ │ │ └── foo │ │ │ └── bar.hbs │ ├── config │ │ ├── environment.js │ │ ├── optional-features.json │ │ └── targets.js │ └── public │ │ └── robots.txt ├── helpers │ ├── .gitkeep │ ├── ember-custom-elements.js │ └── set-component-template.js ├── index.html ├── integration │ ├── .gitkeep │ ├── ember-custom-elements-test.js │ └── outlet-element-test.js ├── test-helper.js └── unit │ ├── .gitkeep │ └── lib │ └── template-compiler-test.js └── vendor └── .gitkeep /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": true 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | 17 | # ember-try 18 | /.node_modules.ember-try/ 19 | /bower.json.ember-try 20 | /package.json.ember-try 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'babel-eslint', 6 | parserOptions: { 7 | ecmaVersion: 2018, 8 | sourceType: 'module', 9 | ecmaFeatures: { 10 | legacyDecorators: true 11 | } 12 | }, 13 | plugins: [ 14 | 'ember' 15 | ], 16 | extends: [ 17 | 'eslint:recommended', 18 | 'plugin:ember/recommended' 19 | ], 20 | env: { 21 | browser: true 22 | }, 23 | rules: { 24 | 'ember/no-jquery': 'error' 25 | }, 26 | overrides: [ 27 | // node files 28 | { 29 | files: [ 30 | '.eslintrc.js', 31 | '.template-lintrc.js', 32 | 'ember-cli-build.js', 33 | 'index.js', 34 | 'testem.js', 35 | 'blueprints/*/index.js', 36 | 'config/**/*.js', 37 | 'tests/dummy/config/**/*.js' 38 | ], 39 | excludedFiles: [ 40 | 'addon/**', 41 | 'addon-test-support/**', 42 | 'app/**', 43 | 'tests/dummy/app/**' 44 | ], 45 | parserOptions: { 46 | sourceType: 'script' 47 | }, 48 | env: { 49 | browser: false, 50 | node: true 51 | }, 52 | plugins: ['node'], 53 | rules: Object.assign({}, require('eslint-plugin-node').configs.recommended.rules, { 54 | // add your custom rules and overrides for node files here 55 | }) 56 | } 57 | ] 58 | }; 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist/ 5 | /tmp/ 6 | 7 | # dependencies 8 | /bower_components/ 9 | /node_modules/ 10 | /template-compiler/ 11 | /tests/dummy-add-on/node_modules/ 12 | 13 | # misc 14 | /.env* 15 | /.pnp* 16 | /.sass-cache 17 | /connect.lock 18 | /coverage/ 19 | /libpeerconnection.log 20 | /npm-debug.log* 21 | /testem.log 22 | /yarn-error.log 23 | .DS_Store 24 | 25 | # ember-try 26 | /.node_modules.ember-try/ 27 | /bower.json.ember-try 28 | /package.json.ember-try 29 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /tmp/ 4 | 5 | # dependencies 6 | /bower_components/ 7 | 8 | # misc 9 | /.bowerrc 10 | /.editorconfig 11 | /.ember-cli 12 | /.env* 13 | /.eslintignore 14 | /.eslintrc.js 15 | /.git/ 16 | /.gitignore 17 | /.template-lintrc.js 18 | /.travis.yml 19 | /.watchmanconfig 20 | /bower.json 21 | /config/ember-try.js 22 | /CONTRIBUTING.md 23 | /ember-cli-build.js 24 | /testem.js 25 | /tests/ 26 | /yarn.lock 27 | .gitkeep 28 | 29 | # ember-try 30 | /.node_modules.ember-try/ 31 | /bower.json.ember-try 32 | /package.json.ember-try 33 | -------------------------------------------------------------------------------- /.template-lintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'octane' 5 | }; 6 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 10.24.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | node_js: 4 | # we recommend testing addons with the same minimum supported node version as Ember CLI 5 | # so that your addon works for all apps 6 | - "10" 7 | 8 | dist: xenial 9 | 10 | addons: 11 | chrome: stable 12 | 13 | cache: 14 | directories: 15 | - $HOME/.npm 16 | 17 | env: 18 | global: 19 | # See https://git.io/vdao3 for details. 20 | - JOBS=1 21 | 22 | branches: 23 | only: 24 | - master 25 | # npm version tags 26 | - /^v\d+\.\d+\.\d+/ 27 | 28 | jobs: 29 | fast_finish: true 30 | allow_failures: 31 | - env: EMBER_TRY_SCENARIO=ember-canary 32 | 33 | include: 34 | # runs linting and tests with current locked deps 35 | - stage: "Tests" 36 | name: "Tests" 37 | script: 38 | - npm run lint 39 | - npm run test:ember 40 | 41 | - stage: "Additional Tests" 42 | name: "Floating Dependencies" 43 | install: 44 | - npm install --no-package-lock 45 | script: 46 | - npm run test:ember 47 | 48 | # we recommend new addons test the current and previous LTS 49 | # as well as latest stable release (bonus points to beta/canary) 50 | - env: EMBER_TRY_SCENARIO=ember-lts-3.8 51 | - env: EMBER_TRY_SCENARIO=ember-lts-3.12 52 | - env: EMBER_TRY_SCENARIO=ember-lts-3.16 53 | - env: EMBER_TRY_SCENARIO=ember-lts-3.20 54 | - env: EMBER_TRY_SCENARIO=ember-lts-3.24 55 | - env: EMBER_TRY_SCENARIO=ember-release 56 | - env: EMBER_TRY_SCENARIO=ember-beta 57 | - env: EMBER_TRY_SCENARIO=ember-canary 58 | - env: EMBER_TRY_SCENARIO=ember-classic 59 | 60 | script: 61 | - node_modules/.bin/ember try:one $EMBER_TRY_SCENARIO 62 | 63 | install: 64 | - npm ci -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Ember Web Components Changelog 2 | ============================== 3 | 4 | ## v2.1.0 5 | 6 | - Add support for unwrapped dynamic block content, removing the requirement to place dynamic block content within another element 7 | - Add support for using the `@customElement` decorator on descendent of HTMLElement 8 | - Make non-standard custom element properties private 9 | - Fix serious bug with non-owned apps failing to render 10 | - Drop Ember 3.6 support. 11 | 12 | ## v2.0.4 13 | 14 | - Fix rendering failure for Ember >= 3.20.0 15 | 16 | ## v2.0.3 17 | 18 | - Fix support for use of the decorator within add-ons 19 | - Fix Glimmer component check for production 20 | 21 | ## v2.0.2 22 | 23 | - Ensure `connectedCallback` runs after Ember initializer 24 | 25 | ## v2.0.1 26 | 27 | - Decorated components shouldn't break when not being invoked from a custom element. 28 | 29 | ## v2.0.0 30 | 31 | - Replace `this.args.customElement` with the `getCustomElement` helper function. 32 | - Implement `forwarded` decorator to provide a way for creating interfaces between components and custom elements. 33 | 34 | ## v1.0.0 35 | 36 | - Change `useShadowRoot` to be false by default, making shadow roots opt-in. This is to avoid extra complexity when rendering components that depend on global styles, which is almost always the expectation. 37 | - Change the rendering behavior for applications to rely on route/outlet rendering for better portability and more shallow HTML. 38 | - Fix faulty outlet state identification logic which was breaking for outlets rendering a different template than the default one for a given route. 39 | 40 | ## v0.4.0 41 | 42 | - Expose the custom element node via the `customElement` component arg. 43 | - Fix misfiring log warning 44 | 45 | ## v0.3.0 46 | 47 | - Add global default options inside `config/environment.js` under `ENV.emberCustomElements.defaultOptions`. 48 | - Add private `deoptimizeModuleEval` option. 49 | 50 | ## v0.2.1 51 | 52 | - Fixed bug with conditional logic surrounding block content, which was causing an infinite render loop. 53 | 54 | ## v0.2.0 55 | 56 | - Added `preserveOutletContent` option, which can be used to keep outlet DOM contents from being cleared when navigating away from a route. 57 | - Fixed a bug in the Outlet element where router event listeners were not being removed, causing the outlet to try and update even after the outlet view has been destroyed. 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | ## Installation 4 | 5 | * `git clone ` 6 | * `cd ember-custom-elements` 7 | * `npm install` 8 | 9 | ## Linting 10 | 11 | * `npm run lint:hbs` 12 | * `npm run lint:js` 13 | * `npm run lint:js -- --fix` 14 | 15 | ## Running tests 16 | 17 | * `ember test` – Runs the test suite on the current Ember version 18 | * `ember test --server` – Runs the test suite in "watch mode" 19 | * `ember try:each` – Runs the test suite against multiple Ember versions 20 | 21 | ## Running the dummy application 22 | 23 | * `ember serve` 24 | * Visit the dummy application at [http://localhost:4200](http://localhost:4200). 25 | 26 | For more information on using ember-cli, visit [https://ember-cli.com/](https://ember-cli.com/). 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.com/Ravenstine/ember-custom-elements.svg?branch=master)](https://travis-ci.com/Ravenstine/ember-custom-elements) 2 | [![npm version](https://badge.fury.io/js/ember-custom-elements.svg)](https://badge.fury.io/js/ember-custom-elements) 3 | 4 | Ember Custom Elements 5 | ===================== 6 | 7 | The most flexible way to render parts of your Ember application using custom elements! 8 | 9 | 10 | ## Demos 11 | 12 | - [Tic Tac Toe game using Ember and React](https://ember-twiddle.com/8fa62cb81a790a3afb6713fd9f2480b5) (based on the [React.js tutorial](https://reactjs.org/tutorial/tutorial.html)) 13 | - [Super Rentals w/ animated route transitions](https://ember-twiddle.com/aa7bd7a7d36641dd5daa5ad6b6eebb5a) (combines custom elements for routes with [Ionic Framework](https://ionicframework.com/)'s animated nav) 14 | - [Nifty Squares](https://ember-twiddle.com/f99d7cb679baf906c3d6b1435e52fdf9) (demonstrates dynamic block content) 15 | 16 | 17 | ## Table of Contents 18 | 19 | * [Compatibility](#compatibility) 20 | * [Installation](#installation) 21 | * [Usage](#usage) 22 | * [Components](#components) 23 | * [Attributes and Arguments](#attributes-and-arguments) 24 | * [Block Content](#block-content) 25 | * [Routes](#routes) 26 | * [Named Outlets](#named-outlets) 27 | * [Outlet Element](#outlet-element) 28 | * [Applications](#applications) 29 | * [Native Custom Elements](#native-custom-elements) 30 | * [Options](#options) 31 | * [Accessing a Custom Element](#accessing-a-custom-element) 32 | * [Forwarding Component Properties](#forwarding-component-properties) 33 | * [Notes](#notes) 34 | * [Elements](#elements) 35 | * [Runloop](#runloop) 36 | * [Contributing](#contributing) 37 | * [License](#license) 38 | 39 | 40 | 41 | ## Compatibility 42 | 43 | * Ember.js v3.8 or above 44 | * Ember CLI v2.13 or above 45 | * Node.js v10 or above 46 | 47 | This add-on won't work at all with versions of `ember-source` prior to `3.6.0`. I will not be actively trying to support versions of Ember that are not recent LTS versions, but I'm open to any pull requests that improve backward compatibility. 48 | 49 | 50 | ## Installation 51 | 52 | ``` 53 | ember install ember-custom-elements 54 | ``` 55 | 56 | If you are targeting older browsers, you may want to use a [polyfill for custom elements](https://github.com/webcomponents/polyfills/tree/master/packages/custom-elements). Other features of web components are also available as [polyfills](https://github.com/webcomponents/polyfills). 57 | 58 | 59 | ## Usage 60 | 61 | 62 | 63 | ### Components 64 | 65 | All you have to do is use the `customElement` decorator in your component file: 66 | 67 | ```javascript 68 | import Component from '@glimmer/component'; 69 | import { customElement } from 'ember-custom-elements'; 70 | 71 | @customElement('my-component') 72 | export default MyComponent extends Component { 73 | 74 | } 75 | ``` 76 | 77 | Now you can use your component _anywhere_ inside the window that your app was instantiated within by using your custom element: 78 | 79 | ```handlebars 80 | 81 | ``` 82 | 83 | In the case that you can't use TC39's proposed decorator syntax, you can call customElement as a function and pass the target class as the first argument: 84 | 85 | ```javascript 86 | export default customElement(MyComponent, 'my-component'); 87 | ``` 88 | 89 | However, it's recommended that you upgrade to a recent version of [ember-cli-babel](https://github.com/babel/ember-cli-babel) so you can use decorator syntax out of the box, or manually install [babel-plugin-proposal-decorators](https://babeljs.io/docs/en/babel-plugin-proposal-decorators). 90 | 91 | In newer versions of Ember, you will get a linting error if you have an empty backing class for your component. Since the `@customElement` decorator needs to be used in a JS file in order to implement a custom element for your component, you may have no choice but to have an empty backing class. 92 | 93 | Thus, you may want to disable the `ember/no-empty-glimmer-component-classes` ESLint rule in your Ember project. In the future, we will explore ways to define custom elements for tagless components, but until then you either need a component class defined. 94 | 95 | 96 | 97 | 98 | #### Attributes and Arguments 99 | 100 | Attributes instances of your custom element are translated to arguments to your component: 101 | 102 | ```handlebars 103 | 104 | ``` 105 | 106 | To use the attribute in your component template, you would use it like any other argument: 107 | 108 | ```handlebars 109 | {{!-- my-component.hbs --}} 110 | {{@some-message}} 111 | ``` 112 | 113 | Changes to attributes are observed, and so argument values are updated automatically. 114 | 115 | 116 | 117 | 118 | #### Block Content 119 | 120 | Block content inside your custom element instances can be treated just like block content within a precompiled template. If your component contains a `{{yield}}` statement, that's where the block content will end up. 121 | 122 | ```handlebars 123 | {{!-- my-component.hbs --}} 124 | foo {{yield}} baz 125 | ``` 126 | 127 | ```handlebars 128 | bar 129 | ``` 130 | 131 | When the component is rendered, we get this: 132 | 133 | ```handlebars 134 | foo bar baz 135 | ``` 136 | 137 | Block content can be dynamic. However, the consuming element needs to be able to handle its children being changed by other forces outside of it; if a child that's dynamic gets removed by the custom element itself, that can lead to the renderer getting confused and spitting out errors during runtime. 138 | 139 | You can see dynamic block content can work in [this demo](https://ember-twiddle.com/f99d7cb679baf906c3d6b1435e52fdf9). 140 | 141 | 142 | 143 | ### Routes 144 | 145 | The `@customElement` decorator can define a custom element that renders an active route, much like the `{{outlet}}` helper does. In fact, this is achieved by creating an outlet view that renders the main outlet for the route. 146 | 147 | Just like with components, you can use it directly on your route class: 148 | 149 | ```javascript 150 | /* app/routes/posts.js */ 151 | 152 | import Route from '@ember/routing/route'; 153 | import { customElement } from 'ember-custom-elements'; 154 | 155 | @customElement('test-route') 156 | export default class PostsRoute extends Route { 157 | model() { 158 | ... 159 | } 160 | } 161 | ``` 162 | 163 | In this case, the `` element will render your route when it has been entered in your application. 164 | 165 | 166 | 167 | 168 | #### Named Outlets 169 | 170 | If your route renders to [named outlets](https://api.emberjs.com/ember/release/classes/Route/methods/renderTemplate?anchor=renderTemplate), you can define custom elements for each outlet with the `outletName` option: 171 | 172 | ```javascript 173 | /* app/routes/posts.js */ 174 | 175 | import Route from '@ember/routing/route'; 176 | import { customElement } from 'ember-custom-elements'; 177 | 178 | @customElement('test-route') 179 | @customElement('test-route-sidebar', { outletName: 'sidebar' }) 180 | export default class PostsRoute extends Route { 181 | model() { 182 | ... 183 | } 184 | 185 | renderTemplate() { 186 | this.render(); 187 | this.render('posts/sidebar', { 188 | outlet: 'sidebar' 189 | }); 190 | } 191 | } 192 | ``` 193 | 194 | In this example, the `` element exhibits the same behavior as `{{outlet "sidebar"}}` would inside the parent route of the `posts` route. Notice that the `outletName` option reflects the name of the outlet specified in the call to the `render()` method. 195 | 196 | Note that the use of `renderTemplate` is being deprecated in newer versions of Ember. 197 | 198 | 199 | 200 | 201 | #### Outlet Element 202 | 203 | This add-on comes with a primitive custom element called `` which can allow you to dynamically render outlets, but with a few differences from the `{{outlet}}` helper due to technical limitations from rendering outside of a route hierarchy. 204 | 205 | 206 | 207 | 208 | ##### Usage 209 | 210 | The outlet element will not be defined by default. You must do this with the `@customElement` decorator function. Here is an example of an instance-initializer you can add to your application that will set up the outlet element: 211 | 212 | ```javascript 213 | // app/custom-elements.js 214 | 215 | import { setOwner } from '@ember/application'; 216 | import { customElement, EmberOutletElement } from 'ember-custom-elements'; 217 | 218 | @customElement('ember-outlet') 219 | export default class OutletElement extends EmberOutletElement { 220 | 221 | } 222 | ``` 223 | 224 | This will allow you to render an outlet like this: 225 | 226 | ```handlebars 227 | 228 | ``` 229 | 230 | By default, the `` will render the main outlet for the `application` route. This can be useful for rendering an already initialized Ember app within other contexts. 231 | 232 | To render another route, you must specify it using the `route=` attribute: 233 | 234 | ```handlebars 235 | 236 | ``` 237 | 238 | If your route specifies named routes, you can also specify route names: 239 | 240 | ```handlebars 241 | 242 | 243 | ``` 244 | 245 | Since an `` can be used outside of an Ember route, the route attribute is required except if you want to render the application route. You cannot just provide the `name=` attribute and expect it to work. 246 | 247 | In the unusual circumstance where you would be loading two or more Ember apps that use the `ember-outlet` element on the same page, you can extend your own custom element off the `ember-outlet` in order to resolve the naming conflict between the two apps. 248 | 249 | 250 | 251 | ### Applications 252 | 253 | You can use the same `@customElement` decorator on your Ember application. This will allow an entire Ember app to be instantiated and rendered within a custom element as soon as that element is connected to a DOM. 254 | 255 | Presumably, you will only want your Ember app to be instantiated by your custom element, so you should define `autoboot = false;` in when defining your app class, like so: 256 | 257 | ```javascript 258 | /* app/app.js */ 259 | 260 | import Application from '@ember/application'; 261 | import Resolver from 'ember-resolver'; 262 | import loadInitializers from 'ember-load-initializers'; 263 | import config from './config/environment'; 264 | import { customElement } from 'ember-custom-elements'; 265 | 266 | @customElement('ember-app') 267 | export default class App extends Application { 268 | modulePrefix = config.modulePrefix; 269 | podModulePrefix = config.podModulePrefix; 270 | Resolver = Resolver; 271 | autoboot = false; 272 | // 👆 this part is important 273 | } 274 | 275 | loadInitializers(App, config.modulePrefix); 276 | ``` 277 | 278 | Once your app has been created, every creation of a custom element for it will only create new application instances, meaning that your instance-initializers will run again but your initializers won't perform again. Custom elements for your app are tied directly to your existing app. 279 | 280 | 281 | 282 | ### Native Custom Elements 283 | 284 | The `customElement` decorator can also be used on native custom elements (i.e. extensions of `HTMLElement`). 285 | 286 | ```javascript 287 | /* app/custom-elements/my-element.js */ 288 | import { customElement } from 'ember-custom-elements'; 289 | 290 | @customElement('my-element') 291 | export default class MyElement extends HTMLElement { 292 | 293 | } 294 | ``` 295 | 296 | There's a few minor things that this add-on does for you when it comes to using plain custom elements: 297 | 298 | - If you need to access the application from a descendent class of `HTMLElement`, you can use `Ember.getOwner` anywhere in your custom element code. 299 | - The `connectedCallback` will only be called after Glimmer has had a chance to render the block of content passed to your custom element. This has to happen because Glimmer inserts elements individually, so even though your custom element may have been connected to the DOM, its prospective children probably haven't been inserted yet. 300 | - Service injection is possible like with any other Ember class using the `@inject` decorator from `@ember/service`. (In pre-Octane Ember, you of course need a [polyfill](https://github.com/ember-polyfills/ember-decorators-polyfill) for ES decorators) 301 | 302 | It's important that your custom elements are located in a folder named `app/custom-elements` so that they can be properly registered with your application. This add-on will NOT infer the tagName of the elements from their respective file names; you must always use the `@customElement` decorator. 303 | 304 | 305 | 306 | ### Options 307 | 308 | At present, there are a few options you can pass when creating custom elements: 309 | 310 | - **extends**: A string representing the name of a native element your custom element should extend from. This is the same thing as the `extends` option passed to [window.customElements.define()](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements#High-level_view). 311 | - **useShadowRoot**: By default, application content rendered in your custom elements will be placed directly into the main DOM. If you set this option to `true`, a shadow root will be used. 312 | - **observedAttributes**: A whitelist of which element attributes to observe. This sets the native `observedAttributes` static property on [custom elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements). It's suggested that you only use this option if you know what you are doing, as once the `observedAttributes` are set on a defined custom element, it cannot be changed after the fact(remember that custom elements can be only defined once). The most common reason to define `observedAttributes` would be for performance reasons, as making calls to JavaScript every time any attribute changes is more expensive than if only some attribute changes should call JavaScript. All that said, you probably don't need this, as ember-custom-elements observes all attribute changes by default. Does nothing for custom elements that instantiate Ember apps. 313 | - **customElementClass**: In the extreme edge case that you need to redefine the behavior of the custom element class itself, you can `import { EmberCustomElement } from 'ember-custom-elements';`, extend it into a subclass, and pass that subclass to the `customElementClass` option. This is definitely an expert tool and, even if you think you need this, you probably don't need it. This is made available only for the desperate. The `EmberCustomElement` class should be considered a private entity. 314 | - **camelizeArgs**: Element attributes must be kabob-case, but if `camelizeArgs` is set to true, these attributes will be exposed to your components in camelCase. 315 | - **outletName**: (routes only) The name of an outlet you wish to render for a route. Defaults to 'main'. The section on [named outlets][#named-outlets] goes into further detail. 316 | - **preserveOutletContent**: (routes only) When set to `true`, this prevents the DOM content inside the element from being cleared when transition away from the route is performed. This is `false` by default, but you may want to set this to `true` in the case where you need to keep the DOM content around for animation purposes. 317 | 318 | 319 | 320 | 321 | #### Options Example 322 | 323 | ```javascript 324 | @customElement('my-component', { extends: 'p', useShadowRoot: true }) 325 | export default MyComponent extends Component { 326 | 327 | } 328 | ``` 329 | 330 | 331 | 332 | 333 | #### Global Default Options 334 | 335 | In the case where you want to apply an option to all uses of the `customElement` decorator, you can set the option as a global default in the `config/environment.js` of your Ember project. 336 | 337 | For example, if you want `preserveOutletContent` to be applied to all route elements, you can add this option to `ENV.emberCustomElements.defaultOptions`: 338 | 339 | ```javascript 340 | module.exports = function(environment) { 341 | ... 342 | emberCustomElements: { 343 | defaultOptions: { 344 | preserveOutletContent: true 345 | } 346 | }, 347 | ... 348 | } 349 | ``` 350 | 351 | 352 | 353 | ### Accessing a Custom Element 354 | 355 | The custom element node that's invoking a component can be accessed using the `getCustomElement` function. 356 | 357 | Simply pass the context of a component; if the component was invoked with a custom element, the node will be returned: 358 | 359 | ```javascript 360 | import Component from '@glimmer/component'; 361 | import { customElement, getCustomElement } from 'ember-custom-elements'; 362 | 363 | @customElement('foo-bar') 364 | export default class FooBar extends Component { 365 | constructor() { 366 | super(...arguments); 367 | const element = getCustomElement(this); 368 | // Do something with your element 369 | this.foo = element.getAttribute('foo'); 370 | } 371 | } 372 | ``` 373 | 374 | ### Forwarding Component Properties 375 | 376 | HTML attributes can only be strings which, while they work well enough for many purposes, can be limiting. 377 | 378 | If you need to share state between your component and the outside world, you can create an interface to your custom element using the `forwarded` decorator. Properties and methods upon which the decorator is used will become accessible on the custom element node. If an outside force sets one of these properties on a custom element, the value will be set on the component. Likewise, a forwarded method that's called on a custom element will be called with the context of the component. 379 | 380 | ```javascript 381 | import Component from '@glimmer/component'; 382 | import { customElement, forwarded } from 'ember-custom-elements'; 383 | 384 | @customElement('foo-bar') 385 | export default class FooBar extends Component { 386 | @forwarded 387 | bar = 'foobar'; 388 | 389 | @forwarded 390 | fooBar() { 391 | return this.bar.toUpperCase(); 392 | } 393 | } 394 | ``` 395 | 396 | When rendered, you can do this: 397 | 398 | ```javascript 399 | const element = document.querySelector('foo-bar'); 400 | element.bar; // 'foobar' 401 | element.fooBar(); // 'FOOBAR" 402 | ``` 403 | 404 | If you are using `tracked` from `@glimmer/tracking`, you can use it in tandem with the `forwarded` decorator on properties. 405 | 406 | 407 | ## Notes 408 | 409 | 410 | 411 | ### Elements 412 | 413 | Once a custom element is defined using `window.customElements.define`, it cannot be redefined. 414 | 415 | This add-on works around that issue by reusing the same custom element class and changing the configuration associated with it. It's necessary in order for application and integration tests to work without encountering errors. This behavior will only be applied to custom elements defined using this add-on. If you try to define an application component on a custom element defined outside of this add-on, an error will be thrown. 416 | 417 | 418 | 419 | ### Runloop 420 | 421 | Because element attributes must be observed, the argument updates to your components occur asynchronously. Thus, if you are changing your custom element attributes dynamically, your tests will need to use `await settled()`. 422 | 423 | 424 | ## Contributing 425 | 426 | See the [Contributing](CONTRIBUTING.md) guide for details. 427 | 428 | 429 | ## License 430 | 431 | This project is licensed under the [MIT License](LICENSE.md). 432 | -------------------------------------------------------------------------------- /addon/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/addon/.gitkeep -------------------------------------------------------------------------------- /addon/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-unused-vars 2 | import EmberCustomElement, { 3 | CURRENT_CUSTOM_ELEMENT, 4 | CUSTOM_ELEMENT_OPTIONS, 5 | INITIALIZERS, 6 | initialize, 7 | } from './lib/custom-element'; 8 | import { 9 | getCustomElements, 10 | addCustomElement, 11 | getTargetClass, 12 | isSupportedClass, 13 | isNativeElement, 14 | } from './lib/common'; 15 | import { setOwner } from '@ember/application'; 16 | import { scheduleOnce } from '@ember/runloop'; 17 | 18 | export { default as EmberOutletElement } from './lib/outlet-element'; 19 | export { default as EmberCustomElement } from './lib/custom-element'; 20 | 21 | export const CUSTOM_ELEMENTS = new WeakMap(); 22 | export const INTERFACED_PROPERTY_DESCRIPTORS = new WeakMap(); 23 | 24 | const RESERVED_PROPERTIES = ['init']; 25 | const ELEMENT_META = Symbol('ELEMENT_META'); 26 | 27 | /** 28 | * A decorator that allows an Ember or Glimmer component to be instantiated 29 | * with a custom element. This means you can define an element tag that 30 | * your component will be automatically rendered in outside of a template. 31 | * 32 | * @param {String} tagName - The tag name that will instantiate your component. Must contain a hyphen. See: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define 33 | * @param {Object} customElementOptions - Options that will be used for constructing a custom element. 34 | * @param {String} customElementOptions.extends - A built-in element that your custom element will extend from. This will be passed to `customElements.define`: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define#Parameters 35 | * @param {Boolean=true} customElementOptions.useShadowRoot - Toggles whether a shadow root will be used to contain the body of your component when it is rendered in the custom element. 36 | * @param {Array} customElementOptions.observedAttributes - An array of attribute names specifying which custom element attributes should be observed. Observed attributes will update their value to the Ember/Glimmer component when said value changes. 37 | * @param {Boolean=false} customElementOptions.camelizeArgs - Element attributes must be kabob-case, but if `camelizeArgs` is set to true, these attributes will be exposed to your components in camelCase. 38 | * @param {String="main"} customElementOptions.outletName - The name of the outlet to render. This option only applies to Ember.Route. 39 | * @param {Boolean="true"} customElementOptions.clearsOutletAfterTransition - When set to `false`, this prevents the DOM content inside the element from being cleared when transition away from the route is performed. This is `true` by default, but you may want to set this to `false` in the case where you need to keep the DOM content around for animation purposes. 40 | * 41 | * Basic usage: 42 | * @example 43 | * import { customElement } from 'ember-web-component'; 44 | * 45 | * @customElement('my-component') 46 | * class MyComponent extends Component { 47 | * } 48 | * 49 | * With options: 50 | * @example 51 | * @customElement('my-component', { extends: 'p', useShadowRoot: false }) 52 | * class MyComponent extends Component { 53 | * } 54 | * 55 | * In your HTML: 56 | * @example 57 | * 58 | * 59 | * By default, attributes set on the custom element instance will not be 60 | * observed, so any changes made to them will not automatically be passed 61 | * on to your component. If you expect attributes on your custom element 62 | * to change, you should set a static property on your component class 63 | * called `observedAttributes` which is a list of attributes that will 64 | * be observed and have changes passed down to their respective component. 65 | * 66 | * With observed attributes: 67 | * 68 | * @example 69 | * @customElement('my-component') 70 | * class MyComponent extends Component { 71 | * 72 | * } 73 | */ 74 | export function customElement() { 75 | const { 76 | targetClass, 77 | tagName, 78 | customElementOptions 79 | } = customElementArgs(...arguments); 80 | 81 | const decorate = function (targetClass) { 82 | // In case of FastBoot. 83 | if(!window || !window.customElements) return; 84 | 85 | let element; 86 | 87 | if (!isSupportedClass(targetClass)) 88 | throw new Error(`The target object for custom element \`${tagName}\` is not an Ember component, route or application.`); 89 | 90 | let decoratedClass = targetClass; 91 | 92 | // This uses a string because that seems to be the one 93 | // way to preserve the name of the original class. 94 | decoratedClass = (new Function( 95 | 'targetClass', 'construct', 96 | ` 97 | return class ${targetClass.name} extends targetClass { 98 | constructor() { 99 | super(...arguments); 100 | construct.call(this); 101 | } 102 | } 103 | `))(targetClass, constructInstanceForCustomElement); 104 | 105 | if (isNativeElement(decoratedClass)) { 106 | // This implements a similar fix to the one for the connectedCallback 107 | // in `addon/lib/custom-element.js`, which is to wait for the body 108 | // content to render (Glimmer inserts child elements individually after 109 | // the parent element has been inserted) before calling the 110 | // connectedCallback. This quirk may break 3rd-party custom elements that 111 | // expect something to be in the body during the connectedCallback. 112 | const connectedCallback = decoratedClass.prototype.connectedCallback; 113 | if (connectedCallback) { 114 | Object.defineProperty(decoratedClass.prototype, 'connectedCallback', { 115 | configurable: true, 116 | value() { 117 | new Promise(resolve => scheduleOnce('afterRender', this, resolve)).then(() => { 118 | return connectedCallback.call(this, ...arguments); 119 | }); 120 | } 121 | }); 122 | } 123 | } 124 | 125 | try { 126 | // Create a custom HTMLElement for our component. 127 | const customElementClass = isNativeElement(decoratedClass) ? decoratedClass : customElementOptions.customElementClass || EmberCustomElement; 128 | class Component extends customElementClass {} 129 | if (customElementOptions.observedAttributes) { 130 | Component.observedAttributes = [ 131 | ...(Component.observedAttributes || []), 132 | ...Array.from(customElementOptions.observedAttributes) 133 | ].filter(Boolean); 134 | } 135 | window.customElements.define(tagName, Component, { extends: customElementOptions.extends }); 136 | element = Component; 137 | } catch(err) { 138 | element = window.customElements.get(tagName); 139 | if (err.name !== 'NotSupportedError' || !element) throw err; 140 | if (!getTargetClass(element)) throw new Error(`A custom element called \`${tagName}\` is already defined by something else.`); 141 | } 142 | 143 | // Overwrite the original config on the element 144 | CUSTOM_ELEMENT_OPTIONS.set(element, customElementOptions); 145 | 146 | // If the element class is being re-used, we should clear 147 | // the initializer for it so that we don't accidentally 148 | // get a destroyed owner. 149 | INITIALIZERS.delete(element); 150 | 151 | addCustomElement(decoratedClass, element); 152 | 153 | return decoratedClass; 154 | }; 155 | 156 | if (targetClass) { 157 | return decorate(targetClass); 158 | } else { 159 | return decorate; 160 | } 161 | } 162 | 163 | /** 164 | * Gets the custom element node for a component or application instance. 165 | * 166 | * @param {*} entity 167 | * @returns {HTMLElement|null} 168 | */ 169 | export function getCustomElement(entity) { 170 | const relatedCustomElement = CUSTOM_ELEMENTS.get(entity); 171 | if (relatedCustomElement) return relatedCustomElement; 172 | const currentCustomElement = CURRENT_CUSTOM_ELEMENT.element; 173 | if (!currentCustomElement) return null; 174 | const customElementClass = currentCustomElement.constructor; 175 | if (getCustomElements(entity.constructor).includes(customElementClass)) { 176 | CUSTOM_ELEMENTS.set(entity, currentCustomElement); 177 | CURRENT_CUSTOM_ELEMENT.element = null; 178 | return currentCustomElement; 179 | } 180 | return null; 181 | } 182 | 183 | /** 184 | * Sets up a property or method to be interfaced via a custom element. 185 | * When used, said property will be accessible on a custom element node 186 | * and will retain the same binding. 187 | * 188 | * @param {*} target 189 | * @param {String} name 190 | * @param {Object} descriptor 191 | */ 192 | export function forwarded(target, name, descriptor) { 193 | if (typeof target !== 'object') 194 | throw new Error(`You are using the '@forwarded' decorator on a class or function. It can only be used in a class body when definiing instance properties.`); 195 | 196 | const targetClass = target.constructor; 197 | 198 | const desc = { ...descriptor }; 199 | 200 | if (RESERVED_PROPERTIES.includes(name)) 201 | throw new Error(`The property name '${name}' is reserved and cannot be an interface for a custom element.`); 202 | 203 | const descriptors = INTERFACED_PROPERTY_DESCRIPTORS.get(targetClass) || []; 204 | descriptors.push({ name, desc }); 205 | INTERFACED_PROPERTY_DESCRIPTORS.set(targetClass, descriptors); 206 | 207 | return desc; 208 | } 209 | 210 | /** 211 | * Once an application instance has been booted, the custom element 212 | * for a component needs to be made aware of said instance as well 213 | * as know what name its component is registered under. This will 214 | * do that, and is used within the instance initializer. For 215 | * components not registered with the application until after boot, 216 | * you will need to use this function to make custom elements work 217 | * for components. Most likely, you won't need this. It's mainly 218 | * used for testing purposes within this add-on. 219 | * 220 | * @function setupCustomElementFor 221 | * @param {Ember.ApplicationInstance} instance 222 | * @param {String} registrationName 223 | */ 224 | export function setupCustomElementFor(instance, registrationName) { 225 | const parsedName = instance.__registry__.fallback.resolver.parseName(registrationName); 226 | const componentClass = instance.resolveRegistration(registrationName); 227 | const customElements = getCustomElements(componentClass); 228 | for (const customElement of customElements) { 229 | const initialize = function() { 230 | setOwner(this, instance); 231 | setMeta(this, { parsedName }); 232 | }; 233 | INITIALIZERS.set(customElement, initialize); 234 | } 235 | } 236 | 237 | function customElementArgs() { 238 | if (typeof arguments[0] === 'function' && typeof arguments[1] === 'string') { 239 | return { 240 | targetClass: arguments[0], 241 | tagName: arguments[1], 242 | customElementOptions: arguments[2] || {} 243 | } 244 | } else if (typeof arguments[0] === 'string') { 245 | return { 246 | targetClass: null, 247 | tagName: arguments[0], 248 | customElementOptions: arguments[1] || {} 249 | } 250 | } else { 251 | throw new Error('customElement should be passed a tagName string but found none.'); 252 | } 253 | } 254 | 255 | function constructInstanceForCustomElement() { 256 | if (isNativeElement(this.constructor)) { 257 | initialize(this); 258 | return; 259 | } 260 | const customElement = CURRENT_CUSTOM_ELEMENT.element; 261 | // There should always be a custom element when the component is 262 | // invoked by one, but if a decorated class isn't invoked by a custom 263 | // element, it shouldn't fail when being constructed. 264 | if (!customElement) return; 265 | CUSTOM_ELEMENTS.set(this, customElement); 266 | CURRENT_CUSTOM_ELEMENT.element = null; 267 | // Build a prototype chain by finding all ancestors 268 | // and sorting them from eldest to youngest 269 | let ancestor = this.constructor; 270 | const self = this; 271 | const ancestors = []; 272 | while (ancestor) { 273 | ancestors.unshift(ancestor); 274 | ancestor = Object.getPrototypeOf(ancestor); 275 | } 276 | // Go through our list of known property descriptors 277 | // for the instance and forward them to the element. 278 | for (const ancestor of ancestors) { 279 | const descriptors = INTERFACED_PROPERTY_DESCRIPTORS.get(ancestor) || []; 280 | for (const { name, desc } of descriptors) { 281 | if (typeof desc.value === 'function') { 282 | customElement[name] = self[name].bind(this); 283 | continue; 284 | } 285 | Object.defineProperty(customElement, name, { 286 | get() { 287 | return self[name]; 288 | }, 289 | set(value) { 290 | self[name] = value; 291 | } 292 | }); 293 | } 294 | } 295 | } 296 | 297 | export function setMeta(element, meta) { 298 | element[ELEMENT_META] = meta; 299 | } 300 | 301 | export function getMeta(element) { 302 | return element[ELEMENT_META]; 303 | } 304 | -------------------------------------------------------------------------------- /addon/instance-initializers/ember-custom-elements.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-classic-classes */ 2 | /* eslint-disable ember/no-classic-components */ 3 | import Component from '@ember/component'; 4 | import { getCustomElements } from '../lib/common'; 5 | import { warn } from '@ember/debug'; 6 | import { defer } from 'rsvp'; 7 | import { setupCustomElementFor } from '../index'; 8 | 9 | let INITIALIZATION_DEFERRED = defer(); 10 | 11 | export function getInitializationPromise() { 12 | return INITIALIZATION_DEFERRED.promise; 13 | } 14 | 15 | /** 16 | * Primarily looks up components that use the `@customElement` decorator 17 | * and evaluates them, allowing their custom elements to be defined. 18 | * 19 | * This does not touch custom elements defined for an Ember.Application. 20 | * 21 | * @param {Ember.ApplicationInstance} instance 22 | */ 23 | export function initialize(instance) { 24 | INITIALIZATION_DEFERRED = defer(); 25 | 26 | // Get a list of all registered components, find the ones that use the customElement 27 | // decorator, and set the app instance and component name on them. 28 | for (const type of ['application', 'component', 'route', 'custom-element']) { 29 | const entityNames = instance.__registry__.fallback.resolver.knownForType(type); 30 | for (const entityName in entityNames) { 31 | const parsedName = instance.__registry__.fallback.resolver.parseName(entityName); 32 | const _moduleName = instance.__registry__.fallback.resolver.findModuleName(parsedName); 33 | const _module = instance.__registry__.fallback.resolver._moduleRegistry._entries[_moduleName]; 34 | // Only evaluate the component module if it is using our decorator. 35 | // This optimization is ignored in testing so that components can be 36 | // dynamically created and registered. 37 | const shouldEvalModule = determineIfShouldEvalModule(instance, _module); 38 | if (!shouldEvalModule) continue; 39 | const componentClass = instance.resolveRegistration(entityName); 40 | const customElements = getCustomElements(componentClass); 41 | const hasCustomElements = customElements.length; 42 | warn( 43 | `ember-custom-elements: Custom element expected for \`${entityName}\` but none found.`, 44 | hasCustomElements, 45 | { id: 'no-custom-elements' } 46 | ); 47 | if (!hasCustomElements) continue; 48 | setupCustomElementFor(instance, entityName); 49 | } 50 | } 51 | 52 | // Notify custom elements that Ember initialization is complete 53 | INITIALIZATION_DEFERRED.resolve(); 54 | 55 | // Register a view that can be used to contain state for web component contents 56 | instance.register('component:-ember-web-component-view', Component.extend({ tagName: '' })); 57 | } 58 | 59 | export default { 60 | initialize 61 | }; 62 | 63 | const DECORATOR_REGEX = /customElement\s*\){0,1}\s*\(/; 64 | 65 | function determineIfShouldEvalModule(instance, _module) { 66 | const { 67 | emberCustomElements = {} 68 | } = instance.resolveRegistration('config:environment'); 69 | if (emberCustomElements.deoptimizeModuleEval) return true; 70 | function _moduleShouldEval(_module) { 71 | for (const moduleName of _module.deps) { 72 | // Check if ember-custom-elements is a dependency of the module 73 | if (moduleName === 'ember-custom-elements') { 74 | const code = (_module.callback || function() {}).toString(); 75 | // Test if a function named "customElement" is called within the module 76 | if (DECORATOR_REGEX.test(code)) return true; 77 | } 78 | const dep = instance.__registry__.fallback.resolver._moduleRegistry._entries[moduleName]; 79 | if (dep && _moduleShouldEval(dep)) return true; 80 | } 81 | return false; 82 | } 83 | return _moduleShouldEval(_module); 84 | } 85 | -------------------------------------------------------------------------------- /addon/lib/block-content.js: -------------------------------------------------------------------------------- 1 | import { scheduleOnce } from '@ember/runloop'; 2 | import { backburner } from './ember-compat'; 3 | 4 | /** 5 | * Tracks changes to block content after it's been captured 6 | * and passed to the template. The purpose of this is to 7 | * cache the block content after each render, detect whether 8 | * the block content has been removed from the rendered template, 9 | * in which case the cached content will be placed into a 10 | * DocumentFragment so that dynamic content from components 11 | * in that context will continue to work and render properly. 12 | * If the component rendered in the element template tries to 13 | * render the block content again, it will use the DocumentFragment 14 | * and the process will repeat. 15 | * 16 | * TL;DR this allows us to safely invoke Ember components 17 | * from within the block of a custom element. 18 | * 19 | * @class BlockContent 20 | * @private 21 | */ 22 | export default class BlockContent { 23 | constructor() { 24 | this.startBoundary = document.createComment(' start '); 25 | this.endBoundary = document.createComment(' end '); 26 | this.fragment = document.createDocumentFragment(); 27 | this.fragment.append(this.startBoundary); 28 | this.fragment.append(this.endBoundary); 29 | const cache = []; 30 | backburner.on('begin', () => { 31 | // eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions 32 | scheduleOnce('actions', this, () => { 33 | if (this.startBoundary.isConnected) { 34 | cache.length = 0; 35 | let currentNode = this.startBoundary; 36 | while (currentNode) { 37 | if (!currentNode) return; 38 | cache.push(currentNode); 39 | if (currentNode === this.endBoundary) break; 40 | currentNode = currentNode.nextSibling; 41 | } 42 | } else { 43 | // eslint-disable-next-line ember/no-incorrect-calls-with-inline-anonymous-functions 44 | scheduleOnce('afterRender', this, () => { 45 | for (const node of cache) { 46 | this.fragment.append(node); 47 | } 48 | cache.length = 0; 49 | }); 50 | } 51 | }); 52 | }); 53 | } 54 | 55 | from(nodes) { 56 | for (const node of Array.from(nodes)) this.append(node); 57 | } 58 | 59 | insertBefore(child) { 60 | this.endBoundary.parentNode.insertBefore(child, this.endBoundary); 61 | } 62 | 63 | removeChild(child) { 64 | let currentNode = this.startBoundary; 65 | while (currentNode) { 66 | if (!currentNode || currentNode === this.endBoundary) return; 67 | if ( 68 | currentNode !== this.startBoundary && 69 | currentNode !== this.endBoundary && 70 | currentNode === child 71 | ) { 72 | child.remove(); 73 | return 74 | } 75 | currentNode = currentNode.nextSibling; 76 | } 77 | } 78 | 79 | append(node) { 80 | this.insertBefore(node); 81 | } 82 | 83 | appendTo(node) { 84 | node.append(this.fragment); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /addon/lib/common.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-classic-components */ 2 | import Application from '@ember/application'; 3 | import Route from '@ember/routing/route'; 4 | import EmberComponent from '@ember/component'; 5 | import { isGlimmerComponent } from './glimmer-compat'; 6 | import { TARGET_AVAILABLE } from './custom-element'; 7 | 8 | const EMBER_WEB_COMPONENTS_CUSTOM_ELEMENTS = Symbol('EMBER_WEB_COMPONENTS_CUSTOM_ELEMENTS'); 9 | const EMBER_WEB_COMPONENTS_TARGET_CLASS = Symbol('EMBER_WEB_COMPONENTS_TARGET_CLASS'); 10 | 11 | /** 12 | * Sets a custom element class on a component class, and vice versa. 13 | * 14 | * @param {Ember.Application|Ember.Component|Glimmer.Component} targetClass 15 | * @param {Class} customElement 16 | * @private 17 | */ 18 | export function addCustomElement(targetClass, customElement) { 19 | targetClass[EMBER_WEB_COMPONENTS_CUSTOM_ELEMENTS] = targetClass[EMBER_WEB_COMPONENTS_CUSTOM_ELEMENTS] || new Set(); 20 | targetClass[EMBER_WEB_COMPONENTS_CUSTOM_ELEMENTS].add(customElement); 21 | customElement[EMBER_WEB_COMPONENTS_TARGET_CLASS] = targetClass; 22 | const deference = customElement[TARGET_AVAILABLE]; 23 | if (deference) deference.resolve(); 24 | } 25 | 26 | /** 27 | * Returns a custom element assigned to a component class or instance, if there is one. 28 | * 29 | * @param {Ember.Application|Ember.Component|Glimmer.Component} 30 | * @private 31 | */ 32 | export function getCustomElements(targetClass) { 33 | const customElements = targetClass[EMBER_WEB_COMPONENTS_CUSTOM_ELEMENTS] || targetClass.constructor && targetClass.constructor[EMBER_WEB_COMPONENTS_CUSTOM_ELEMENTS] || []; 34 | return Array.from(customElements); 35 | } 36 | 37 | /** 38 | * Returns a target class associated with an element class or instance. 39 | * 40 | * @param {*} getTargetClass 41 | * @private 42 | */ 43 | export function getTargetClass(customElement) { 44 | if (!customElement) return; 45 | return ( 46 | customElement[EMBER_WEB_COMPONENTS_TARGET_CLASS] || 47 | (customElement.constructor && customElement.constructor[EMBER_WEB_COMPONENTS_TARGET_CLASS]) 48 | ); 49 | } 50 | 51 | /** 52 | * Indicates whether a class can be turned into a custom element. 53 | * @param {Class} targetClass 54 | * @returns {Boolean} 55 | */ 56 | export function isSupportedClass(targetClass) { 57 | return isApp(targetClass) || 58 | isRoute(targetClass) || 59 | isComponent(targetClass) || 60 | isGlimmerComponent(targetClass) || 61 | isNativeElement(targetClass); 62 | } 63 | 64 | /** 65 | * Indicates whether an object is an Ember.Application 66 | * 67 | * @param {Class} targetClass 68 | * @private 69 | * @returns {Boolean} 70 | */ 71 | export function isApp(targetClass) { 72 | return isAncestorOf(targetClass, Application); 73 | } 74 | 75 | /** 76 | * Indicates whether an object is an Ember.Route 77 | * 78 | * @param {Class} targetClass 79 | * @private 80 | * @returns {Boolean} 81 | */ 82 | export function isRoute(targetClass) { 83 | return isAncestorOf(targetClass, Route); 84 | } 85 | 86 | /** 87 | * Indicates whether an object is an Ember component 88 | * 89 | * @param {Class} targetClass 90 | * @private 91 | * @returns {Boolean} 92 | */ 93 | export function isComponent(targetClass) { 94 | return isAncestorOf(targetClass, EmberComponent); 95 | } 96 | 97 | /** 98 | * Indicates whether an object is an HTMLElement 99 | * 100 | * @param {Class} targetClass 101 | * @private 102 | * @returns {Boolean} 103 | */ 104 | export function isNativeElement(targetClass) { 105 | return isAncestorOf(targetClass, HTMLElement); 106 | } 107 | 108 | function isAncestorOf(a, b) { 109 | if (!a) return false; 110 | 111 | let ancestor = a; 112 | 113 | while (ancestor) { 114 | if (ancestor === b) return true; 115 | ancestor = Object.getPrototypeOf(ancestor); 116 | } 117 | 118 | return false; 119 | } 120 | -------------------------------------------------------------------------------- /addon/lib/custom-element.js: -------------------------------------------------------------------------------- 1 | import { notifyPropertyChange, set } from '@ember/object'; 2 | import { schedule, scheduleOnce } from '@ember/runloop'; 3 | import { getOwner, setOwner } from '@ember/application'; 4 | import { camelize } from '@ember/string'; 5 | import { getInitializationPromise } from '../instance-initializers/ember-custom-elements'; 6 | import { compileTemplate } from './template-compiler'; 7 | import OutletElement, { getPreserveOutletContent, OUTLET_VIEWS } from './outlet-element'; 8 | import BlockContent from './block-content'; 9 | import { getMeta, setMeta } from '../index'; 10 | import { getTargetClass, isApp } from './common'; 11 | import { defer } from 'rsvp'; 12 | import { destroy } from './ember-compat'; 13 | 14 | const APPS = new WeakMap(); 15 | const APP_INSTANCES = new WeakMap(); 16 | const COMPONENT_VIEWS = new WeakMap(); 17 | const ATTRIBUTES_OBSERVERS = new WeakMap(); 18 | const BLOCK_CONTENT = Symbol('BLOCK_CONTENT'); 19 | 20 | export const CURRENT_CUSTOM_ELEMENT = { element: null }; 21 | export const CUSTOM_ELEMENT_OPTIONS = new WeakMap(); 22 | export const INITIALIZERS = new WeakMap(); 23 | export const TARGET_AVAILABLE = Symbol('TARGET_AVAILABLE'); 24 | 25 | /** 26 | * The custom element that wraps an actual Ember component. 27 | * 28 | * @class EmberCustomElement 29 | * @extends HTMLElement 30 | */ 31 | export default class EmberCustomElement extends HTMLElement { 32 | static [TARGET_AVAILABLE] = defer(); 33 | 34 | /** 35 | * Private properties don't appear to be accessible in 36 | * functions that we bind to the instance, which is why 37 | * this uses a symbol instead. 38 | */ 39 | [BLOCK_CONTENT] = new BlockContent(); 40 | 41 | constructor() { 42 | super(...arguments); 43 | 44 | initialize(this); 45 | } 46 | 47 | /** 48 | * Sets up the component instance on element insertion and creates an 49 | * observer to update the component with attribute changes. 50 | * 51 | * Also calls `didReceiveAttrs` on the component because this otherwise 52 | * won't be called by virtue of the way we're instantiating the component 53 | * outside of a template. 54 | */ 55 | async connectedCallback() { 56 | // connectedCallback may be called once your element is no longer connected, use Node.isConnected to make sure. 57 | // https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements 58 | if (!this.isConnected) return; 59 | 60 | // If the related Ember app code has not been evaluated by 61 | // the browser yet, wait for the target class to be decorated 62 | // and associated with the custom element class before continuing. 63 | await this.constructor[TARGET_AVAILABLE].promise; 64 | 65 | let targetClass = getTargetClass(this); 66 | 67 | // Apps may have an owner they're registered to, but that is 68 | // not the expectation most of the time, so we have to 69 | // detect that and handle it differently. 70 | if (isApp(targetClass)) return connectApplication.call(this); 71 | 72 | await getInitializationPromise(); 73 | 74 | const { type } = getMeta(this).parsedName; 75 | if (type === 'component') return connectComponent.call(this); 76 | if (type === 'route') return connectRoute.call(this); 77 | } 78 | 79 | /** 80 | * Reflects element attribute changes to component properties. 81 | * 82 | * @param {String} attrName 83 | */ 84 | attributeChangedCallback(attrName) { 85 | if (!this._attributeObserverEnabled) return; 86 | this.changedAttributes.add(attrName); 87 | scheduleOnce('render', this, updateComponentArgs); 88 | } 89 | 90 | /** 91 | * Destroys the component upon element removal. 92 | */ 93 | async disconnectedCallback() { 94 | const app = APPS.get(this); 95 | if (app) await destroy(app); 96 | // ☝️ Calling that seems to cause a rendering error 97 | // in tests that is difficult to address. 98 | const instance = APP_INSTANCES.get(this); 99 | if (instance) await destroy(instance); 100 | const componentView = COMPONENT_VIEWS.get(this); 101 | if (componentView) await destroy(componentView); 102 | const attributesObserver = ATTRIBUTES_OBSERVERS.get(this); 103 | if (attributesObserver) attributesObserver.disconnect(); 104 | const outletView = OUTLET_VIEWS.get(this); 105 | if (outletView) await OutletElement.prototype.destroyOutlet.call(this); 106 | const { type } = getMeta(this).parsedName; 107 | if (type === 'route' && !getPreserveOutletContent(this)) this.innerHTML = ''; 108 | } 109 | 110 | removeChild() { 111 | const { type } = (getMeta(this).parsedName || {}); 112 | if (type === 'component' || type === 'custom-element') { 113 | this[BLOCK_CONTENT].removeChild(...arguments); 114 | } else { 115 | super.removeChild(...arguments); 116 | } 117 | } 118 | 119 | insertBefore() { 120 | const { type } = (getMeta(this).parsedName || {}); 121 | if (type === 'component' || type === 'custom-element') { 122 | this[BLOCK_CONTENT].insertBefore(...arguments); 123 | } else { 124 | super.insertBefore(...arguments); 125 | } 126 | } 127 | } 128 | 129 | /** 130 | * @private 131 | */ 132 | function updateComponentArgs() { 133 | const changes = Array.from(this.changedAttributes); 134 | if (changes.size < 1) return; 135 | set(this, '_attributeObserverEnabled', false); 136 | try { 137 | const view = COMPONENT_VIEWS.get(this); 138 | if (!view) return; 139 | const options = getOptions(this); 140 | const attrs = { ...view._attrs }; 141 | set(view, '_attrs', attrs); 142 | for (const attr of changes) { 143 | const attrName = options.camelizeArgs ? camelize(attr) : attr; 144 | attrs[attrName] = this.getAttribute(attr); 145 | notifyPropertyChange(view, `_attrs.${attrName}`); 146 | } 147 | } finally { 148 | set(this, '_attributeObserverEnabled', true); 149 | this.changedAttributes.clear(); 150 | } 151 | } 152 | 153 | /** 154 | * Sets up a component to be rendered in the element. 155 | * @private 156 | */ 157 | async function connectComponent() { 158 | // https://stackoverflow.com/questions/48498581/textcontent-empty-in-connectedcallback-of-a-custom-htmlelement 159 | await new Promise(resolve => schedule('afterRender', this, resolve)); 160 | Object.defineProperties(this, { 161 | changedAttributes: { 162 | value: new Set(), 163 | configurable: false, 164 | enumerable: false, 165 | writable: false 166 | } 167 | }); 168 | this._attributeObserverEnabled = true; 169 | // Capture block content and replace 170 | this[BLOCK_CONTENT].from(this.childNodes); 171 | const options = getOptions(this); 172 | const useShadowRoot = Boolean(options.useShadowRoot); 173 | if (useShadowRoot) this.attachShadow({mode: 'open'}); 174 | const target = this.shadowRoot ? this.shadowRoot : this; 175 | if (target === this) this.innerHTML = ''; 176 | // Setup attributes and attribute observer 177 | const attrs = {}; 178 | for (const attr of this.getAttributeNames()) { 179 | const attrName = options.camelizeArgs ? camelize(attr) : attr; 180 | attrs[attrName] = this.getAttribute(attr); 181 | } 182 | const observedAttributes = this.constructor.observedAttributes; 183 | if (observedAttributes) { 184 | // This allows any attributes that aren't initially present 185 | // to be tracked if they become present later and set to be observed. 186 | // eslint-disable-next-line no-prototype-builtins 187 | for (const attr of observedAttributes) if (!attrs.hasOwnProperty(attr)) { 188 | const attrName = options.camelizeArgs ? camelize(attr) : attr; 189 | attrs[attrName] = null; 190 | } 191 | } else if (observedAttributes !== false) { 192 | const attributesObserver = new MutationObserver(mutations => { 193 | for (const { type, attributeName } of mutations) { 194 | if (type !== 'attributes') continue; 195 | this.attributeChangedCallback(attributeName); 196 | } 197 | }); 198 | ATTRIBUTES_OBSERVERS.set(this, attributesObserver); 199 | attributesObserver.observe(this, { attributes: true }); 200 | } 201 | const owner = getOwner(this); 202 | const view = owner.factoryFor('component:-ember-web-component-view').create({ 203 | layout: compileTemplate(getMeta(this).parsedName.name, Object.keys(attrs)), 204 | _attrs: attrs, 205 | blockContent: null, 206 | }); 207 | COMPONENT_VIEWS.set(this, view); 208 | // This allows the component to consume the custom element node 209 | // in the constructor and anywhere else. It works because the 210 | // instantiation of the component is always synchronous, 211 | // constructors are always synchronous, and we have overridden 212 | // the constructor so that it stores the node and deletes this 213 | // property. 214 | CURRENT_CUSTOM_ELEMENT.element = this; 215 | // This bypasses a check that happens in view.appendTo 216 | // that prevents us from attaching the component 217 | const proxy = document.createDocumentFragment(); 218 | proxy.removeChild = child => child.remove(); 219 | proxy.insertBefore = (node, reference) => { 220 | const parent = (reference || {}).parentNode || proxy; 221 | DocumentFragment.prototype.insertBefore.apply(parent, [node, reference]); 222 | }; 223 | view.renderer.appendTo(view, proxy); 224 | target.append(proxy); 225 | set(view, 'blockContent', this[BLOCK_CONTENT].fragment); 226 | } 227 | 228 | /** 229 | * Sets up a route to be rendered in the element 230 | * @private 231 | */ 232 | async function connectRoute() { 233 | const options = getOptions(this); 234 | const useShadowRoot = options.useShadowRoot; 235 | if (useShadowRoot) this.attachShadow({ mode: 'open' }); 236 | CURRENT_CUSTOM_ELEMENT.element = this; 237 | OutletElement.prototype.connectedCallback.call(this); 238 | } 239 | /** 240 | * Sets up an application to be rendered in the element. 241 | * 242 | * Here, we are actually booting the app into a detached 243 | * element and then relying on `connectRoute` to render 244 | * the application route for the app instance. 245 | * 246 | * There are a few advantages to this. This allows the 247 | * rendered content to be less "deep", meaning that we 248 | * don't need two useless elements, which the app 249 | * instance is expecting, to be present in the DOM. The 250 | * second advantage is that this prevents problems 251 | * rendering apps within other apps in a way that doesn't 252 | * require the use of a shadowRoot. 253 | * 254 | * @private 255 | */ 256 | async function connectApplication() { 257 | const parentElement = document.createElement('div'); 258 | const rootElement = document.createElement('div'); 259 | parentElement.append(rootElement); 260 | CURRENT_CUSTOM_ELEMENT.element = this; 261 | const owner = getOwner(this); 262 | let app; 263 | // If the app is owned, use a factory to instantiate 264 | // the app instead of using the constructor directly. 265 | const config = { 266 | rootElement, 267 | autoboot: false, 268 | }; 269 | if (owner) { 270 | app = owner.factoryFor(getMeta(this).parsedName.fullName).create(config); 271 | } else { 272 | const App = getTargetClass(this); 273 | app = App.create(config); 274 | } 275 | APPS.set(this, app); 276 | await app.boot(); 277 | const instance = app.buildInstance(); 278 | APP_INSTANCES.set(this, instance); 279 | await instance.boot({ rootElement }); 280 | await instance.startRouting(); 281 | setOwner(this, instance); 282 | if (!owner) { 283 | await getInitializationPromise(); 284 | // The outlet-element methods expect the element 285 | // to have resolver meta data associated with it. 286 | const meta = instance.__registry__.fallback.resolver.parseName('application:main'); 287 | setMeta(this, meta); 288 | } 289 | connectRoute.call(this); 290 | } 291 | 292 | export function getOptions(element) { 293 | const customElementOptions = CUSTOM_ELEMENT_OPTIONS.get(element.constructor); 294 | const ENV = getOwner(element).resolveRegistration('config:environment') || {}; 295 | const { defaultOptions = {} } = ENV.emberCustomElements || {}; 296 | return Object.assign({}, defaultOptions, customElementOptions); 297 | } 298 | 299 | export function initialize(customElement) { 300 | const initializer = INITIALIZERS.get(customElement.constructor); 301 | if (initializer) initializer.call(customElement); 302 | } 303 | 304 | EmberCustomElement.prototype.updateOutletState = OutletElement.prototype.updateOutletState; 305 | EmberCustomElement.prototype.scheduleUpdateOutletState = OutletElement.prototype.scheduleUpdateOutletState; 306 | -------------------------------------------------------------------------------- /addon/lib/ember-compat.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-get */ 2 | /* eslint-disable ember/new-module-imports */ 3 | 4 | import { get } from '@ember/object'; 5 | 6 | const ogRequire = window.require; 7 | 8 | function _require() { 9 | try { 10 | return ogRequire(...arguments); 11 | } catch (_) { 12 | // no-op 13 | } 14 | } 15 | 16 | const Ember = (_require('ember') || {}).default || (new Function('window', 'return window.Ember;'))(window); 17 | 18 | export default Ember; 19 | 20 | function emberRequire() { 21 | try { 22 | return Ember.__loader.require(...arguments); 23 | } catch (_) { 24 | // no-op 25 | } 26 | } 27 | 28 | const destroyable = emberRequire('@ember/destroyable'); 29 | 30 | /** 31 | * Someday, Ember may no longer implement objects in a way 32 | * that adds a `destroy()` method to them by default. To 33 | * prepare for that, this first tries to use the newer 34 | * destroyable API, falling back to the old style `destroy()` 35 | * method on the object if the destroyable API is not 36 | * available. 37 | * 38 | * @param {Object} object 39 | * @returns 40 | */ 41 | export function destroy(object) { 42 | if (destroyable) { 43 | return destroyable.destroy(object); 44 | } else if (typeof object.destroy === 'function') { 45 | return object.destroy(); 46 | } 47 | } 48 | 49 | export function registerDestructor(object, callback) { 50 | if (destroyable) { 51 | return destroyable.registerDestructor(object, callback); 52 | } else { 53 | // Obviously this is nowhere near a 1-to-1 54 | // replica of registerDestructor, but it 55 | // satisfies the current needs of this add-on. 56 | const ogDestroy = object.destroy; 57 | if (typeof ogDestroy === 'function') { 58 | Object.defineProperty(object, 'destroy', { 59 | value() { 60 | callback(); 61 | ogDestroy.call(object, ...arguments); 62 | } 63 | }); 64 | } 65 | } 66 | } 67 | 68 | export const setComponentTemplate = 69 | emberRequire('@ember/runloop').setComponentTemplate || 70 | Ember._setComponentTemplate; 71 | 72 | const runloop = emberRequire('@ember/runloop'); 73 | 74 | export const backburner = 75 | runloop._backburner || 76 | runloop.backburner || 77 | get(Ember, 'run.backburner'); 78 | -------------------------------------------------------------------------------- /addon/lib/glimmer-compat.js: -------------------------------------------------------------------------------- 1 | /* global require */ 2 | 3 | // Import through `require` in case it is not a dependency 4 | const GlimmerComponentModule = require('@glimmer/component'); 5 | const GlimmerComponent = GlimmerComponentModule && GlimmerComponentModule.default; 6 | 7 | /** 8 | * Indicates whether a class is a Glimmer component. 9 | * 10 | * @param {Class} targetClass 11 | * @returns {Boolean} 12 | * @private 13 | */ 14 | export function isGlimmerComponent(targetClass) { 15 | if (!GlimmerComponent) { 16 | return false; 17 | } 18 | 19 | let ancestor = targetClass; 20 | 21 | while (ancestor) { 22 | if (ancestor === GlimmerComponent) { 23 | return true; 24 | } 25 | 26 | ancestor = Object.getPrototypeOf(ancestor); 27 | } 28 | 29 | return false; 30 | } 31 | 32 | -------------------------------------------------------------------------------- /addon/lib/outlet-element.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-private-routing-service */ 2 | import { getOwner } from '@ember/application'; 3 | import { scheduleOnce } from '@ember/runloop'; 4 | import { getOptions } from './custom-element'; 5 | import ROUTE_CONNECTIONS from './route-connections'; 6 | import { getMeta } from '../index'; 7 | import { destroy } from './ember-compat'; 8 | 9 | export const OUTLET_VIEWS = new WeakMap(); 10 | 11 | /** 12 | * A custom element that can render an outlet from an Ember app. 13 | * 14 | * @argument {String} route - The dot-delimited name of a route. 15 | * @argument {String='main'} name - The name of an outlet. 16 | * @argument {String='true'} preserveContent - Prevents outlet contents from being cleared when transitioning out of the route or when the element is disconnected. 17 | */ 18 | export default class EmberWebOutlet extends HTMLElement { 19 | constructor() { 20 | super(...arguments); 21 | this.initialize(); 22 | } 23 | 24 | /** 25 | * @override 26 | */ 27 | initialize() {} 28 | 29 | connectedCallback() { 30 | const target = this.shadowRoot || this; 31 | const owner = getOwner(this); 32 | const router = owner.lookup('router:main'); 33 | const OutletView = owner.factoryFor('view:-outlet'); 34 | const view = OutletView.create(); 35 | view.appendTo(target); 36 | OUTLET_VIEWS.set(this, view); 37 | this.scheduleUpdateOutletState = this.scheduleUpdateOutletState.bind(this); 38 | router.on('routeWillChange', this.scheduleUpdateOutletState); 39 | router.on('routeDidChange', this.scheduleUpdateOutletState); 40 | this.updateOutletState(); 41 | } 42 | 43 | scheduleUpdateOutletState(transition) { 44 | if (transition.to.name !== getRouteName(this) && getPreserveOutletContent(this)) return; 45 | scheduleOnce('render', this, 'updateOutletState') 46 | } 47 | 48 | /** 49 | * Looks up the outlet on the top-level view and updates the state of our outlet view. 50 | */ 51 | updateOutletState() { 52 | if (!this.isConnected) return; 53 | const router = getOwner(this).lookup('router:main'); 54 | if (!router._toplevelView) return; 55 | let routeName = getRouteName(this); 56 | const loadingName = `${routeName}_loading`; 57 | const errorName = `${routeName}_error`; 58 | if (router.isActive(loadingName)) { 59 | routeName = loadingName; 60 | } else if (router.isActive(errorName)) { 61 | routeName = errorName; 62 | } 63 | const stateObj = (() => { 64 | if (typeof router._toplevelView.ref.compute === 'function') { 65 | return router._toplevelView.ref.compute(); 66 | } else { 67 | return router._toplevelView.ref.outletState 68 | } 69 | })(); 70 | const outletState = lookupOutlet(stateObj, routeName, getOutletName(this)) || {}; 71 | const view = OUTLET_VIEWS.get(this); 72 | view.setOutletState(outletState); 73 | } 74 | 75 | disconnectedCallback() { 76 | const owner = getOwner(this); 77 | const router = owner.lookup('router:main'); 78 | router.off('routeWillChange', this.scheduleUpdateOutletState); 79 | router.off('routeDidChange', this.scheduleUpdateOutletState); 80 | this.destroyOutlet(); 81 | } 82 | 83 | async destroyOutlet() { 84 | const view = OUTLET_VIEWS.get(this); 85 | if (view) await destroy(view); 86 | const target = this.shadowRoot || this; 87 | if (this.preserveOutletContent !== 'true') target.innerHTML = ''; 88 | } 89 | } 90 | 91 | /** 92 | * Given an outlet state, returns a descendent outlet state that matches 93 | * the route name and the outlet name provided. 94 | * 95 | * @param {Object} outletState 96 | * @param {String} routeName 97 | * @param {String=} outletName 98 | */ 99 | function lookupOutlet(outletState, routeName, outletName) { 100 | const route = outletState.render.owner.lookup(`route:${routeName}`); 101 | if (!route) return Object.create(null); 102 | const routeConnections = (() => { 103 | if (route.connections) return route.connections; 104 | if (ROUTE_CONNECTIONS && ROUTE_CONNECTIONS.get) return ROUTE_CONNECTIONS.get(route); 105 | })(); 106 | if (!routeConnections) return null; 107 | const outletRender = routeConnections.find(outletState => outletState.outlet === outletName); 108 | function _lookupOutlet(outletState) { 109 | if (outletState.render === outletRender) return outletState; 110 | const outlets = Object.values(outletState.outlets); 111 | for (const outlet of outlets) { 112 | const foundOutlet = _lookupOutlet(outlet); 113 | if (foundOutlet) return foundOutlet; 114 | } 115 | return Object.create(null); 116 | } 117 | return _lookupOutlet(outletState); 118 | } 119 | 120 | /** 121 | * If the referenced class is a route, returns the name of the route. 122 | * 123 | * @private 124 | * @param {HTMLElement|EmberCustomElement} element 125 | * @returns {String|null} 126 | */ 127 | function getRouteName(element) { 128 | const { parsedName } = getMeta(element); 129 | const { type, fullNameWithoutType } = parsedName; 130 | if (type === 'route') { 131 | return fullNameWithoutType.replace('/', '.'); 132 | } else if (type === 'application') { 133 | return 'application'; 134 | } 135 | const attr = element.getAttribute ? element.getAttribute('route') : null; 136 | const routeName = attr ? attr.trim() : null; 137 | return routeName && routeName.length ? routeName : 'application'; 138 | } 139 | 140 | /** 141 | * If the referenced class is a route, returns the name of a specified outlet. 142 | * 143 | * @param {HTMLElement|EmberCustomElement} element 144 | * @returns {String|null} 145 | */ 146 | function getOutletName(element) { 147 | const options = getOptions(element); 148 | return options?.outletName || element.getAttribute('name') || 'main'; 149 | } 150 | 151 | /** 152 | * If the referenced class is a route, and this is set to `true`, the DOM tree 153 | * inside the element will not be cleared when the route is transitioned away 154 | * until the element itself is destroyed. 155 | * 156 | * This only applies to routes. No behavior changes when applied to components 157 | * or applications. 158 | * 159 | * @param {HTMLElement|EmberCustomElement} element 160 | * @returns {Boolean} 161 | */ 162 | export function getPreserveOutletContent(element) { 163 | const options = getOptions(element); 164 | return options?.preserveOutletContent || element.getAttribute('preserve-content') === 'true' || false; 165 | } 166 | -------------------------------------------------------------------------------- /addon/lib/route-connections.js: -------------------------------------------------------------------------------- 1 | import Ember from './ember-compat'; 2 | 3 | /** 4 | * This file is a bit of a hack. What it does is expose access to a 5 | * private value in the Ember internals which allows us to consistently 6 | * map route instances to outlet states. Otherwise, there isn't a way 7 | * to identify whether an outlet state matches a route other than 8 | * matching by template name, which may be totally different from the 9 | * route name if the route is rendering different templates into 10 | * named routes. 11 | * 12 | * Fortunately, the internal module for Ember.Route exports its WeakMap 13 | * for keeping track of what routes are connected to which outlet states. 14 | */ 15 | 16 | Ember.__loader.define('ember-custom-elements/route-connections', ['exports', '@ember/-internals/routing/lib/system/route'], function(_exports, _route) { 17 | 'use strict'; 18 | Object.defineProperty(_exports, '__esModule', { 19 | value: true 20 | }); 21 | _exports.default = _route.ROUTE_CONNECTIONS; 22 | }); 23 | const { default: ROUTE_CONNECTIONS } = Ember.__loader.require('ember-custom-elements/route-connections'); 24 | 25 | export default ROUTE_CONNECTIONS; -------------------------------------------------------------------------------- /addon/lib/template-compiler.js: -------------------------------------------------------------------------------- 1 | import { createTemplateFactory } from '@ember/template-factory'; 2 | const BASE_TEMPLATE = '~~BASE~TEMPLATE~~'; 3 | const BREAK = Symbol('break'); 4 | 5 | /** 6 | * Because the `ember-template-compiler.js` file is so large, 7 | * this module is a sort of hack to extract only the part of 8 | * the template compilation process that we need to consistently 9 | * render components in arbitrary locations, while supporting 10 | * all the expected behavior of the component lifecycle, which 11 | * is hard to achieve when instantiating a component class 12 | * outside of Ember's rendering system. 13 | * 14 | * There is a Broccoli plugin in this add-on that replaces the 15 | * `BASE_TEMPLATE` sigil above with a precompiled "base" template 16 | * for a component. This gives us a template structure we can 17 | * build a component template off of. The reason we need to do 18 | * this is that the template structure changes for different 19 | * versions of Ember, as well as the opcodes, so this allows us 20 | * to build templates for the version of Ember being used, whilst 21 | * not having to include hundreds of kilobytes from 22 | * `ember-template-compiler.js` on the frontend. 23 | */ 24 | 25 | /** 26 | * Given a component name and a list of element attributes, 27 | * compiles a template that renders a component with those 28 | * element attributes mapped to arguments. 29 | * 30 | * This will only work for component instantiation. It's not 31 | * designed to compile any other kind of template. 32 | * 33 | * @param {String} componentName - This should be kabob-case. 34 | * @param {Array} attributeNames - A list of element attribute names. 35 | */ 36 | export function compileTemplate(componentName, attributeNames=[]) { 37 | const template = clone(BASE_TEMPLATE); 38 | const block = JSON.parse(template.block); 39 | const statement = block.statements ? block.statements[0] : block; 40 | if (Array.isArray(block.symbols)) block.symbols = []; 41 | // Replace the placeholder component name with the actual one. 42 | crawl(statement, ({ object }) => { 43 | if (object === 'component-name') return componentName; 44 | }); 45 | let argumentNames; 46 | let argumentIdentifiers; 47 | // Identify the argument names array 48 | crawl(statement, ({ object, next }) => { 49 | if (!object || object[0] !== '@argName') return; 50 | argumentNames = object; 51 | argumentIdentifiers = next; 52 | return BREAK; 53 | }); 54 | // Now that we have the argument names array, 55 | // erase the placeholder within in 56 | argumentNames.length = 0; 57 | const baseValue = argumentIdentifiers[0]; 58 | argumentIdentifiers.length = 0; 59 | // https://github.com/glimmerjs/glimmer-vm/blob/319f3e391c547544129e4dab0746b059b665880e/packages/%40glimmer/compiler/lib/allocate-symbols.ts#L113 60 | function pushArg(name) { 61 | argumentNames.push(`@${name}`); 62 | // https://github.com/glimmerjs/glimmer-vm/blob/319f3e391c547544129e4dab0746b059b665880e/packages/%40glimmer/compiler/lib/allocate-symbols.ts#L130 63 | const value = clone(baseValue); 64 | crawl(value, ({ object }) => { 65 | if (object !== 'valueName') return; 66 | return name; 67 | }); 68 | argumentIdentifiers.push(value); 69 | } 70 | // Set args 71 | for (const name of attributeNames) pushArg(name); 72 | // Return a template factory 73 | template.id = componentName; 74 | template.block = JSON.stringify(block); 75 | return createTemplateFactory(template); 76 | } 77 | 78 | /** 79 | * "clones" an object. 80 | * Obviously only supports JSON-compatible types 81 | * but that's fine for the purposes of this lib. 82 | * @param {*} obj 83 | */ 84 | function clone(obj) { 85 | return JSON.parse(JSON.stringify(obj)); 86 | } 87 | 88 | /** 89 | * Given an object and a callback, will crawl the object 90 | * until the callback returns a truthy value, in which case 91 | * the current value being crawled will be replaced by 92 | * the return value of the callback. If `BREAK` is returned 93 | * by the callback, the crawl will be cancelled. 94 | * 95 | * @param {Object|Array|Function} obj 96 | * @param {Function} callback 97 | */ 98 | function crawl(obj, callback) { 99 | const ctx = { 100 | parent: null, 101 | previous: null, 102 | next: null, 103 | index: null, 104 | object: obj 105 | }; 106 | const _crawl = (ctx) => { 107 | const callbackResult = callback({ ...ctx }); 108 | if (typeof callbackResult !== 'undefined') return callbackResult; 109 | const obj = ctx.object; 110 | if (typeof obj !== 'object') return null; 111 | for (const i in obj) { 112 | // eslint-disable-next-line no-prototype-builtins 113 | if (!obj.hasOwnProperty(i)) continue; 114 | const crawlResult = _crawl({ 115 | parent: obj, 116 | object: obj[i], 117 | next: Array.isArray(obj) ? obj[parseInt(i) + 1] : null, 118 | previous: Array.isArray(obj) ? obj[parseInt(i) - 1] : null, 119 | index: i 120 | }); 121 | if (crawlResult === BREAK) break; 122 | if (crawlResult) obj[i] = crawlResult; 123 | } 124 | return null; 125 | } 126 | return _crawl(ctx); 127 | } 128 | -------------------------------------------------------------------------------- /app/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/app/.gitkeep -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | customElement, 3 | getCustomElement, 4 | setupCustomElementFor, 5 | EmberOutletElement, 6 | EmberCustomElement 7 | } from 'ember-custom-elements'; -------------------------------------------------------------------------------- /app/instance-initializers/ember-custom-elements.js: -------------------------------------------------------------------------------- 1 | export { default, initialize } from 'ember-custom-elements/instance-initializers/ember-custom-elements'; 2 | -------------------------------------------------------------------------------- /config/ember-try.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getChannelURL = require('ember-source-channel-url'); 4 | 5 | module.exports = async function() { 6 | return { 7 | scenarios: [ 8 | { 9 | name: 'ember-lts-3.8', 10 | npm: { 11 | devDependencies: { 12 | 'ember-source': '~3.8.0' 13 | } 14 | } 15 | }, 16 | { 17 | name: 'ember-lts-3.12', 18 | npm: { 19 | devDependencies: { 20 | 'ember-source': '~3.12.0' 21 | } 22 | } 23 | }, 24 | { 25 | name: 'ember-lts-3.16', 26 | npm: { 27 | devDependencies: { 28 | 'ember-source': '~3.16.0' 29 | } 30 | } 31 | }, 32 | { 33 | name: 'ember-lts-3.20', 34 | npm: { 35 | devDependencies: { 36 | 'ember-source': '~3.20.0' 37 | } 38 | } 39 | }, 40 | { 41 | name: 'ember-lts-3.24', 42 | npm: { 43 | devDependencies: { 44 | 'ember-source': '~3.24.0' 45 | } 46 | } 47 | }, 48 | { 49 | name: 'ember-release', 50 | npm: { 51 | devDependencies: { 52 | 'ember-source': await getChannelURL('release') 53 | } 54 | } 55 | }, 56 | { 57 | name: 'ember-beta', 58 | npm: { 59 | devDependencies: { 60 | 'ember-source': await getChannelURL('beta') 61 | } 62 | } 63 | }, 64 | { 65 | name: 'ember-canary', 66 | npm: { 67 | devDependencies: { 68 | 'ember-source': await getChannelURL('canary') 69 | } 70 | } 71 | }, 72 | // The default `.travis.yml` runs this scenario via `npm test`, 73 | // not via `ember try`. It's still included here so that running 74 | // `ember try:each` manually or from a customized CI config will run it 75 | // along with all the other scenarios. 76 | { 77 | name: 'ember-default', 78 | npm: { 79 | devDependencies: {} 80 | } 81 | }, 82 | { 83 | name: 'ember-classic', 84 | env: { 85 | EMBER_OPTIONAL_FEATURES: JSON.stringify({ 86 | 'application-template-wrapper': true, 87 | 'default-async-observers': false, 88 | 'template-only-glimmer-components': false 89 | }) 90 | }, 91 | npm: { 92 | ember: { 93 | edition: 'classic' 94 | } 95 | } 96 | } 97 | ] 98 | }; 99 | }; 100 | -------------------------------------------------------------------------------- /config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(/* environment, appConfig */) { 4 | return { }; 5 | }; 6 | -------------------------------------------------------------------------------- /ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 4 | 5 | module.exports = function(defaults) { 6 | let app = new EmberAddon(defaults, { 7 | // Add options here 8 | }); 9 | 10 | /* 11 | This build file specifies the options for the dummy test app of this 12 | addon, located in `/tests/dummy` 13 | This build file does *not* influence how the addon or the app using it 14 | behave. You most likely want to be modifying `./index.js` or app's build file 15 | */ 16 | 17 | return app.toTree(); 18 | }; 19 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line node/no-unpublished-require 2 | const { precompile } = require('ember-source/dist/ember-template-compiler'); 3 | const replace = require('broccoli-string-replace'); 4 | 5 | 'use strict'; 6 | 7 | const BASE_TEMPLATE_STRING = '{{this.blockContent}}'; 8 | 9 | module.exports = { 10 | name: require('./package').name, 11 | treeForAddon(tree) { 12 | let outputTree = replace(tree, { 13 | files: ['lib/template-compiler.js'], 14 | pattern: { 15 | match: /'~~BASE~TEMPLATE~~'/, 16 | replacement: precompile(BASE_TEMPLATE_STRING) 17 | } 18 | }); 19 | return this._super(outputTree); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | {"compilerOptions":{"target":"es6","experimentalDecorators":true},"exclude":["node_modules","bower_components","tmp","vendor",".git","dist"]} -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ember-custom-elements", 3 | "version": "2.1.0-1", 4 | "description": "Easily use custom elements to invoke your Ember components, routes, and applications.", 5 | "keywords": [ 6 | "ember-addon", 7 | "emberjs", 8 | "ember", 9 | "components", 10 | "web components", 11 | "custom elements" 12 | ], 13 | "repository": "https://github.com/Ravenstine/ember-custom-elements", 14 | "license": "MIT", 15 | "author": "Ten Bitcomb", 16 | "directories": { 17 | "doc": "doc", 18 | "test": "tests" 19 | }, 20 | "scripts": { 21 | "build": "ember build --environment=production", 22 | "lint": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*", 23 | "lint:hbs": "ember-template-lint .", 24 | "lint:js": "eslint .", 25 | "lint:js:fix": "eslint . --fix", 26 | "start": "ember serve", 27 | "test": "npm-run-all lint:* test:*", 28 | "test:ember": "ember test", 29 | "test:ember-compatibility": "ember try:each", 30 | "try:clean": "rm package-lock.json.ember-try && rm -rf .node_modules.ember-try && rm -rf node_modules/ && npm install" 31 | }, 32 | "dependencies": { 33 | "broccoli-string-replace": "^0.1.2", 34 | "ember-cli-babel": "^7.23.1", 35 | "ember-cli-htmlbars": "^5.3.1", 36 | "rsvp": "^4.8.5" 37 | }, 38 | "devDependencies": { 39 | "@ember/optional-features": "^2.0.0", 40 | "@ember/test-helpers": "^2.2.0", 41 | "@glimmer/component": "^1.0.3", 42 | "@glimmer/tracking": "^1.0.3", 43 | "babel-eslint": "^10.1.0", 44 | "broccoli-asset-rev": "^3.0.0", 45 | "ember-auto-import": "^1.10.1", 46 | "ember-cli": "~3.25.2", 47 | "ember-cli-dependency-checker": "^3.2.0", 48 | "ember-cli-inject-live-reload": "^2.0.2", 49 | "ember-cli-sri": "^2.1.1", 50 | "ember-cli-terser": "^4.0.1", 51 | "ember-decorators-polyfill": "^1.1.5", 52 | "ember-disable-prototype-extensions": "^1.1.3", 53 | "ember-export-application-global": "^2.0.1", 54 | "ember-load-initializers": "^2.1.2", 55 | "ember-maybe-import-regenerator": "^0.1.6", 56 | "ember-page-title": "^6.2.1", 57 | "ember-qunit": "^5.1.2", 58 | "ember-resolver": "^8.0.2", 59 | "ember-source": "~3.26.1", 60 | "ember-source-channel-url": "^3.0.0", 61 | "ember-template-lint": "^2.18.1", 62 | "ember-try": "^1.4.0", 63 | "eslint": "^7.20.0", 64 | "eslint-config-prettier": "^7.2.0", 65 | "eslint-plugin-ember": "^10.2.0", 66 | "eslint-plugin-node": "^11.1.0", 67 | "eslint-plugin-prettier": "^3.3.1", 68 | "loader.js": "^4.7.0", 69 | "npm-run-all": "^4.1.5", 70 | "prettier": "^2.2.1", 71 | "qunit": "^2.14.0", 72 | "qunit-dom": "^1.6.0" 73 | }, 74 | "engines": { 75 | "node": "10.* || >= 12" 76 | }, 77 | "ember": { 78 | "edition": "octane" 79 | }, 80 | "ember-addon": { 81 | "configPath": "tests/dummy/config", 82 | "paths": [ 83 | "tests/dummy-add-on" 84 | ] 85 | }, 86 | "peerDependencies": { 87 | "ember-source": ">=3.6.0" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | launch_in_ci: ['Chrome'], 7 | launch_in_dev: ['Chrome'], 8 | browser_start_timeout: 120, 9 | browser_args: { 10 | Chrome: { 11 | ci: [ 12 | // --no-sandbox is needed when running Chrome inside a container 13 | process.env.CI ? '--no-sandbox' : null, 14 | '--headless', 15 | '--disable-dev-shm-usage', 16 | '--disable-software-rasterizer', 17 | '--mute-audio', 18 | '--remote-debugging-port=0', 19 | '--window-size=1440,900', 20 | ].filter(Boolean), 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /tests/acceptance/ember-custom-elements-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { find, visit, currentURL } from '@ember/test-helpers'; 3 | import { setupApplicationTest } from 'ember-qunit'; 4 | 5 | module('Acceptance | ember custom elements', function(hooks) { 6 | setupApplicationTest(hooks); 7 | 8 | test('visiting /test', async function(assert) { 9 | await visit('/test'); 10 | 11 | assert.equal(currentURL(), '/test'); 12 | 13 | const element = find('foo-bar'); 14 | 15 | assert.equal(element.textContent.trim(), 'foo bar'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/dummy-add-on/addon/components/dummy-add-on-component.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-empty-glimmer-component-classes */ 2 | import Component from '@glimmer/component'; 3 | import { customElement } from 'ember-custom-elements'; 4 | 5 | @customElement('dummy-add-on-component') 6 | export default class DummyAddOnComponent extends Component { 7 | 8 | } -------------------------------------------------------------------------------- /tests/dummy-add-on/app/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/dummy-add-on/app/.gitkeep -------------------------------------------------------------------------------- /tests/dummy-add-on/app/components/dummy-add-on-component.js: -------------------------------------------------------------------------------- 1 | export { default } from 'dummy-add-on/components/dummy-add-on-component'; -------------------------------------------------------------------------------- /tests/dummy-add-on/app/templates/components/dummy-add-on-component.hbs: -------------------------------------------------------------------------------- 1 |

Foo Bar

-------------------------------------------------------------------------------- /tests/dummy-add-on/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable node/no-extraneous-require */ 2 | 'use strict'; 3 | 4 | const funnel = require('broccoli-funnel'); 5 | 6 | module.exports = { 7 | name: require('./package').name, 8 | isDevelopingAddon() { 9 | return true; 10 | }, 11 | treeForAddon() { 12 | const tree = this._super(...arguments); 13 | if (!this.parent || !this.parent.app) { 14 | return tree; 15 | } else { 16 | return funnel(tree, { 17 | exclude: [/.*/] 18 | }); 19 | } 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /tests/dummy-add-on/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dummy-add-on", 3 | "keywords": [ 4 | "ember-addon" 5 | ], 6 | "dependencies": { 7 | "ember-cli-babel": "^7.18.0", 8 | "ember-cli-htmlbars": "^5.3.1" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | import Application from '@ember/application'; 2 | import Resolver from 'ember-resolver'; 3 | import loadInitializers from 'ember-load-initializers'; 4 | import config from './config/environment'; 5 | 6 | export default class App extends Application { 7 | modulePrefix = config.modulePrefix; 8 | podModulePrefix = config.podModulePrefix; 9 | Resolver = Resolver; 10 | } 11 | 12 | loadInitializers(App, config.modulePrefix); 13 | -------------------------------------------------------------------------------- /tests/dummy/app/components/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/dummy/app/components/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/components/test-component.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-classic-components */ 2 | /* eslint-disable ember/require-tagless-components */ 3 | import Component from '@ember/component'; 4 | import { customElement } from 'ember-custom-elements'; 5 | 6 | @customElement('foo-bar') 7 | export default class TestComponent extends Component { 8 | } -------------------------------------------------------------------------------- /tests/dummy/app/controllers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/dummy/app/controllers/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/helpers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/dummy/app/helpers/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} 16 | 17 | 18 | {{content-for "body"}} 19 | 20 | 21 | 22 | 23 | {{content-for "body-footer"}} 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/dummy/app/models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/dummy/app/models/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from './config/environment'; 3 | 4 | export default class Router extends EmberRouter { 5 | location = config.locationType; 6 | rootURL = config.rootURL; 7 | } 8 | 9 | Router.map(function() { 10 | this.route('test'); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/dummy/app/routes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/dummy/app/routes/.gitkeep -------------------------------------------------------------------------------- /tests/dummy/app/routes/test.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default class TestRoute extends Route { 4 | } 5 | -------------------------------------------------------------------------------- /tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/dummy/app/styles/app.css -------------------------------------------------------------------------------- /tests/dummy/app/templates/application.hbs: -------------------------------------------------------------------------------- 1 |

Welcome to Ember

2 | {{outlet}} -------------------------------------------------------------------------------- /tests/dummy/app/templates/components/test-component.hbs: -------------------------------------------------------------------------------- 1 | foo bar -------------------------------------------------------------------------------- /tests/dummy/app/templates/test-loading.hbs: -------------------------------------------------------------------------------- 1 |

Loading...

-------------------------------------------------------------------------------- /tests/dummy/app/templates/test.hbs: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/dummy/app/templates/test/bar.hbs: -------------------------------------------------------------------------------- 1 | {{outlet}} -------------------------------------------------------------------------------- /tests/dummy/app/templates/test/foo.hbs: -------------------------------------------------------------------------------- 1 | {{!-- --}} -------------------------------------------------------------------------------- /tests/dummy/app/templates/test/foo/bar.hbs: -------------------------------------------------------------------------------- 1 |

BAR!

-------------------------------------------------------------------------------- /tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function(environment) { 4 | let ENV = { 5 | modulePrefix: 'dummy', 6 | environment, 7 | rootURL: '/', 8 | locationType: 'none', 9 | EmberENV: { 10 | FEATURES: { 11 | // Here you can enable experimental features on an ember canary build 12 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 13 | }, 14 | EXTEND_PROTOTYPES: { 15 | // Prevent Ember Data from overriding Date.parse. 16 | Date: false 17 | } 18 | }, 19 | 20 | APP: { 21 | // Here you can pass flags/options to your application instance 22 | // when it is created 23 | } 24 | }; 25 | 26 | if (environment === 'development') { 27 | // ENV.APP.LOG_RESOLVER = true; 28 | // ENV.APP.LOG_ACTIVE_GENERATION = true; 29 | // ENV.APP.LOG_TRANSITIONS = true; 30 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 31 | // ENV.APP.LOG_VIEW_LOOKUPS = true; 32 | } 33 | 34 | if (environment === 'test') { 35 | // Testem prefers this... 36 | ENV.locationType = 'none'; 37 | 38 | // keep test console output quieter 39 | ENV.APP.LOG_ACTIVE_GENERATION = false; 40 | ENV.APP.LOG_VIEW_LOOKUPS = false; 41 | 42 | ENV.APP.rootElement = '#ember-testing'; 43 | ENV.APP.autoboot = false; 44 | } 45 | 46 | if (environment === 'production') { 47 | // here you can enable a production-specific feature 48 | } 49 | 50 | return ENV; 51 | }; 52 | -------------------------------------------------------------------------------- /tests/dummy/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true 6 | } 7 | -------------------------------------------------------------------------------- /tests/dummy/config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 1 Chrome versions', 5 | 'last 1 Firefox versions', 6 | 'last 1 Safari versions' 7 | ]; 8 | 9 | const isCI = !!process.env.CI; 10 | const isProduction = process.env.EMBER_ENV === 'production'; 11 | 12 | if (isCI || isProduction) { 13 | browsers.push('ie 11'); 14 | } 15 | 16 | module.exports = { 17 | browsers 18 | }; 19 | -------------------------------------------------------------------------------- /tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /tests/helpers/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/helpers/.gitkeep -------------------------------------------------------------------------------- /tests/helpers/ember-custom-elements.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-private-routing-service */ 2 | /* eslint-disable ember/new-module-imports */ 3 | import Ember from 'ember-custom-elements/lib/ember-compat'; 4 | import Router from '@ember/routing/router'; 5 | import { setupCustomElementFor } from 'ember-custom-elements'; 6 | 7 | export function setupComponentForTest(owner, componentClass, template, registrationName) { 8 | owner.register(`component:${registrationName}`, componentClass); 9 | setupCustomElementFor(owner, `component:${registrationName}`); 10 | if (Ember._setComponentTemplate) { 11 | return Ember._setComponentTemplate(template, componentClass); 12 | } 13 | try { 14 | owner.unregister(`template:components/${registrationName}`) 15 | } catch(err) { 16 | // noop 17 | } 18 | owner.register(`template:components/${registrationName}`, template); 19 | } 20 | 21 | export function setupApplicationForTest(owner, applicationClass, registrationName) { 22 | const fullName = `application:${registrationName}`; 23 | try { 24 | owner.unregister(fullName); 25 | } catch(err) { 26 | // noop 27 | } 28 | owner.register(fullName, applicationClass); 29 | setupCustomElementFor(owner, fullName); 30 | } 31 | 32 | export function setupRouteForTest(owner, routeClass, registrationName) { 33 | const fullName = `route:${registrationName}`; 34 | try { 35 | owner.unregister(fullName); 36 | } catch(err) { 37 | // noop 38 | } 39 | owner.register(fullName, routeClass); 40 | setupCustomElementFor(owner, fullName); 41 | } 42 | 43 | export function setupNativeElementForTest(owner, elementClass, tagName) { 44 | const fullName = `custom-element:${tagName}`; 45 | try { 46 | owner.unregister(fullName); 47 | } catch (_) { 48 | // noop 49 | } 50 | owner.register(fullName, elementClass); 51 | setupCustomElementFor(owner, fullName); 52 | } 53 | 54 | export function setupRouteTest(hooks) { 55 | hooks.beforeEach(function() { 56 | document.getElementById('ember-testing').classList.remove('ember-application'); 57 | try { 58 | this.owner.lookup('router:main').destroy(); 59 | this.owner.unregister('router:main'); 60 | } catch (err) { 61 | // noop 62 | } 63 | class TestRouter extends Router { 64 | location = 'none'; 65 | rootURL = '/'; 66 | } 67 | this.owner.register('router:main', TestRouter); 68 | this.owner.lookup('router:main'); 69 | document.getElementById('ember-testing').classList.add('ember-application'); 70 | }); 71 | } 72 | 73 | export function setupTestRouter(owner, callback) { 74 | const TestRouter = owner.resolveRegistration('router:main'); 75 | 76 | TestRouter.map(callback); 77 | 78 | owner.lookup('router:main').setupRouter(); 79 | } -------------------------------------------------------------------------------- /tests/helpers/set-component-template.js: -------------------------------------------------------------------------------- 1 | import { setComponentTemplate as _setComponentTemplate } from 'ember-custom-elements/lib/ember-compat'; 2 | 3 | export async function setComponentTemplate(template, component, owner, registrationName) { 4 | if (_setComponentTemplate) { 5 | return _setComponentTemplate(component, template); 6 | } 7 | component.prototype.layout = template; 8 | try { 9 | owner.unregister(`template:components/${registrationName}`) 10 | } catch(err) { 11 | // noop 12 | } 13 | owner.register(`template:components/${registrationName}`, template); 14 | } 15 | -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Dummy Tests 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | {{content-for "test-head"}} 12 | 13 | 14 | 15 | 16 | 17 | {{content-for "head-footer"}} 18 | {{content-for "test-head-footer"}} 19 | 20 | 21 | {{content-for "body"}} 22 | {{content-for "test-body"}} 23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {{content-for "body-footer"}} 38 | {{content-for "test-body-footer"}} 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/integration/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/integration/.gitkeep -------------------------------------------------------------------------------- /tests/integration/ember-custom-elements-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/require-tagless-components */ 2 | /* eslint-disable ember/no-classic-components */ 3 | /* eslint-disable no-unused-vars */ 4 | import Ember, { registerDestructor } from 'ember-custom-elements/lib/ember-compat'; 5 | import { module, test, } from 'qunit'; 6 | import { setupRenderingTest } from 'ember-qunit'; 7 | import { set } from '@ember/object'; 8 | import { later, scheduleOnce } from '@ember/runloop'; 9 | import { find, 10 | findAll, 11 | render, 12 | waitUntil, 13 | settled 14 | } from '@ember/test-helpers'; 15 | import { 16 | setupComponentForTest, 17 | setupRouteForTest, 18 | setupRouteTest, 19 | setupApplicationForTest, 20 | setupNativeElementForTest, 21 | setupTestRouter 22 | } from '../helpers/ember-custom-elements'; 23 | import { hbs } from 'ember-cli-htmlbars'; 24 | import EmberComponent from '@ember/component'; 25 | import GlimmerComponent from '@glimmer/component'; 26 | import DummyApplication from 'dummy/app'; 27 | import Route from '@ember/routing/route'; 28 | import { customElement, forwarded, getCustomElement } from 'ember-custom-elements'; 29 | import { tracked } from '@glimmer/tracking'; 30 | import Service, { inject as service } from '@ember/service'; 31 | import { getOwner } from '@ember/application'; 32 | 33 | module('Integration | Component | ember-custom-elements', function (hooks) { 34 | setupRenderingTest(hooks); 35 | 36 | const components = [ 37 | { name: 'ember component', klass: EmberComponent }, 38 | { name: 'glimmer component', klass: GlimmerComponent } 39 | ]; 40 | 41 | for (const { name, klass } of components) { 42 | module(name, function () { 43 | test('it renders', async function (assert) { 44 | @customElement('web-component') 45 | class EmberCustomElement extends klass {} 46 | 47 | const template = hbs`foo bar`; 48 | 49 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 50 | 51 | await render(hbs``); 52 | 53 | const element = find('web-component'); 54 | assert.equal(element.textContent.trim(), 'foo bar'); 55 | }); 56 | 57 | test('it supports function syntax', async function (assert) { 58 | const EmberCustomElement = customElement(class extends klass {}, 'web-component'); 59 | 60 | const template = hbs`foo bar`; 61 | 62 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 63 | 64 | await render(hbs``); 65 | const element = find('web-component'); 66 | assert.equal(element.textContent.trim(), 'foo bar'); 67 | }); 68 | 69 | test('it translates attributes to arguments and updates them', async function (assert) { 70 | assert.expect(2); 71 | 72 | @customElement('web-component') 73 | class EmberCustomElement extends klass {} 74 | 75 | const template = hbs`{{@foo}}`; 76 | 77 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 78 | 79 | set(this, 'foo', 'bar'); 80 | await render(hbs``); 81 | const element = find('web-component'); 82 | 83 | assert.equal(element.textContent.trim(), 'bar'); 84 | 85 | set(this, 'foo', 'baz'); 86 | await settled(); 87 | assert.equal(element.textContent.trim(), 'baz'); 88 | }); 89 | 90 | test('it can translate attributes to camelCase arguments', async function (assert) { 91 | assert.expect(2); 92 | 93 | @customElement('web-component', { camelizeArgs: true }) 94 | class EmberCustomElement extends klass {} 95 | 96 | const template = hbs`{{@fooBar}}`; 97 | 98 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 99 | 100 | set(this, 'foo', 'bar'); 101 | await render(hbs``); 102 | const element = find('web-component'); 103 | 104 | assert.equal(element.textContent.trim(), 'bar'); 105 | 106 | set(this, 'foo', 'baz'); 107 | await settled(); 108 | assert.equal(element.textContent.trim(), 'baz'); 109 | }); 110 | 111 | test('it only updates arguments defined by observedAttributes', async function (assert) { 112 | assert.expect(4); 113 | 114 | @customElement('observed-attributes', { observedAttributes: ['bar'] }) 115 | class EmberCustomElement extends klass {} 116 | 117 | const template = hbs` 118 | {{@foo}} 119 | {{@bar}} 120 | `; 121 | 122 | setupComponentForTest(this.owner, EmberCustomElement, template, 'observed-attributes'); 123 | 124 | set(this, 'foo', 'bar'); 125 | set(this, 'bar', 'baz'); 126 | 127 | await render(hbs``); 128 | 129 | const element = find('observed-attributes'); 130 | const foo = element.querySelector('[data-test-foo]'); 131 | const bar = element.querySelector('[data-test-bar]'); 132 | 133 | assert.equal(foo.textContent.trim(), 'bar'); 134 | assert.equal(bar.textContent.trim(), 'baz'); 135 | 136 | set(this, 'foo', 'baz'); 137 | set(this, 'bar', 'qux'); 138 | 139 | await settled(); 140 | 141 | assert.equal(foo.textContent.trim(), 'bar'); 142 | assert.equal(bar.textContent.trim(), 'qux'); 143 | }); 144 | 145 | test('it takes block content', async function (assert) { 146 | assert.expect(2); 147 | 148 | @customElement('web-component') 149 | class EmberCustomElement extends klass {} 150 | 151 | const template = hbs`foo {{yield}} baz`; 152 | 153 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 154 | 155 | set(this, 'bar', 'bar'); 156 | await render(hbs`{{this.bar}}`); 157 | const element = find('web-component'); 158 | assert.equal(element.textContent.trim(), 'foo bar baz'); 159 | 160 | set(this, 'bar', 'baz'); 161 | await settled(); 162 | assert.equal(element.textContent.trim(), 'foo baz baz') 163 | }); 164 | 165 | test('it supports logic with block content', async function (assert) { 166 | assert.expect(3); 167 | 168 | @customElement('web-component') 169 | class EmberCustomElement extends klass {} 170 | 171 | const template = hbs`foo{{#if @show-content}} {{yield}}{{/if}} baz`; 172 | 173 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 174 | 175 | set(this, 'bar', 'bar'); 176 | set(this, 'showContent', 'true'); 177 | await render(hbs`{{this.bar}}`); 178 | const element = find('web-component'); 179 | assert.equal(element.textContent.trim(), 'foo bar baz'); 180 | 181 | set(this, 'showContent', false); 182 | await settled(); 183 | assert.equal(element.textContent.trim(), 'foo baz'); 184 | 185 | set(this, 'bar', 'baz'); 186 | set(this, 'showContent', 'true'); 187 | await settled(); 188 | assert.equal(element.textContent.trim(), 'foo baz baz'); 189 | }); 190 | 191 | test('it can render with a shadow root', async function (assert) { 192 | @customElement('web-component', { useShadowRoot: true }) 193 | class EmberCustomElement extends klass {} 194 | 195 | const template = hbs`foo bar`; 196 | 197 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 198 | 199 | await render(hbs``); 200 | 201 | const element = find('web-component'); 202 | assert.equal(element.shadowRoot.textContent.trim(), 'foo bar'); 203 | }); 204 | 205 | test('it can define multiple custom elements', async function (assert) { 206 | // Just adding an options hash here to make sure it doesn't cause an error 207 | @customElement('foo-component') 208 | @customElement('bar-component') 209 | class EmberCustomElement extends klass {} 210 | 211 | const template = hbs`foo bar`; 212 | 213 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 214 | 215 | await render(hbs``); 216 | 217 | const foo = find('foo-component'); 218 | assert.equal(foo.textContent.trim(), 'foo bar'); 219 | 220 | const bar = find('bar-component'); 221 | assert.equal(bar.textContent.trim(), 'foo bar'); 222 | }); 223 | 224 | test('it can access the custom element in the constructor', async function (assert) { 225 | assert.expect(1); 226 | 227 | @customElement('web-component', { useShadowRoot: false }) 228 | class EmberCustomElement extends klass { 229 | constructor() { 230 | super(...arguments); 231 | const element = getCustomElement(this); 232 | assert.equal(element.tagName, 'WEB-COMPONENT', 'found the custom element'); 233 | } 234 | } 235 | 236 | setupComponentForTest(this.owner, EmberCustomElement, hbs``, 'web-component'); 237 | 238 | await render(hbs``); 239 | 240 | }); 241 | 242 | test('it can access the custom element in another method', async function (assert) { 243 | assert.expect(1); 244 | 245 | @customElement('web-component', { useShadowRoot: false }) 246 | class EmberCustomElement extends klass { 247 | constructor() { 248 | super(...arguments); 249 | scheduleOnce('actions', this, 'someMethod'); 250 | } 251 | someMethod() { 252 | const element = getCustomElement(this); 253 | assert.equal(element.tagName, 'WEB-COMPONENT', 'found the custom element'); 254 | } 255 | } 256 | 257 | setupComponentForTest(this.owner, EmberCustomElement, hbs``, 'web-component'); 258 | 259 | await render(hbs``); 260 | 261 | }); 262 | 263 | test('it can interface with custom element properties', async function (assert) { 264 | @customElement('web-component') 265 | class EmberCustomElement extends klass { 266 | @forwarded foo; 267 | 268 | constructor() { 269 | super(...arguments); 270 | this.foo = 'bar'; 271 | } 272 | } 273 | 274 | const template = hbs`foo bar`; 275 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 276 | 277 | await render(hbs``); 278 | const element = find('web-component'); 279 | assert.equal(element.foo, 'bar', 'sets a property'); 280 | }); 281 | 282 | // eslint-disable-next-line ember/new-module-imports 283 | if (Ember._tracked) { 284 | test('it can track interfaced custom element properties', async function (assert) { 285 | @customElement('web-component') 286 | class EmberCustomElement extends klass { 287 | @forwarded 288 | @tracked 289 | foo; 290 | 291 | constructor() { 292 | super(...arguments); 293 | } 294 | } 295 | 296 | const template = hbs`{{this.foo}}`; 297 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 298 | 299 | await render(hbs``); 300 | const element = find('web-component'); 301 | element.foo = 'bar'; 302 | await settled(); 303 | assert.equal(element.textContent.trim(), 'bar', 'responds to change'); 304 | }); 305 | } 306 | 307 | test('it forwards methods', async function (assert) { 308 | @customElement('web-component') 309 | class EmberCustomElement extends klass { 310 | foo = 'foobar'; 311 | 312 | @forwarded 313 | foobar() { 314 | return this.foo.toUpperCase(); 315 | } 316 | } 317 | 318 | const template = hbs`foo bar`; 319 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 320 | 321 | await render(hbs``); 322 | const element = find('web-component'); 323 | assert.equal(element.foobar(), 'FOOBAR', 'calls method on component'); 324 | }); 325 | 326 | test('it throws error when applied to static properties', async function (assert) { 327 | assert.throws(() => { 328 | @customElement('web-component') 329 | class EmberCustomElement extends klass { 330 | @forwarded 331 | static foo = 'foobar'; 332 | } 333 | }); 334 | }); 335 | 336 | test('it should still render without a custom element', async function (assert) { 337 | @customElement('web-component') 338 | class EmberCustomElement extends klass {} 339 | 340 | const template = hbs`
foo bar
`; 341 | 342 | setupComponentForTest(this.owner, EmberCustomElement, template, 'web-component'); 343 | 344 | await render(hbs``); 345 | const element = find('[data-test-component]'); 346 | assert.equal(element.textContent.trim(), 'foo bar'); 347 | }); 348 | }); 349 | } 350 | 351 | module('ember application', function () { 352 | const performTest = (isOwned) => { 353 | module(isOwned ? 'owned' : 'standalone', function () { 354 | test('it renders', async function (assert) { 355 | @customElement('web-component') 356 | class EmberWebApplication extends DummyApplication { 357 | autoboot = false; 358 | } 359 | if (isOwned) { 360 | setupApplicationForTest(this.owner, EmberWebApplication, 'ember-web-application'); 361 | } 362 | await render(hbs``); 363 | const element = find('web-component'); 364 | await settled(); 365 | assert.equal(element.textContent.trim(), 'Welcome to Ember'); 366 | }); 367 | 368 | test('it can appear multiple times in the DOM', async function (assert) { 369 | assert.expect(3); 370 | 371 | @customElement('web-component') 372 | class EmberWebApplication extends DummyApplication { 373 | autoboot = false; 374 | } 375 | if (isOwned) { 376 | setupApplicationForTest(this.owner, EmberWebApplication, 'ember-web-application'); 377 | } 378 | await render(hbs` 379 | 380 | 381 | 382 | `); 383 | 384 | const elements = findAll('web-component'); 385 | for (const element of elements) { 386 | assert.equal(element.textContent.trim(), 'Welcome to Ember'); 387 | } 388 | }); 389 | 390 | test('it can access the custom element', async function (assert) { 391 | assert.expect(1); 392 | @customElement('web-component') 393 | class EmberWebApplication extends DummyApplication { 394 | autoboot = false; 395 | 396 | constructor() { 397 | super(...arguments); 398 | const element = getCustomElement(this); 399 | assert.equal(element.tagName, 'WEB-COMPONENT', 'found the custom element'); 400 | } 401 | } 402 | if (isOwned) { 403 | setupApplicationForTest(this.owner, EmberWebApplication, 'ember-web-application'); 404 | } 405 | render(hbs``); 406 | }); 407 | 408 | test('it gets destroyed when the element gets removed', async function (assert) { 409 | assert.expect(2); 410 | @customElement('web-component') 411 | class EmberWebApplication extends DummyApplication { 412 | autoboot = false; 413 | 414 | constructor() { 415 | super(...arguments); 416 | 417 | registerDestructor(this, () => { 418 | assert.step('did destroy'); 419 | }); 420 | } 421 | } 422 | if (isOwned) { 423 | setupApplicationForTest(this.owner, EmberWebApplication, 'ember-web-application'); 424 | } 425 | await render(hbs``); 426 | this.element.insertAdjacentHTML('afterbegin', ''); 427 | const element = find('web-component'); 428 | await settled(); 429 | element.remove(); 430 | await settled(); 431 | assert.verifySteps(['did destroy']); 432 | }); 433 | }); 434 | } 435 | 436 | performTest(true); 437 | performTest(false); 438 | }); 439 | 440 | module('ember routes', function (hooks) { 441 | setupRouteTest(hooks); 442 | 443 | test('it renders', async function (assert) { 444 | @customElement('web-component') 445 | class TestRoute extends Route { 446 | 447 | } 448 | setupRouteForTest(this.owner, TestRoute, 'test-route'); 449 | 450 | this.owner.register('template:application', hbs``); 451 | this.owner.register('template:test-route', hbs`

Hello World

`); 452 | 453 | setupTestRouter(this.owner, function () { 454 | this.route('test-route', { path: '/' }); 455 | }); 456 | 457 | this.owner.lookup('service:router').transitionTo('/'); 458 | await settled(); 459 | const element = find('web-component'); 460 | assert.equal(element.textContent.trim(), 'Hello World'); 461 | }); 462 | 463 | test('it can render with a shadow root', async function (assert) { 464 | @customElement('web-component', { useShadowRoot: true }) 465 | class TestRoute extends Route { 466 | 467 | } 468 | setupRouteForTest(this.owner, TestRoute, 'test-route'); 469 | 470 | this.owner.register('template:application', hbs``); 471 | this.owner.register('template:test-route', hbs`

Hello World

`); 472 | 473 | setupTestRouter(this.owner, function () { 474 | this.route('test-route', { path: '/' }); 475 | }); 476 | 477 | this.owner.lookup('service:router').transitionTo('/'); 478 | await settled(); 479 | const element = find('web-component'); 480 | assert.equal(element.shadowRoot.textContent.trim(), 'Hello World'); 481 | }); 482 | 483 | test('it renders loading substate', async function (assert) { 484 | @customElement('web-component') 485 | class TestRoute extends Route { 486 | model() { 487 | return new Promise(resolve => later(resolve, 100)); 488 | } 489 | } 490 | setupRouteForTest(this.owner, TestRoute, 'test-route'); 491 | class TestRouteLoading extends Route { 492 | 493 | } 494 | setupRouteForTest(this.owner, TestRouteLoading, 'test-route_loading'); 495 | 496 | this.owner.register('template:application', hbs``); 497 | this.owner.register('template:test-route', hbs`

Hello World

`); 498 | this.owner.register('template:test-route_loading', hbs`

Loading...

`); 499 | 500 | setupTestRouter(this.owner, function () { 501 | this.route('test-route', { path: '/' }); 502 | }); 503 | 504 | this.owner.lookup('service:router').transitionTo('/'); 505 | await waitUntil(() => find('web-component')); 506 | const element = find('web-component'); 507 | await waitUntil(() => element.querySelector('[data-test-loading]')); 508 | assert.equal(element.textContent.trim(), 'Loading...', 'renders loading substate'); 509 | await waitUntil(() => element.querySelector('[data-test-heading]')); 510 | assert.equal(element.textContent.trim(), 'Hello World', 'renders route'); 511 | }); 512 | 513 | test('it renders error substate', async function (assert) { 514 | @customElement('web-component') 515 | class TestRoute extends Route { 516 | model() { 517 | throw new Error(); 518 | } 519 | } 520 | setupRouteForTest(this.owner, TestRoute, 'test-route'); 521 | class TestRouteError extends Route { 522 | 523 | } 524 | setupRouteForTest(this.owner, TestRouteError, 'test-route_error'); 525 | 526 | this.owner.register('template:application', hbs``); 527 | this.owner.register('template:test-route', hbs`

Hello World

`); 528 | this.owner.register('template:test-route_error', hbs`

Whoops!

`); 529 | 530 | setupTestRouter(this.owner, function () { 531 | this.route('test-route', { path: '/' }); 532 | }); 533 | 534 | this.owner.lookup('service:router').transitionTo('/'); 535 | await waitUntil(() => find('web-component')); 536 | const element = find('web-component'); 537 | await waitUntil(() => element.querySelector('[data-test-error]')); 538 | assert.equal(element.textContent.trim(), 'Whoops!', 'renders error substate'); 539 | }); 540 | 541 | test('it renders routes within routes', async function (assert) { 542 | @customElement('web-component') 543 | class FooRoute extends Route {} 544 | setupRouteForTest(this.owner, FooRoute, 'foo'); 545 | this.owner.register('template:foo', hbs`

foo

{{outlet}}`); 546 | 547 | class BarRoute extends Route {} 548 | setupRouteForTest(this.owner, BarRoute, 'foo.bar'); 549 | this.owner.register('template:foo/bar', hbs`

bar

{{outlet}}`); 550 | 551 | class BazRoute extends Route {} 552 | setupRouteForTest(this.owner, BazRoute, 'foo.bar.baz'); 553 | this.owner.register('template:foo/bar/baz', hbs`

baz

`); 554 | 555 | this.owner.register('template:application', hbs``); 556 | 557 | setupTestRouter(this.owner, function () { 558 | this.route('foo', function () { 559 | this.route('bar', function () { 560 | this.route('baz'); 561 | }); 562 | }); 563 | }); 564 | 565 | await this.owner.lookup('service:router').transitionTo('/foo/bar/baz'); 566 | await settled(); 567 | const element = find('web-component'); 568 | assert.equal(element.textContent.trim(), 'foo bar baz', 'renders sub routes'); 569 | }); 570 | 571 | test('it transitions between routes', async function (assert) { 572 | @customElement('web-component') 573 | class FooRoute extends Route {} 574 | setupRouteForTest(this.owner, FooRoute, 'foo'); 575 | this.owner.register('template:foo', hbs`

foo

{{outlet}}`); 576 | 577 | class BarRoute extends Route {} 578 | setupRouteForTest(this.owner, BarRoute, 'foo.bar'); 579 | this.owner.register('template:foo/bar', hbs`

bar

`); 580 | 581 | class BazRoute extends Route {} 582 | setupRouteForTest(this.owner, BazRoute, 'foo.baz'); 583 | this.owner.register('template:foo/baz', hbs`

baz

`); 584 | 585 | this.owner.register('template:application', hbs``); 586 | 587 | setupTestRouter(this.owner, function () { 588 | this.route('foo', { path: '/' }, function () { 589 | this.route('bar'); 590 | this.route('baz'); 591 | }); 592 | }); 593 | 594 | await this.owner.lookup('service:router').transitionTo('/bar'); 595 | await settled(); 596 | const element = find('web-component'); 597 | assert.equal(element.textContent.trim(), 'foo bar', 'renders first route'); 598 | await this.owner.lookup('service:router').transitionTo('/baz'); 599 | await settled(); 600 | assert.equal(element.textContent.trim(), 'foo baz', 'transitions to second route'); 601 | }); 602 | 603 | test('it destroys DOM contents when navigating away', async function (assert) { 604 | @customElement('foo-route') 605 | class FooRoute extends Route { 606 | 607 | } 608 | setupRouteForTest(this.owner, FooRoute, 'foo-route'); 609 | 610 | @customElement('bar-route') 611 | class BazRoute extends Route { 612 | 613 | } 614 | setupRouteForTest(this.owner, BazRoute, 'bar-route'); 615 | 616 | this.owner.register('template:application', hbs``); 617 | this.owner.register('template:foo-route', hbs`

foo

`); 618 | this.owner.register('template:bar-route', hbs`

bar

`); 619 | 620 | setupTestRouter(this.owner, function () { 621 | this.route('foo-route', { path: '/foo' }); 622 | this.route('bar-route', { path: '/bar' }); 623 | }); 624 | 625 | this.owner.lookup('service:router').transitionTo('/foo'); 626 | await settled(); 627 | this.owner.lookup('service:router').transitionTo('/bar'); 628 | await settled(); 629 | const element = find('foo-route'); 630 | assert.notOk(element.querySelector('[data-test-foo]'), 'it destroys DOM contents'); 631 | }); 632 | 633 | test('it can preserve DOM contents when navigating away', async function (assert) { 634 | @customElement('foo-route', { preserveOutletContent: true }) 635 | class FooRoute extends Route { 636 | 637 | } 638 | setupRouteForTest(this.owner, FooRoute, 'foo-route'); 639 | 640 | @customElement('bar-route') 641 | class BazRoute extends Route { 642 | 643 | } 644 | setupRouteForTest(this.owner, BazRoute, 'bar-route'); 645 | 646 | this.owner.register('template:application', hbs``); 647 | this.owner.register('template:foo-route', hbs`

foo

`); 648 | this.owner.register('template:bar-route', hbs`

bar

`); 649 | 650 | setupTestRouter(this.owner, function () { 651 | this.route('foo-route', { path: '/foo' }); 652 | this.route('bar-route', { path: '/bar' }); 653 | }); 654 | 655 | this.owner.lookup('service:router').transitionTo('/foo'); 656 | await settled(); 657 | this.owner.lookup('service:router').transitionTo('/bar'); 658 | await settled(); 659 | const element = find('foo-route'); 660 | assert.ok(element.querySelector('[data-test-foo]'), 'it preserves DOM contents'); 661 | }); 662 | }); 663 | 664 | module('native custom elements', function (hooks) { 665 | test('it renders a native custom element', async function (assert) { 666 | @customElement('native-component-1') 667 | class NativeCustomElement extends HTMLElement { 668 | connectedCallback() { 669 | this.insertAdjacentHTML('beforeend', '

I am a native custom element

'); 670 | } 671 | } 672 | setupNativeElementForTest(this.owner, NativeCustomElement, 'native-component-1'); 673 | await render(hbs``); 674 | 675 | const element = find('native-component-1'); 676 | assert.equal(element.textContent.trim(), 'I am a native custom element'); 677 | }); 678 | 679 | test('it handles dynamic block content', async function (assert) { 680 | @customElement('native-component-2') 681 | class NativeCustomElement extends HTMLElement { 682 | async connectedCallback() { 683 | this.insertAdjacentText('afterbegin', 'I\'m '); 684 | this.insertAdjacentText('beforeend', 'short and stout'); 685 | } 686 | removeChild() { 687 | super.removeChild(...arguments); 688 | } 689 | insertBefore() { 690 | super.insertBefore(...arguments); 691 | } 692 | } 693 | setupNativeElementForTest(this.owner, NativeCustomElement, 'native-component-2'); 694 | set(this, 'show', true); 695 | await render(hbs`{{#if this.show}}a little teapot {{/if}}`); 696 | 697 | const element = find('native-component-2'); 698 | assert.equal(element.textContent.trim(), 'I\'m a little teapot short and stout'); 699 | set(this, 'show', false); 700 | await settled(); 701 | assert.equal(element.textContent.trim(), 'I\'m short and stout'); 702 | set(this, 'show', true); 703 | await settled(); 704 | assert.equal(element.textContent.trim(), 'I\'m a little teapot short and stout'); 705 | }); 706 | 707 | test('it adds an owner', async function (assert) { 708 | const owner = this.owner; 709 | 710 | @customElement('native-component-3') 711 | class NativeCustomElement extends HTMLElement { 712 | connectedCallback() { 713 | assert.equal(owner, getOwner(this), 'owner is obtainable'); 714 | } 715 | } 716 | setupNativeElementForTest(this.owner, NativeCustomElement, 'native-component-3'); 717 | await render(hbs``); 718 | }); 719 | 720 | test('it supports service injection', async function (assert) { 721 | class DummyService extends Service { 722 | message = 'foo'; 723 | } 724 | 725 | this.owner.register('service:dummy', DummyService); 726 | 727 | @customElement('native-component-4') 728 | class NativeCustomElement extends HTMLElement { 729 | @service dummy; 730 | 731 | connectedCallback() { 732 | this.innerText = this.dummy.message; 733 | } 734 | } 735 | setupNativeElementForTest(this.owner, NativeCustomElement, 'native-component-4'); 736 | await render(hbs``); 737 | 738 | const element = find('native-component-4'); 739 | assert.equal(element.textContent.trim(), 'foo'); 740 | }); 741 | }); 742 | 743 | module('unsupported', function () { 744 | test('it throws an error for unsupported classes', async function (assert) { 745 | try { 746 | @customElement('web-component') 747 | // eslint-disable-next-line no-unused-vars 748 | class EmberCustomElement {} 749 | } catch (error) { 750 | assert.equal(error.message, 'The target object for custom element `web-component` is not an Ember component, route or application.'); 751 | } 752 | }); 753 | }); 754 | 755 | module('tag name collisions', function () { 756 | test('it throws an error for a custom element already defined by something else', async function (assert) { 757 | if (!window.customElements.get('some-other-custom-element')) { 758 | class SomeOtherCustomElement extends HTMLElement { 759 | constructor() { 760 | super(...arguments); 761 | } 762 | } 763 | window.customElements.define('some-other-custom-element', SomeOtherCustomElement); 764 | } 765 | try { 766 | @customElement('some-other-custom-element') 767 | // eslint-disable-next-line no-unused-vars 768 | class EmberCustomElement extends EmberComponent {} 769 | } catch (error) { 770 | assert.equal(error.message, 'A custom element called `some-other-custom-element` is already defined by something else.'); 771 | } 772 | }); 773 | }); 774 | 775 | module('add-ons', function () { 776 | // Travis now fails when the dummy-add-on is a dependency 777 | // so for now we're skipping this since it's less important. 778 | test('can be used within an add-on', async function (assert) { 779 | /** 780 | * See lib/dummy-add-on to see how and where this 781 | * custom element is being defined. 782 | */ 783 | await render(hbs``); 784 | const element = find('dummy-add-on-component'); 785 | assert.equal(element.textContent.trim(), 'Foo Bar'); 786 | }); 787 | }); 788 | }); 789 | -------------------------------------------------------------------------------- /tests/integration/outlet-element-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-private-routing-service */ 2 | /* eslint-disable ember/no-test-this-render */ 3 | import { module, test, } from 'qunit'; 4 | import { setupRenderingTest } from 'ember-qunit'; 5 | import { find, 6 | render, 7 | settled 8 | } from '@ember/test-helpers'; 9 | import { hbs } from 'ember-cli-htmlbars'; 10 | import { customElement, EmberOutletElement } from 'ember-custom-elements'; 11 | import Route from '@ember/routing/route'; 12 | import { setupRouteTest, setupTestRouter, setupNativeElementForTest } from '../helpers/ember-custom-elements'; 13 | 14 | @customElement('outlet-element') 15 | class OutletElement extends EmberOutletElement {} 16 | 17 | module('Integration | Element | outlet-element', function(hooks) { 18 | setupRenderingTest(hooks); 19 | setupRouteTest(hooks); 20 | 21 | hooks.beforeEach(function() { 22 | setupNativeElementForTest(this.owner, OutletElement, 'outlet-element'); 23 | }); 24 | 25 | test('it renders', async function(assert) { 26 | this.owner.register('template:application', hbs`

Hello World

`); 27 | this.owner.resolveRegistration('router:main').map(function() { 28 | }); 29 | setupTestRouter(this.owner, function() {}); 30 | this.owner.lookup('router:main').transitionTo('/'); 31 | await render(hbs``); 32 | 33 | const element = find('outlet-element'); 34 | assert.equal(element.textContent.trim(), 'Hello World'); 35 | }); 36 | 37 | test('it renders a specific route', async function(assert) { 38 | this.owner.register('template:foo-bar', hbs`

Hello World

`); 39 | this.owner.resolveRegistration('router:main').map(function() { 40 | this.route('foo-bar'); 41 | }); 42 | setupTestRouter(this.owner, function() {}); 43 | this.owner.lookup('router:main').transitionTo('foo-bar'); 44 | await render(hbs``); 45 | 46 | const element = find('outlet-element'); 47 | assert.equal(element.textContent.trim(), 'Hello World'); 48 | }); 49 | 50 | test('it renders a named outlet', async function(assert) { 51 | class FooBarRoute extends Route { 52 | renderTemplate() { 53 | this.render('bar', { 54 | outlet: 'bar' 55 | }); 56 | super.renderTemplate(...arguments); 57 | } 58 | } 59 | this.owner.register('route:foo-bar', FooBarRoute); 60 | this.owner.register('template:application', hbs` 61 | 62 | 63 | `); 64 | this.owner.register('template:foo-bar', hbs`foobar`); 65 | this.owner.register('template:bar', hbs`bar`); 66 | setupTestRouter(this.owner, function() { 67 | this.route('foo-bar'); 68 | }); 69 | await this.owner.lookup('router:main').transitionTo('foo-bar'); 70 | await settled(); 71 | const named = find('[data-test-named-outlet]'); 72 | assert.equal(named.textContent.trim(), 'bar'); 73 | const unnamed = find('[data-test-unnamed-outlet]'); 74 | assert.equal(unnamed.textContent.trim(), ''); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import Application from 'dummy/app'; 2 | import config from 'dummy/config/environment'; 3 | import * as QUnit from 'qunit'; 4 | import { setApplication } from '@ember/test-helpers'; 5 | import { setup } from 'qunit-dom'; 6 | import { start } from 'ember-qunit'; 7 | 8 | setApplication(Application.create(config.APP)); 9 | 10 | setup(QUnit.assert); 11 | 12 | start(); 13 | -------------------------------------------------------------------------------- /tests/unit/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/tests/unit/.gitkeep -------------------------------------------------------------------------------- /tests/unit/lib/template-compiler-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/no-classic-components */ 2 | /* eslint-disable ember/no-classic-classes */ 3 | import { module, test } from 'qunit'; 4 | import { setupRenderingTest } from 'ember-qunit'; 5 | import { find, findAll, render } from '@ember/test-helpers'; 6 | import { hbs } from 'ember-cli-htmlbars'; 7 | import Component from '@ember/component'; 8 | import { compileTemplate } from 'ember-custom-elements/lib/template-compiler'; 9 | 10 | module('Unit | Utility | template-compiler', function (hooks) { 11 | setupRenderingTest(hooks); 12 | 13 | hooks.beforeEach(function () { 14 | this.owner.register('component:dummy-component', Component.extend({ 15 | tagName: '', 16 | layout: hbs` 17 |

Hello World

18 | {{#if (has-block)}} 19 |

{{yield}}

20 | {{/if}} 21 |
    22 | {{#each @items as |item|}} 23 |
  • {{item}}
  • 24 | {{/each}} 25 |
26 | ` 27 | })); 28 | }) 29 | 30 | test('it renders', async function (assert) { 31 | const template = compileTemplate('dummy-component', ['items']); 32 | this.blockContent = 'foo'; 33 | // eslint-disable-next-line ember/no-attrs-in-components 34 | this._attrs = { 35 | items: [ 36 | 'bar', 37 | 'baz', 38 | 'qux' 39 | ] 40 | }; 41 | await render(template); 42 | assert.equal(find('h2').textContent.trim(), 'Hello World'); 43 | assert.equal(find('h3').textContent.trim(), 'foo'); 44 | assert.equal(findAll('li')[0].textContent.trim(), 'bar'); 45 | assert.equal(findAll('li')[1].textContent.trim(), 'baz'); 46 | assert.equal(findAll('li')[2].textContent.trim(), 'qux'); 47 | }); 48 | }); -------------------------------------------------------------------------------- /vendor/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ravenstine/ember-custom-elements/7386bc69b004694f46b657d63fd8f9d683055829/vendor/.gitkeep --------------------------------------------------------------------------------