`);
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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.com/Ravenstine/ember-custom-elements)
2 | [](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 |
--------------------------------------------------------------------------------
/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`